| 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 |