返回 DeepSeek-Reasonix
docs_test.go
根目录 / internal / productdocs / docs_test.go
1 package productdocs
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/json"
7 "fmt"
8 "strings"
9 "sync"
10 "testing"
11 "testing/fstest"
12
13 productcontent "reasonix/docs"
14 "reasonix/internal/provider"
15 releasenotes "reasonix/release-notes"
16 )
17
18 func TestEmbeddedCatalogLoadsDeterministically(t *testing.T) {
19 first, err := loadCatalog(productcontent.Content)
20 if err != nil {
21 t.Fatalf("loadCatalog: %v", err)
22 }
23 second, err := loadCatalog(productcontent.Content)
24 if err != nil {
25 t.Fatalf("loadCatalog second time: %v", err)
26 }
27 if len(first.docs) < 40 {
28 t.Fatalf("embedded docs = %d, want at least 40", len(first.docs))
29 }
30 if len(first.sections) < len(first.docs) {
31 t.Fatalf("embedded sections = %d, docs = %d", len(first.sections), len(first.docs))
32 }
33 if first.digest == "" || first.digest != second.digest {
34 t.Fatalf("digest mismatch: %q != %q", first.digest, second.digest)
35 }
36 if _, ok := first.byPath["GUIDE.zh-CN.md"]; !ok {
37 t.Fatal("Chinese guide is missing from the embedded catalog")
38 }
39 if _, ok := first.byPath["GUIDE.md"]; !ok {
40 t.Fatal("English guide is missing from the embedded catalog")
41 }
42 }
43
44 func TestEmbeddedAndSourceManifestsMatch(t *testing.T) {
45 embedded, err := EmbeddedManifest()
46 if err != nil {
47 t.Fatalf("EmbeddedManifest: %v", err)
48 }
49 source, err := SourceManifest(productcontent.Content, releasenotes.Content)
50 if err != nil {
51 t.Fatalf("SourceManifest: %v", err)
52 }
53 if embedded.Digest != source.Digest || embedded.Documents != source.Documents || embedded.Sections != source.Sections {
54 t.Fatalf("embedded manifest %#v does not match source %#v", embedded, source)
55 }
56 if !strings.HasPrefix(embedded.Digest, "sha256:") || embedded.Version == "" || embedded.Revision == "" {
57 t.Fatalf("manifest is missing build identity: %#v", embedded)
58 }
59 if embedded.ReleaseNotes < 10 {
60 t.Fatalf("embedded release notes = %d, want release history", embedded.ReleaseNotes)
61 }
62 }
63
64 func TestDocsCommandOverviewAndSearchUseEmbeddedCorpus(t *testing.T) {
65 overview, err := CommandOverview("zh-CN")
66 if err != nil {
67 t.Fatal(err)
68 }
69 for _, want := range []string{"内置 Reasonix 文档", "version=", "revision=", "digest=sha256:", "/docs 1.19.5 更新日志"} {
70 if !strings.Contains(overview, want) {
71 t.Fatalf("command overview missing %q:\n%s", want, overview)
72 }
73 }
74 qualified, err := CommandOverviewFor("en", "/reasonix:docs")
75 if err != nil {
76 t.Fatal(err)
77 }
78 if !strings.Contains(qualified, "Usage: /reasonix:docs <question>") || strings.Contains(qualified, "Example: /docs ") {
79 t.Fatalf("qualified command overview used the wrong invocation:\n%s", qualified)
80 }
81
82 results, err := SearchEmbedded(context.Background(), "1.19.5 更新日志")
83 if err != nil {
84 t.Fatal(err)
85 }
86 firstStart := strings.Index(results, "\n1. ")
87 firstEnd := strings.Index(results, "\n2. ")
88 if firstStart < 0 || firstEnd <= firstStart || !strings.Contains(results[firstStart:firstEnd], "path=changelog/v1.19.5.zh-CN.md") || !strings.Contains(results, "digest=sha256:") {
89 t.Fatalf("command search did not use the embedded release catalog:\n%s", results)
90 }
91 }
92
93 func TestExtensionDeveloperGuidesAreEmbeddedAndSearchable(t *testing.T) {
94 c, err := loadCatalog(productcontent.Content)
95 if err != nil {
96 t.Fatal(err)
97 }
98 for _, path := range []string{
99 "EXTENSIONS.md",
100 "EXTENSIONS.zh-CN.md",
101 "EXTENSION_PROTOCOL.md",
102 "EXTENSION_PROTOCOL.zh-CN.md",
103 "PLUGIN_PACKAGES.md",
104 "PLUGIN_PACKAGES.zh-CN.md",
105 } {
106 if _, ok := c.byPath[path]; !ok {
107 t.Fatalf("extension developer guide %q is missing from the embedded catalog", path)
108 }
109 }
110
111 tool := &docsTool{catalog: c}
112 english, err := tool.search(context.Background(), "sidecar Manifest v1 runtime Go SDK", "en", "all", 10)
113 if err != nil {
114 t.Fatal(err)
115 }
116 if !strings.Contains(english, "path=docs/EXTENSIONS.md") || !strings.Contains(english, "path=docs/PLUGIN_PACKAGES.md") {
117 t.Fatalf("English extension search did not expose the overview and manifest reference:\n%s", english)
118 }
119
120 chinese, err := tool.search(context.Background(), "Sidecar 插件 Manifest v1 扩展开发", "zh-CN", "all", 10)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if !strings.Contains(chinese, "path=docs/EXTENSIONS.zh-CN.md") || !strings.Contains(chinese, "path=docs/PLUGIN_PACKAGES.zh-CN.md") {
125 t.Fatalf("Chinese extension search did not expose the overview and manifest reference:\n%s", chinese)
126 }
127 }
128
129 func TestSourceManifestChangesWithMarkdownBytes(t *testing.T) {
130 first, err := SourceManifest(fstest.MapFS{"GUIDE.md": {Data: []byte("# Guide\n\nFirst.\n")}}, nil)
131 if err != nil {
132 t.Fatal(err)
133 }
134 second, err := SourceManifest(fstest.MapFS{"GUIDE.md": {Data: []byte("# Guide\n\nSecond.\n")}}, nil)
135 if err != nil {
136 t.Fatal(err)
137 }
138 if first.Digest == second.Digest {
139 t.Fatalf("different Markdown bytes produced the same digest: %s", first.Digest)
140 }
141 }
142
143 func TestSourceManifestDigestUsesUnambiguousFileFraming(t *testing.T) {
144 firstA := []byte("# Guide\n\nalpha")
145 firstB := []byte("betaB.md\x00# Reference\n\ngamma")
146 secondA := []byte("# Guide\n\nalphaB.md\x00beta")
147 secondB := []byte("# Reference\n\ngamma")
148
149 legacyDigest := func(a, b []byte) [sha256.Size]byte {
150 h := sha256.New()
151 for _, record := range []struct {
152 name string
153 data []byte
154 }{{"A.md", a}, {"B.md", b}} {
155 _, _ = h.Write([]byte(record.name))
156 _, _ = h.Write([]byte{0})
157 _, _ = h.Write(record.data)
158 }
159 var digest [sha256.Size]byte
160 copy(digest[:], h.Sum(nil))
161 return digest
162 }
163 if legacyDigest(firstA, firstB) != legacyDigest(secondA, secondB) {
164 t.Fatal("test fixture must collide under the legacy delimiter-only framing")
165 }
166
167 first, err := SourceManifest(fstest.MapFS{
168 "A.md": {Data: firstA},
169 "B.md": {Data: firstB},
170 }, nil)
171 if err != nil {
172 t.Fatal(err)
173 }
174 second, err := SourceManifest(fstest.MapFS{
175 "A.md": {Data: secondA},
176 "B.md": {Data: secondB},
177 }, nil)
178 if err != nil {
179 t.Fatal(err)
180 }
181 if first.Documents != second.Documents || first.Sections != second.Sections {
182 t.Fatalf("fixture changed corpus shape: first=%#v second=%#v", first, second)
183 }
184 if first.Digest == second.Digest {
185 t.Fatalf("different file boundaries produced the same digest: %s", first.Digest)
186 }
187 }
188
189 func TestSearchRejectsOversizedQueriesBeforeRetrieval(t *testing.T) {
190 oversized := strings.Repeat("文", maxQueryRunes+1)
191 tests := []struct {
192 name string
193 run func() error
194 }{
195 {
196 name: "tool",
197 run: func() error {
198 args, err := json.Marshal(map[string]any{"operation": "search", "query": oversized})
199 if err != nil {
200 return err
201 }
202 _, err = NewTool().Execute(context.Background(), args)
203 return err
204 },
205 },
206 {
207 name: "slash command host path",
208 run: func() error {
209 _, err := SearchEmbedded(context.Background(), oversized)
210 return err
211 },
212 },
213 }
214 for _, test := range tests {
215 t.Run(test.name, func(t *testing.T) {
216 err := test.run()
217 if err == nil || !strings.Contains(err.Error(), "maximum 4096 characters") {
218 t.Fatalf("oversized query error = %v", err)
219 }
220 })
221 }
222 }
223
224 func TestReleaseNotesAreSearchableInBothLanguages(t *testing.T) {
225 c, err := loadCatalogWithReleaseNotes(productcontent.Content, releasenotes.Content)
226 if err != nil {
227 t.Fatal(err)
228 }
229 tl := &docsTool{catalog: c}
230
231 english, err := tl.search(context.Background(), "v1.19.5 usage statistics dashboard", "en", "user", 5)
232 if err != nil {
233 t.Fatal(err)
234 }
235 if !strings.Contains(english, "path=changelog/v1.19.5.md") || !strings.Contains(english, "source=release-notes/releases.json#v1.19.5") {
236 t.Fatalf("English release search missing versioned changelog:\n%s", english)
237 }
238
239 chinese, err := tl.search(context.Background(), "v1.19.5 用量统计面板", "zh-CN", "user", 5)
240 if err != nil {
241 t.Fatal(err)
242 }
243 if !strings.Contains(chinese, "path=changelog/v1.19.5.zh-CN.md") {
244 t.Fatalf("Chinese release search missing versioned changelog:\n%s", chinese)
245 }
246
247 sections, err := tl.read("", "changelog/v1.19.5.zh-CN.md")
248 if err != nil {
249 t.Fatal(err)
250 }
251 if !strings.Contains(sections, "source: release-notes/releases.json#v1.19.5") {
252 t.Fatalf("release section listing missing JSON provenance:\n%s", sections)
253 }
254 section, err := tl.read("changelog/v1.19.5.zh-CN.md::s002", "")
255 if err != nil {
256 t.Fatal(err)
257 }
258 if !strings.Contains(section, "source: release-notes/releases.json#v1.19.5 rendered-lines=") {
259 t.Fatalf("release section missing rendered provenance:\n%s", section)
260 }
261 }
262
263 func TestReleaseSearchDoesNotTreatPreviewAsExactStableVersion(t *testing.T) {
264 docsFS := fstest.MapFS{
265 "GUIDE.md": {Data: []byte("# Guide\n\nEmbedded documentation.\n")},
266 }
267 releaseNotesFS := fstest.MapFS{
268 "releases.json": {Data: []byte(`{
269 "releases": [
270 {"version":"1.19.0","title":{"en":"Stable","zh":"稳定版"},"summary":{"en":"Stable changelog","zh":"稳定版更新日志"}},
271 {"version":"1.19.0-preview.1","title":{"en":"Preview","zh":"预览版"},"summary":{"en":"Preview-only changelog","zh":"仅预览版更新日志"}}
272 ]
273 }`)},
274 }
275 c, err := loadCatalogWithReleaseNotes(docsFS, releaseNotesFS)
276 if err != nil {
277 t.Fatal(err)
278 }
279 tl := &docsTool{catalog: c}
280
281 stable, err := tl.search(context.Background(), "1.19.0 changelog", "en", "user", 10)
282 if err != nil {
283 t.Fatal(err)
284 }
285 if !strings.Contains(stable, "path=changelog/v1.19.0.md") {
286 t.Fatalf("stable release search missing exact changelog:\n%s", stable)
287 }
288 if strings.Contains(stable, "path=changelog/v1.19.0-preview.1.md") {
289 t.Fatalf("stable release search treated preview as an exact version match:\n%s", stable)
290 }
291
292 preview, err := tl.search(context.Background(), "1.19.0-preview.1 changelog", "en", "user", 10)
293 if err != nil {
294 t.Fatal(err)
295 }
296 if !strings.Contains(preview, "path=changelog/v1.19.0-preview.1.md") {
297 t.Fatalf("preview release search missing exact changelog:\n%s", preview)
298 }
299 }
300
301 func TestSourceManifestChangesWithReleaseCatalogBytes(t *testing.T) {
302 docsFS := fstest.MapFS{"GUIDE.md": {Data: []byte("# Guide\n\nBody.\n")}}
303 firstNotes := fstest.MapFS{"releases.json": {Data: []byte(`{"releases":[{"version":"1.0.0","title":{"en":"First","zh":"第一"},"summary":{"en":"Body","zh":"正文"}}]}`)}}
304 secondNotes := fstest.MapFS{"releases.json": {Data: []byte(`{"releases":[{"version":"1.0.0","title":{"en":"Second","zh":"第二"},"summary":{"en":"Body","zh":"正文"}}]}`)}}
305 first, err := SourceManifest(docsFS, firstNotes)
306 if err != nil {
307 t.Fatal(err)
308 }
309 second, err := SourceManifest(docsFS, secondNotes)
310 if err != nil {
311 t.Fatal(err)
312 }
313 if first.Digest == second.Digest || first.ReleaseNotes != 1 || second.ReleaseNotes != 1 {
314 t.Fatalf("release catalog was not bound into manifests: first=%#v second=%#v", first, second)
315 }
316 }
317
318 func TestReleaseCatalogRejectsUnsafeVirtualPathVersion(t *testing.T) {
319 _, _, err := renderReleaseDocuments([]byte(`{"releases":[{"version":"../secret","title":{"en":"Bad","zh":"错误"},"summary":{"en":"Body","zh":"正文"}}]}`))
320 if err == nil || !strings.Contains(err.Error(), "invalid version") {
321 t.Fatalf("unsafe release version error = %v", err)
322 }
323 }
324
325 func TestSearchPrefersRelevantChineseAndEnglishSections(t *testing.T) {
326 c, err := loadCatalog(productcontent.Content)
327 if err != nil {
328 t.Fatal(err)
329 }
330 tl := &docsTool{catalog: c}
331
332 zh, err := tl.search(context.Background(), "工具权限 Auto Yolo 自动批准", "auto", "all", 5)
333 if err != nil {
334 t.Fatalf("Chinese search: %v", err)
335 }
336 if !strings.Contains(zh, "docs/TOOL_APPROVAL_MODES.zh-CN.md") {
337 t.Fatalf("Chinese search did not find the permission guide:\n%s", zh)
338 }
339
340 en, err := tl.search(context.Background(), "REASONIX_HOME configuration paths", "auto", "all", 5)
341 if err != nil {
342 t.Fatalf("English search: %v", err)
343 }
344 if !strings.Contains(en, "docs/CONFIG_PATHS.md") {
345 t.Fatalf("English search did not find configuration paths:\n%s", en)
346 }
347 }
348
349 func TestToolSearchReadAndListRoundTrip(t *testing.T) {
350 c, err := loadCatalog(fstest.MapFS{
351 "GUIDE.md": {Data: []byte("# Guide\n\n## Configure MCP\n\nSet `reasonix.toml` before connecting plugins.\n")},
352 })
353 if err != nil {
354 t.Fatal(err)
355 }
356 tl := &docsTool{catalog: c}
357 search, err := tl.Execute(context.Background(), json.RawMessage(`{"operation":"search","query":"reasonix.toml MCP"}`))
358 if err != nil {
359 t.Fatalf("search: %v", err)
360 }
361 if !strings.Contains(search, "GUIDE.md::s002") {
362 t.Fatalf("search result missing stable section id:\n%s", search)
363 }
364 read, err := tl.Execute(context.Background(), json.RawMessage(`{"operation":"read","section_id":"GUIDE.md::s002"}`))
365 if err != nil {
366 t.Fatalf("read: %v", err)
367 }
368 if !strings.Contains(read, "Set `reasonix.toml`") || !strings.Contains(read, "source: docs/GUIDE.md:3-5") {
369 t.Fatalf("read result missing content or provenance:\n%s", read)
370 }
371 list, err := tl.Execute(context.Background(), json.RawMessage(`{"operation":"list","language":"en"}`))
372 if err != nil {
373 t.Fatalf("list: %v", err)
374 }
375 if !strings.Contains(list, "path=docs/GUIDE.md") {
376 t.Fatalf("list result missing guide:\n%s", list)
377 }
378 for _, field := range []string{"version=", "revision=", "digest=sha256:"} {
379 if !strings.Contains(list, field) {
380 t.Fatalf("list result missing %s:\n%s", field, list)
381 }
382 }
383 }
384
385 func TestParserIgnoresHeadingsInsideFencedCode(t *testing.T) {
386 doc := parseDocument("EXAMPLE.md", "# Example\n\n## Real\n\n```md\n### Not a section\n```\n\n ### Indented code\n\nText about C#.\n")
387 if len(doc.sections) != 2 {
388 t.Fatalf("sections = %d, want 2", len(doc.sections))
389 }
390 if !strings.Contains(doc.sections[1].content, "### Not a section") || !strings.Contains(doc.sections[1].content, "### Indented code") {
391 t.Fatalf("code-block heading content was lost: %q", doc.sections[1].content)
392 }
393 }
394
395 func TestGoldmarkHeadingsPreserveTextAndSetextSemantics(t *testing.T) {
396 doc := parseDocument("EXAMPLE.md", "# Example\n\n### Configure *MCP* with `reasonix.toml`, C#, and <https://example.com>\n\nBody.\n\nSetext section\n--------------\n\nMore.\n")
397 if len(doc.sections) != 3 {
398 t.Fatalf("sections = %d, want 3", len(doc.sections))
399 }
400 if doc.sections[1].heading != "Example > Configure MCP with reasonix.toml, C#, and https://example.com" {
401 t.Fatalf("ATX heading = %q", doc.sections[1].heading)
402 }
403 if doc.sections[2].heading != "Example > Setext section" {
404 t.Fatalf("Setext heading = %q", doc.sections[2].heading)
405 }
406 }
407
408 func TestReadRejectsUntrustedPathsAndUnknownSections(t *testing.T) {
409 c, err := loadCatalog(fstest.MapFS{
410 "GUIDE.md": {Data: []byte("# Guide\n\nSafe content.\n")},
411 })
412 if err != nil {
413 t.Fatal(err)
414 }
415 tl := &docsTool{catalog: c}
416 if _, err := tl.read("../../secret::s001", ""); err == nil {
417 t.Fatal("unknown section id should be rejected")
418 }
419 if _, err := tl.read("", "../../secret.md"); err == nil {
420 t.Fatal("path traversal should be rejected")
421 }
422 }
423
424 func TestCatalogRejectsInvalidUTF8(t *testing.T) {
425 _, err := loadCatalog(fstest.MapFS{"BAD.md": {Data: []byte{0xff, 0xfe}}})
426 if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
427 t.Fatalf("invalid UTF-8 error = %v", err)
428 }
429 }
430
431 func TestDocsToolContractIsStableAndReadOnly(t *testing.T) {
432 tl := NewTool()
433 if tl.Name() != "docs" || !tl.ReadOnly() {
434 t.Fatalf("tool contract = name %q readOnly %v", tl.Name(), tl.ReadOnly())
435 }
436 canonical := provider.CanonicalizeSchema(tl.Schema())
437 if !json.Valid(canonical) {
438 t.Fatalf("docs schema is invalid or unstable: %s", canonical)
439 }
440 contract := tl.Name() + "\n" + tl.Description() + "\n" + string(canonical)
441 got := fmt.Sprintf("%x", sha256.Sum256([]byte(contract)))
442 const want = "0113a592b6bcba5dd2be78c552f95ca27337534b6bb55b7b5e6fd89414f95be5"
443 if got != want {
444 t.Fatalf("provider-visible docs contract changed: got %s, want %s", got, want)
445 }
446 }
447
448 func TestDocsToolSupportsConcurrentReadOnlyCalls(t *testing.T) {
449 tl := NewTool()
450 const workers = 12
451 var wg sync.WaitGroup
452 errs := make(chan error, workers)
453 for i := 0; i < workers; i++ {
454 wg.Add(1)
455 go func(index int) {
456 defer wg.Done()
457 args := json.RawMessage(`{"operation":"search","query":"MCP configuration","limit":2}`)
458 if index%2 == 1 {
459 args = json.RawMessage(`{"operation":"list","language":"zh-CN","audience":"user"}`)
460 }
461 out, err := tl.Execute(context.Background(), args)
462 if err != nil {
463 errs <- err
464 return
465 }
466 if !strings.Contains(out, "digest=sha256:") {
467 errs <- fmt.Errorf("missing corpus digest: %s", out)
468 }
469 }(i)
470 }
471 wg.Wait()
472 close(errs)
473 for err := range errs {
474 t.Error(err)
475 }
476 }
477
477 lines GO