@apmantza/greedysearch-pi 1.9.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test.mjs CHANGED
@@ -1,534 +1,1342 @@
1
- #!/usr/bin/env node
2
- // test.mjs — Cross-platform test runner for GreedySearch (Windows + Unix)
3
- //
4
- // Usage:
5
- // node test.mjs # run all tests (~8-12 min)
6
- // node test.mjs quick # skip slow tests (~3 min)
7
- // node test.mjs smoke # basic health check (~60s)
8
- // node test.mjs parallel # race condition tests only
9
- // node test.mjs flags # flag/option tests only
10
- // node test.mjs edge # edge case tests only
11
- // node test.mjs unit # fast unit tests only (no Chrome needed)
12
-
13
- import { spawn } from "node:child_process";
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { dirname, join } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
-
18
- const __dir = dirname(fileURLToPath(import.meta.url));
19
-
20
- // ANSI colors
21
- const C = {
22
- red: "\x1b[31m",
23
- green: "\x1b[32m",
24
- yellow: "\x1b[33m",
25
- blue: "\x1b[34m",
26
- cyan: "\x1b[36m",
27
- reset: "\x1b[0m",
28
- };
29
-
30
- const mode = process.argv[2] || "all";
31
- const resultsDir = join(__dir, "results", `test_${Date.now()}`);
32
- mkdirSync(resultsDir, { recursive: true });
33
-
34
- let pass = 0,
35
- fail = 0,
36
- warn = 0,
37
- skip = 0;
38
- const failures = [],
39
- warnings = [];
40
- const startTime = Date.now();
41
-
42
- // ─────────────────────────────────────────────────────────────────────────────
43
- // Helpers
44
- // ─────────────────────────────────────────────────────────────────────────────
45
-
46
- function passMsg(msg) {
47
- pass++;
48
- console.log(` ${C.green}✓${C.reset} ${msg}`);
49
- }
50
- function failMsg(msg) {
51
- fail++;
52
- console.log(` ${C.red}✗${C.reset} ${msg}`);
53
- failures.push(msg);
54
- }
55
- function warnMsg(msg) {
56
- warn++;
57
- console.log(` ${C.yellow}⚠${C.reset} ${msg}`);
58
- warnings.push(msg);
59
- }
60
- function section(title) {
61
- console.log(`\n${C.blue}${title}${C.reset}`);
62
- }
63
- function subsection(title) {
64
- console.log(`\n${C.yellow}${title}${C.reset}`);
65
- }
66
-
67
- async function runNode(args, timeoutSec = 60) {
68
- return new Promise((resolve, reject) => {
69
- const proc = spawn(process.execPath, args, {
70
- cwd: __dir,
71
- stdio: ["ignore", "pipe", "pipe"],
72
- timeout: timeoutSec * 1000,
73
- });
74
- let out = "",
75
- err = "";
76
- proc.stdout.on("data", (d) => (out += d));
77
- proc.stderr.on("data", (d) => (err += d));
78
- proc.on("close", (code) => resolve({ code, out, err }));
79
- proc.on("error", reject);
80
- });
81
- }
82
-
83
- function checkJson(file, checkFn) {
84
- try {
85
- const data = JSON.parse(readFileSync(file, "utf8"));
86
- return checkFn(data);
87
- } catch (e) {
88
- return `PARSE_ERROR: ${e.message}`;
89
- }
90
- }
91
-
92
- // ─────────────────────────────────────────────────────────────────────────────
93
- // Unit Tests (no Chrome required)
94
- // ─────────────────────────────────────────────────────────────────────────────
95
-
96
- if (["", "all", "unit", "quick", "smoke"].includes(mode)) {
97
- section("🧪 Unit Tests");
98
-
99
- subsection("stripQuotes — param double-escaping workaround (issue #2)");
100
- const { stripQuotes } = await import("./src/tools/shared.ts");
101
-
102
- const stripCases = [
103
- // [input, expected, label]
104
- ['"all"', "all", 'double-escaped enum: \\"all\\"'],
105
- ['"standard"', "standard", 'double-escaped enum: \\"standard\\"'],
106
- ['"deep"', "deep", 'double-escaped enum: \\"deep\\"'],
107
- ["all", "all", "already clean: all"],
108
- ["standard", "standard", "already clean: standard"],
109
- ["", "", "empty string"],
110
- ];
111
- for (const [input, expected, label] of stripCases) {
112
- const got = stripQuotes(input);
113
- if (got === expected) passMsg(`stripQuotes: ${label}`);
114
- else
115
- failMsg(`stripQuotes: ${label} — expected "${expected}", got "${got}"`);
116
- }
117
-
118
- subsection("Tool param normalization — greedy_search engine/depth");
119
- const normalizeEnum = (val, fallback) =>
120
- stripQuotes(val ?? fallback) || fallback;
121
-
122
- const normCases = [
123
- // [raw, fallback, expected, label]
124
- ['"all"', "all", "all", 'engine \\"all\\" (double-escaped)'],
125
- [
126
- '"perplexity"',
127
- "all",
128
- "perplexity",
129
- 'engine \\"perplexity\\" (double-escaped)',
130
- ],
131
- [
132
- '"standard"',
133
- "standard",
134
- "standard",
135
- 'depth \\"standard\\" (double-escaped)',
136
- ],
137
- ['"deep"', "standard", "deep", 'depth \\"deep\\" (double-escaped)'],
138
- [undefined, "all", "all", "engine undefined → default"],
139
- [undefined, "standard", "standard", "depth undefined → default"],
140
- ["gemini", "all", "gemini", "engine clean string"],
141
- ];
142
- for (const [raw, fallback, expected, label] of normCases) {
143
- const got = normalizeEnum(raw, fallback);
144
- if (got === expected) passMsg(`normalize: ${label}`);
145
- else failMsg(`normalize: ${label} — expected "${expected}", got "${got}"`);
146
- }
147
-
148
- subsection(
149
- "Bing/Perplexity error matching — headless → visible auto-retry detection",
150
- );
151
- // The auto-retry in bin/search.mjs uses this shared helper to decide
152
- // whether to switch from headless to visible Chrome and retry.
153
- const {
154
- findHeadlessBlockedEngines,
155
- isHeadlessBlockedError,
156
- isManualVerificationError,
157
- } = await import("./src/search/recovery.mjs");
158
- const cfTestCases = [
159
- // [error message, expected match, label]
160
- ["input not found", true, 'legacy pattern: "input not found"'],
161
- [
162
- "Copilot input not found",
163
- true,
164
- 'extended: "input not found" in sentence',
165
- ],
166
- ["VERIFICATION REQUIRED", true, 'legacy pattern: "VERIFICATION REQUIRED"'],
167
- ["verification failed", true, 'extended: "verification" in sentence'],
168
- [
169
- "Clipboard interceptor returned empty text",
170
- true,
171
- "new: clipboard error (headless Cloudflare block)",
172
- ],
173
- [
174
- "[bing] Clipboard empty, retrying in 2s...",
175
- true,
176
- "new: clipboard empty retry message",
177
- ],
178
- [
179
- "Cloudflare challenge detected — content blocked in headless",
180
- true,
181
- "new: Cloudflare detection triggers visible retry",
182
- ],
183
- [
184
- "Network timeout after 30000ms",
185
- true,
186
- "new: timeout triggers visible retry",
187
- ],
188
- ["", false, "empty string"],
189
- ];
190
- for (const [error, expected, label] of cfTestCases) {
191
- const matched = isHeadlessBlockedError(error);
192
- if (matched === expected) passMsg(`cfPattern: ${label}`);
193
- else failMsg(`cfPattern: ${label} — expected ${expected}, got ${matched}`);
194
- }
195
-
196
- subsection("Manual verification detection keeps visible Chrome open");
197
- const manualCases = [
198
- [
199
- "Perplexity verification required — please solve it manually in the browser window",
200
- true,
201
- "perplexity manual verification",
202
- ],
203
- [
204
- "Copilot verification required — please solve it manually in the browser window",
205
- true,
206
- "bing manual verification",
207
- ],
208
- ["selector changed", false, "non-verification extractor failure"],
209
- ];
210
- for (const [error, expected, label] of manualCases) {
211
- const matched = isManualVerificationError(error);
212
- if (matched === expected) passMsg(`manualVerification: ${label}`);
213
- else
214
- failMsg(
215
- `manualVerification: ${label} — expected ${expected}, got ${matched}`,
216
- );
217
- }
218
-
219
- const retryEngines = findHeadlessBlockedEngines({
220
- perplexity: { error: "Clipboard interceptor returned empty text" },
221
- bing: { error: "Copilot verification required" },
222
- google: { error: "Google verification required" },
223
- });
224
- if (retryEngines.join(",") === "perplexity,bing") {
225
- passMsg("visible retry engines: perplexity and bing only");
226
- } else {
227
- failMsg(
228
- `visible retry engines: expected perplexity,bing, got ${retryEngines.join(",")}`,
229
- );
230
- }
231
-
232
- const pplxTestCases = [
233
- ["ask-input selector not found", true, 'legacy: "ask-input"'],
234
- [
235
- "Clipboard interceptor returned empty text",
236
- true,
237
- "new: clipboard also triggers for perplexity",
238
- ],
239
- ["Perplexity timeout", true, "timeout triggers visible retry"],
240
- ];
241
- for (const [error, expected, label] of pplxTestCases) {
242
- const matched = isHeadlessBlockedError(error);
243
- if (matched === expected) passMsg(`pplxPattern: ${label}`);
244
- else
245
- failMsg(`pplxPattern: ${label} — expected ${expected}, got ${matched}`);
246
- }
247
-
248
- subsection("mode marker file — isChromeHeadless detection");
249
- const { isChromeHeadless: isHeadlessCheck } = await import(
250
- "./src/search/chrome.mjs"
251
- );
252
- const headlessResult = typeof isHeadlessCheck === "function";
253
- if (headlessResult) passMsg("isChromeHeadless: function exists");
254
- else failMsg("isChromeHeadless: not a function");
255
- }
256
-
257
- // ─────────────────────────────────────────────────────────────────────────────
258
- // Pre-flight Checks
259
- // ─────────────────────────────────────────────────────────────────────────────
260
-
261
- section("🔧 Pre-flight Checks");
262
-
263
- // Check CDP module
264
- if (!existsSync(join(__dir, "bin", "cdp.mjs"))) {
265
- failMsg("bin/cdp.mjs missing - extension not properly installed");
266
- process.exit(1);
267
- } else {
268
- passMsg("CDP module present");
269
- }
270
-
271
- // Check Node version
272
- const nodeVersion = process.version.match(/v(\d+)/)?.[1];
273
- if (nodeVersion && parseInt(nodeVersion) >= 22) {
274
- passMsg(`Node.js 22+ (${process.version})`);
275
- } else {
276
- warnMsg(`Node.js ${process.version} (22+ recommended)`);
277
- }
278
-
279
- // Check Chrome launcher
280
- if (!existsSync(join(__dir, "bin", "launch.mjs"))) {
281
- warnMsg("bin/launch.mjs missing - Chrome auto-launch may fail");
282
- } else {
283
- passMsg("Chrome launcher present");
284
- }
285
-
286
- // ─────────────────────────────────────────────────────────────────────────────
287
- // Flag & Option Tests
288
- // ─────────────────────────────────────────────────────────────────────────────
289
-
290
- if (["", "all", "flags", "quick", "smoke"].includes(mode)) {
291
- section("🏷️ Flag & Option Tests");
292
-
293
- subsection("Testing --inline flag (stdout output)...");
294
- const inlineFile = join(resultsDir, "flag_inline.json");
295
- const { out: inlineOut } = await runNode(
296
- [join(__dir, "bin", "search.mjs"), "perplexity", "what is AI", "--inline"],
297
- 90,
298
- );
299
- if (inlineOut) {
300
- writeFileSync(inlineFile, inlineOut, "utf8");
301
- const hasAnswer = checkJson(
302
- inlineFile,
303
- (d) => d.answer || d.perplexity?.answer,
304
- );
305
- if (hasAnswer) {
306
- passMsg("--inline: JSON output to stdout");
307
- } else {
308
- warnMsg(`--inline: ${hasAnswer}`);
309
- }
310
- } else {
311
- failMsg("--inline: timeout or no output");
312
- }
313
-
314
- subsection("Testing engine aliases...");
315
- for (const alias of ["p", "g", "b"]) {
316
- const aliasFile = join(resultsDir, `alias_${alias}.json`);
317
- const { out: _aliasOut } = await runNode(
318
- [
319
- join(__dir, "bin", "search.mjs"),
320
- alias,
321
- "test query",
322
- "--out",
323
- aliasFile,
324
- ],
325
- 60,
326
- );
327
- if (existsSync(aliasFile) && aliasFile.length > 0) {
328
- passMsg(`alias '${alias}': search completed`);
329
- } else {
330
- warnMsg(`alias '${alias}': failed (may be expected for some engines)`);
331
- }
332
- }
333
- }
334
-
335
- // ─────────────────────────────────────────────────────────────────────────────
336
- // Edge Case Tests
337
- // ─────────────────────────────────────────────────────────────────────────────
338
-
339
- if (["", "all", "edge", "quick"].includes(mode)) {
340
- section("🔍 Edge Case Tests");
341
-
342
- subsection("Test 1: Special characters in query...");
343
- const specialFile = join(resultsDir, "edge_special.json");
344
- await runNode(
345
- [
346
- join(__dir, "bin", "search.mjs"),
347
- "perplexity",
348
- "C++ memory management & pointers",
349
- "--out",
350
- specialFile,
351
- ],
352
- 90,
353
- );
354
- if (existsSync(specialFile)) {
355
- const queryCheck = checkJson(
356
- specialFile,
357
- (d) => d.query?.includes("C++") && d.query?.includes("&"),
358
- );
359
- if (queryCheck) {
360
- passMsg("Edge1: special chars preserved");
361
- } else {
362
- warnMsg("Edge1: query mangled");
363
- }
364
- } else {
365
- warnMsg("Edge1: search failed");
366
- }
367
-
368
- subsection("Test 2: Very short query...");
369
- const shortFile = join(resultsDir, "edge_short.json");
370
- await runNode(
371
- [
372
- join(__dir, "bin", "search.mjs"),
373
- "perplexity",
374
- "Docker",
375
- "--out",
376
- shortFile,
377
- ],
378
- 90,
379
- );
380
- if (existsSync(shortFile)) {
381
- const hasAnswer = checkJson(shortFile, (d) => d.answer?.length > 10);
382
- if (hasAnswer) {
383
- passMsg("Edge2: short query handled");
384
- } else {
385
- warnMsg("Edge2: no answer");
386
- }
387
- } else {
388
- warnMsg("Edge2: timeout");
389
- }
390
-
391
- subsection("Test 3: Unicode/international characters...");
392
- const unicodeFile = join(resultsDir, "edge_unicode.json");
393
- await runNode(
394
- [
395
- join(__dir, "bin", "search.mjs"),
396
- "google",
397
- "日本のAI技術について教えて",
398
- "--out",
399
- unicodeFile,
400
- ],
401
- 120,
402
- );
403
- if (existsSync(unicodeFile)) {
404
- const unicodeCheck = checkJson(unicodeFile, (d) =>
405
- d.query?.includes("日本"),
406
- );
407
- if (unicodeCheck) {
408
- passMsg("Edge3: unicode preserved");
409
- } else {
410
- warnMsg("Edge3: unicode mangled");
411
- }
412
- } else {
413
- warnMsg("Edge3: timeout");
414
- }
415
- }
416
-
417
- // ─────────────────────────────────────────────────────────────────────────────
418
- // GitHub Fetch Tests
419
- // ─────────────────────────────────────────────────────────────────────────────
420
-
421
- if (["", "all", "edge", "quick", "smoke"].includes(mode)) {
422
- section("🐙 GitHub Fetch Tests");
423
-
424
- subsection("Test 1: Blob file fetch (raw URL)...");
425
- const ghBlobFile = join(resultsDir, "gh_blob.json");
426
- const blobScript = `
427
- import { fetchGitHubContent } from '../../src/github.mjs';
428
- import { writeFileSync } from 'fs';
429
- try {
430
- const r = await fetchGitHubContent('https://github.com/expressjs/express/blob/master/Readme.md');
431
- writeFileSync('${ghBlobFile.replace(/\\/g, "\\\\")}', JSON.stringify(r));
432
- } catch(e) {
433
- writeFileSync('${ghBlobFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: false, error: e.message }));
434
- }
435
- `;
436
- const blobTmp = join(resultsDir, "_gh_blob_test.mjs");
437
- writeFileSync(blobTmp, blobScript, "utf8");
438
- await runNode([blobTmp], 20);
439
-
440
- if (existsSync(ghBlobFile)) {
441
- const result = checkJson(
442
- ghBlobFile,
443
- (r) => r.ok && r.content?.length > 100,
444
- );
445
- if (result) {
446
- passMsg("GitHub blob: content fetched");
447
- } else {
448
- failMsg("GitHub blob: failed");
449
- }
450
- } else {
451
- failMsg("GitHub blob: no output");
452
- }
453
-
454
- subsection("Test 2: HTTP fetcher pipeline...");
455
- const ghFetchFile = join(resultsDir, "gh_fetcher.json");
456
- const fetcherScript = `
457
- import { fetchSourceHttp } from '../../src/fetcher.mjs';
458
- import { writeFileSync } from 'fs';
459
- try {
460
- const r = await fetchSourceHttp('https://github.com/expressjs/express/blob/master/Readme.md');
461
- writeFileSync('${ghFetchFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: r.ok, length: r.markdown?.length, error: r.error }));
462
- } catch(e) {
463
- writeFileSync('${ghFetchFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: false, error: e.message }));
464
- }
465
- `;
466
- const fetcherTmp = join(resultsDir, "_gh_fetcher_test.mjs");
467
- writeFileSync(fetcherTmp, fetcherScript, "utf8");
468
- await runNode([fetcherTmp], 20);
469
-
470
- if (existsSync(ghFetchFile)) {
471
- const result = checkJson(ghFetchFile, (r) => r.ok && r.length > 100);
472
- if (result) {
473
- passMsg("GitHub via fetcher: content fetched");
474
- } else {
475
- failMsg("GitHub via fetcher: failed");
476
- }
477
- } else {
478
- failMsg("GitHub via fetcher: no output");
479
- }
480
- }
481
-
482
- // ─────────────────────────────────────────────────────────────────────────────
483
- // Summary
484
- // ─────────────────────────────────────────────────────────────────────────────
485
-
486
- section("📊 Test Summary");
487
-
488
- const duration = ((Date.now() - startTime) / 1000).toFixed(1);
489
- const reportFile = join(resultsDir, "REPORT.md");
490
-
491
- const report = `# GreedySearch Test Report
492
-
493
- **Date:** ${new Date().toISOString()}
494
- **Duration:** ${duration}s
495
- **Results Directory:** ${resultsDir}
496
- **Test Mode:** ${mode}
497
-
498
- ## Summary
499
-
500
- | Metric | Count |
501
- |--------|-------|
502
- | Passed | ${pass} |
503
- | ❌ Failed | ${fail} |
504
- | ⚠️ Warnings | ${warn} |
505
- | ⊘ Skipped | ${skip} |
506
- | **Total** | ${pass + fail + warn + skip} |
507
-
508
- ${failures.length ? `### Failures\n${failures.map((f, i) => `${i + 1}. ${f}`).join("\n")}` : ""}
509
- ${warnings.length ? `### Warnings\n${warnings.map((w, i) => `${i + 1}. ${w}`).join("\n")}` : ""}
510
- `;
511
-
512
- writeFileSync(reportFile, report, "utf8");
513
-
514
- console.log(`\n${C.yellow}═══ Results ═══${C.reset}`);
515
- console.log(` ${C.green}Passed: ${pass}${C.reset}`);
516
- console.log(` ${C.red}Failed: ${fail}${C.reset}`);
517
- console.log(` ${C.yellow}Warnings: ${warn}${C.reset}`);
518
- console.log(` ${C.cyan}Skipped: ${skip}${C.reset}`);
519
- console.log(` Duration: ${duration}s`);
520
- console.log(`\n Results: ${resultsDir}`);
521
- console.log(` Report: ${reportFile}\n`);
522
-
523
- if (failures.length) {
524
- console.log(`${C.red}Failures:${C.reset}`);
525
- failures.forEach((f) => console.log(` ${C.red}•${C.reset} ${f}`));
526
- console.log();
527
- }
528
- if (warnings.length) {
529
- console.log(`${C.yellow}Warnings:${C.reset}`);
530
- warnings.forEach((w) => console.log(` ${C.yellow}•${C.reset} ${w}`));
531
- console.log();
532
- }
533
-
534
- process.exit(fail > 0 ? 1 : 0);
1
+ #!/usr/bin/env node
2
+ // test.mjs — Cross-platform test runner for GreedySearch (Windows + Unix)
3
+ //
4
+ // Usage:
5
+ // node test.mjs # run all tests (~8-12 min)
6
+ // node test.mjs quick # skip slow tests (~3 min)
7
+ // node test.mjs smoke # basic health check (~60s)
8
+ // node test.mjs parallel # race condition tests only
9
+ // node test.mjs flags # flag/option tests only
10
+ // node test.mjs edge # edge case tests only
11
+ // node test.mjs unit # fast unit tests only (no Chrome needed)
12
+ // node test.mjs synth # synthesis config smoke (gemini + chatgpt)
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const __dir = dirname(fileURLToPath(import.meta.url));
20
+
21
+ // ANSI colors
22
+ const C = {
23
+ red: "\x1b[31m",
24
+ green: "\x1b[32m",
25
+ yellow: "\x1b[33m",
26
+ blue: "\x1b[34m",
27
+ cyan: "\x1b[36m",
28
+ reset: "\x1b[0m",
29
+ };
30
+
31
+ const mode = process.argv[2] || "all";
32
+ const resultsDir = join(__dir, "results", `test_${Date.now()}`);
33
+ mkdirSync(resultsDir, { recursive: true });
34
+
35
+ let pass = 0,
36
+ fail = 0,
37
+ warn = 0,
38
+ skip = 0;
39
+ const failures = [],
40
+ warnings = [];
41
+ const startTime = Date.now();
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // Helpers
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ function passMsg(msg) {
48
+ pass++;
49
+ console.log(` ${C.green}✓${C.reset} ${msg}`);
50
+ }
51
+ function failMsg(msg) {
52
+ fail++;
53
+ console.log(` ${C.red}✗${C.reset} ${msg}`);
54
+ failures.push(msg);
55
+ }
56
+ function warnMsg(msg) {
57
+ warn++;
58
+ console.log(` ${C.yellow}⚠${C.reset} ${msg}`);
59
+ warnings.push(msg);
60
+ }
61
+ function section(title) {
62
+ console.log(`\n${C.blue}${title}${C.reset}`);
63
+ }
64
+ function subsection(title) {
65
+ console.log(`\n${C.yellow}${title}${C.reset}`);
66
+ }
67
+
68
+ async function runNode(args, timeoutSec = 60) {
69
+ return new Promise((resolve, reject) => {
70
+ const proc = spawn(process.execPath, args, {
71
+ cwd: __dir,
72
+ stdio: ["ignore", "pipe", "pipe"],
73
+ timeout: timeoutSec * 1000,
74
+ });
75
+ let out = "",
76
+ err = "";
77
+ proc.stdout.on("data", (d) => (out += d));
78
+ proc.stderr.on("data", (d) => (err += d));
79
+ proc.on("close", (code) => resolve({ code, out, err }));
80
+ proc.on("error", reject);
81
+ });
82
+ }
83
+
84
+ function checkJson(file, checkFn) {
85
+ try {
86
+ const data = JSON.parse(readFileSync(file, "utf8"));
87
+ return checkFn(data);
88
+ } catch (e) {
89
+ return `PARSE_ERROR: ${e.message}`;
90
+ }
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // Unit Tests (no Chrome required)
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+
97
+ if (["", "all", "unit", "quick", "smoke", "synth"].includes(mode)) {
98
+ section("🧪 Unit Tests");
99
+
100
+ subsection("stripQuotes param double-escaping workaround (issue #2)");
101
+ const { stripQuotes } = await import("./src/tools/shared.ts");
102
+
103
+ const stripCases = [
104
+ // [input, expected, label]
105
+ ['"all"', "all", 'double-escaped enum: \\"all\\"'],
106
+ ['"standard"', "standard", 'double-escaped enum: \\"standard\\"'],
107
+ ['"deep"', "deep", 'double-escaped enum: \\"deep\\"'],
108
+ ["all", "all", "already clean: all"],
109
+ ["standard", "standard", "already clean: standard"],
110
+ ["", "", "empty string"],
111
+ ];
112
+ for (const [input, expected, label] of stripCases) {
113
+ const got = stripQuotes(input);
114
+ if (got === expected) passMsg(`stripQuotes: ${label}`);
115
+ else
116
+ failMsg(`stripQuotes: ${label} — expected "${expected}", got "${got}"`);
117
+ }
118
+
119
+ subsection("Tool param normalization greedy_search engine/depth");
120
+ const normalizeEnum = (val, fallback) =>
121
+ stripQuotes(val ?? fallback) || fallback;
122
+
123
+ const normCases = [
124
+ // [raw, fallback, expected, label]
125
+ ['"all"', "all", "all", 'engine \\"all\\" (double-escaped)'],
126
+ [
127
+ '"perplexity"',
128
+ "all",
129
+ "perplexity",
130
+ 'engine \\"perplexity\\" (double-escaped)',
131
+ ],
132
+ [
133
+ '"standard"',
134
+ "standard",
135
+ "standard",
136
+ 'depth \\"standard\\" (double-escaped)',
137
+ ],
138
+ ['"deep"', "standard", "deep", 'depth \\"deep\\" (double-escaped)'],
139
+ [undefined, "all", "all", "engine undefined → default"],
140
+ [undefined, "standard", "standard", "depth undefined → default"],
141
+ ["gemini", "all", "gemini", "engine clean string"],
142
+ ];
143
+ for (const [raw, fallback, expected, label] of normCases) {
144
+ const got = normalizeEnum(raw, fallback);
145
+ if (got === expected) passMsg(`normalize: ${label}`);
146
+ else failMsg(`normalize: ${label} — expected "${expected}", got "${got}"`);
147
+ }
148
+
149
+ subsection(
150
+ "Bing/Perplexity error matching — headless → visible auto-retry detection",
151
+ );
152
+ // The auto-retry in bin/search.mjs uses this shared helper to decide
153
+ // whether to switch from headless to visible Chrome and retry.
154
+ const {
155
+ findHeadlessBlockedEngines,
156
+ isHeadlessBlockedError,
157
+ isManualVerificationError,
158
+ } = await import("./src/search/recovery.mjs");
159
+ const cfTestCases = [
160
+ // [error message, expected match, label]
161
+ ["input not found", true, 'legacy pattern: "input not found"'],
162
+ [
163
+ "Copilot input not found",
164
+ true,
165
+ 'extended: "input not found" in sentence',
166
+ ],
167
+ ["VERIFICATION REQUIRED", true, 'legacy pattern: "VERIFICATION REQUIRED"'],
168
+ ["verification failed", true, 'extended: "verification" in sentence'],
169
+ [
170
+ "Clipboard interceptor returned empty text",
171
+ true,
172
+ "new: clipboard error (headless Cloudflare block)",
173
+ ],
174
+ [
175
+ "[bing] Clipboard empty, retrying in 2s...",
176
+ true,
177
+ "new: clipboard empty retry message",
178
+ ],
179
+ [
180
+ "Cloudflare challenge detected — content blocked in headless",
181
+ true,
182
+ "new: Cloudflare detection triggers visible retry",
183
+ ],
184
+ [
185
+ "Network timeout after 30000ms",
186
+ true,
187
+ "new: timeout triggers visible retry",
188
+ ],
189
+ ["", false, "empty string"],
190
+ ];
191
+ for (const [error, expected, label] of cfTestCases) {
192
+ const matched = isHeadlessBlockedError(error);
193
+ if (matched === expected) passMsg(`cfPattern: ${label}`);
194
+ else failMsg(`cfPattern: ${label} — expected ${expected}, got ${matched}`);
195
+ }
196
+
197
+ subsection("Manual verification detection keeps visible Chrome open");
198
+ const manualCases = [
199
+ [
200
+ "Perplexity verification required — please solve it manually in the browser window",
201
+ true,
202
+ "perplexity manual verification",
203
+ ],
204
+ [
205
+ "Copilot verification required — please solve it manually in the browser window",
206
+ true,
207
+ "bing manual verification",
208
+ ],
209
+ ["selector changed", false, "non-verification extractor failure"],
210
+ ];
211
+ for (const [error, expected, label] of manualCases) {
212
+ const matched = isManualVerificationError(error);
213
+ if (matched === expected) passMsg(`manualVerification: ${label}`);
214
+ else
215
+ failMsg(
216
+ `manualVerification: ${label} — expected ${expected}, got ${matched}`,
217
+ );
218
+ }
219
+
220
+ const retryEngines = findHeadlessBlockedEngines({
221
+ perplexity: { error: "Clipboard interceptor returned empty text" },
222
+ bing: { error: "Copilot verification required" },
223
+ google: { error: "Google verification required" },
224
+ });
225
+ if (retryEngines.join(",") === "perplexity,bing") {
226
+ passMsg("visible retry engines: perplexity and bing only");
227
+ } else {
228
+ failMsg(
229
+ `visible retry engines: expected perplexity,bing, got ${retryEngines.join(",")}`,
230
+ );
231
+ }
232
+
233
+ const pplxTestCases = [
234
+ ["ask-input selector not found", true, 'legacy: "ask-input"'],
235
+ [
236
+ "Clipboard interceptor returned empty text",
237
+ true,
238
+ "new: clipboard also triggers for perplexity",
239
+ ],
240
+ ["Perplexity timeout", true, "timeout triggers visible retry"],
241
+ ];
242
+ for (const [error, expected, label] of pplxTestCases) {
243
+ const matched = isHeadlessBlockedError(error);
244
+ if (matched === expected) passMsg(`pplxPattern: ${label}`);
245
+ else
246
+ failMsg(`pplxPattern: ${label} — expected ${expected}, got ${matched}`);
247
+ }
248
+
249
+ subsection("Chrome lifecycle visible/headless mode detection");
250
+ const { detectHeadlessFromChromeCommandLine, isChromeHeadless } =
251
+ await import("./src/search/chrome.mjs");
252
+ const { commandLineMatchesGreedyChrome } = await import(
253
+ "./src/search/browser-lifecycle.mjs"
254
+ );
255
+
256
+ const visibleCmd =
257
+ '"C:/Program Files/Google/Chrome/Application/chrome.exe" --remote-debugging-port=9222 --user-data-dir=C:\\Users\\me\\AppData\\Local\\Temp\\greedysearch-chrome-profile about:blank';
258
+ const headlessCmd = `${visibleCmd} --headless=new`;
259
+ const rendererCmd = `${visibleCmd} --type=renderer`;
260
+
261
+ if (detectHeadlessFromChromeCommandLine(visibleCmd) === false) {
262
+ passMsg("chrome mode: live visible command line overrides stale marker");
263
+ } else {
264
+ failMsg("chrome mode: visible command line should detect non-headless");
265
+ }
266
+ if (detectHeadlessFromChromeCommandLine(headlessCmd) === true) {
267
+ passMsg("chrome mode: live headless command line detected");
268
+ } else {
269
+ failMsg("chrome mode: headless command line should detect headless");
270
+ }
271
+ if (detectHeadlessFromChromeCommandLine(rendererCmd) === null) {
272
+ passMsg("chrome mode: ignores child renderer processes");
273
+ } else {
274
+ failMsg("chrome mode: renderer command line should be ignored");
275
+ }
276
+ if (
277
+ commandLineMatchesGreedyChrome(
278
+ visibleCmd,
279
+ "C:/Users/me/AppData/Local/Temp/greedysearch-chrome-profile",
280
+ )
281
+ ) {
282
+ passMsg(
283
+ "stale cleanup: Windows backslash profile path verifies as GreedySearch Chrome",
284
+ );
285
+ } else {
286
+ failMsg(
287
+ "stale cleanup: should accept equivalent slash/backslash profile paths",
288
+ );
289
+ }
290
+ if (
291
+ !commandLineMatchesGreedyChrome(
292
+ rendererCmd,
293
+ "C:/Users/me/AppData/Local/Temp/greedysearch-chrome-profile",
294
+ )
295
+ ) {
296
+ passMsg("stale cleanup: renderer child is not treated as browser process");
297
+ } else {
298
+ failMsg(
299
+ "stale cleanup: renderer child should not verify as browser process",
300
+ );
301
+ }
302
+ if (typeof isChromeHeadless === "function")
303
+ passMsg("isChromeHeadless: function exists");
304
+ else failMsg("isChromeHeadless: not a function");
305
+
306
+ subsection("Synthesis routing configurable synthesizer helpers");
307
+ const { normalizeSynthesizer, getSynthesisStartUrl } = await import(
308
+ "./src/search/synthesis-runner.mjs"
309
+ );
310
+ if (normalizeSynthesizer("gem") === "gemini")
311
+ passMsg("synthesizer: gem alias normalizes to gemini");
312
+ else failMsg("synthesizer: gem alias should normalize to gemini");
313
+ if (normalizeSynthesizer("gpt") === "chatgpt")
314
+ passMsg("synthesizer: gpt alias normalizes to chatgpt");
315
+ else failMsg("synthesizer: gpt alias should normalize to chatgpt");
316
+ if (getSynthesisStartUrl("chatgpt") === "https://chatgpt.com/")
317
+ passMsg("synthesizer: chatgpt start URL");
318
+ else failMsg("synthesizer: unexpected chatgpt start URL");
319
+
320
+ subsection("Research mode option/query normalization");
321
+ const { clampResearchOptions, normalizeResearchQueries } = await import(
322
+ "./src/search/research.mjs"
323
+ );
324
+ const { ALL_ENGINES, DEFAULT_SYNTHESIZER, ENGINES, RESEARCH_ENGINES } =
325
+ await import("./src/search/constants.mjs");
326
+ if (RESEARCH_ENGINES.join(",") === ALL_ENGINES.join(",")) {
327
+ passMsg("research config: reuses normal all-engine fan-out");
328
+ } else {
329
+ failMsg(
330
+ `research config: expected ${ALL_ENGINES.join(",")}, got ${RESEARCH_ENGINES.join(",")}`,
331
+ );
332
+ }
333
+ if (DEFAULT_SYNTHESIZER === "gemini") {
334
+ passMsg("research config: default synthesizer is gemini");
335
+ } else {
336
+ failMsg(
337
+ `research config: expected gemini default, got ${DEFAULT_SYNTHESIZER}`,
338
+ );
339
+ }
340
+ if (!ENGINES.consensus && !ENGINES.cns) {
341
+ passMsg("research config: consensus is not a registered engine");
342
+ } else {
343
+ failMsg("research config: consensus should not be registered");
344
+ }
345
+ if (
346
+ ENGINES["semantic-scholar"] &&
347
+ ENGINES.s2 === ENGINES["semantic-scholar"]
348
+ ) {
349
+ passMsg("research config: semantic-scholar is registered with s2 alias");
350
+ } else {
351
+ failMsg("research config: semantic-scholar registration missing");
352
+ }
353
+ const clamped = clampResearchOptions({
354
+ breadth: 99,
355
+ iterations: 0,
356
+ });
357
+ if (
358
+ clamped.breadth === 5 &&
359
+ clamped.iterations === 1 &&
360
+ clamped.maxSources === 10
361
+ ) {
362
+ passMsg("research options: clamp and fallback values");
363
+ } else {
364
+ failMsg(
365
+ `research options: expected breadth=5 iterations=1 maxSources=10, got ${JSON.stringify(clamped)}`,
366
+ );
367
+ }
368
+
369
+ const researchQueries = normalizeResearchQueries(
370
+ {
371
+ queries: [
372
+ { query: " browser automation AI agents ", researchGoal: "Compare" },
373
+ { query: "browser automation AI agents", researchGoal: "duplicate" },
374
+ "Lightpanda browser CDP automation",
375
+ ],
376
+ },
377
+ "AI browser research",
378
+ 3,
379
+ );
380
+ if (
381
+ researchQueries.length === 3 &&
382
+ researchQueries[0].query === "AI browser research" &&
383
+ researchQueries[1].query === "browser automation AI agents"
384
+ ) {
385
+ passMsg("research queries: prepend original and dedupe planned queries");
386
+ } else {
387
+ failMsg(`research queries: unexpected ${JSON.stringify(researchQueries)}`);
388
+ }
389
+
390
+ const expandedQueries = normalizeResearchQueries(
391
+ null,
392
+ "Lightpanda browser",
393
+ 3,
394
+ );
395
+ if (expandedQueries.length === 3) {
396
+ passMsg("research queries: fallback expansion fills requested breadth");
397
+ } else {
398
+ failMsg(
399
+ `research queries: expected 3 expanded queries, got ${expandedQueries.length}`,
400
+ );
401
+ }
402
+
403
+ const markdownQueries = normalizeResearchQueries(
404
+ {
405
+ queries: [
406
+ "site:[GitHub](https://github.com) Lightpanda",
407
+ "read [official docs](https://example.com/docs) now",
408
+ ],
409
+ },
410
+ "Lightpanda browser",
411
+ 3,
412
+ { includeOriginal: false, expand: false },
413
+ );
414
+ if (
415
+ markdownQueries[0]?.query === "site:GitHub Lightpanda" &&
416
+ markdownQueries[1]?.query === "read official docs now"
417
+ ) {
418
+ passMsg("research queries: markdown links sanitized without regex");
419
+ } else {
420
+ failMsg(
421
+ `research queries: markdown sanitize unexpected ${JSON.stringify(markdownQueries)}`,
422
+ );
423
+ }
424
+
425
+ subsection("Source ranking social domains are low-priority");
426
+ const { buildSourceRegistry } = await import("./src/search/sources.mjs");
427
+ const ranked = buildSourceRegistry(
428
+ {
429
+ perplexity: {
430
+ sources: [
431
+ {
432
+ title: "Facebook post",
433
+ url: "https://facebook.com/groups/x/posts/1",
434
+ },
435
+ {
436
+ title: "Official docs",
437
+ url: "https://docs.example.com/lightpanda",
438
+ },
439
+ ],
440
+ },
441
+ bing: {
442
+ sources: [
443
+ {
444
+ title: "Facebook mirror",
445
+ url: "https://www.facebook.com/groups/x/posts/1",
446
+ },
447
+ { title: "Project", url: "https://example.com/lightpanda" },
448
+ ],
449
+ },
450
+ },
451
+ "Lightpanda browser documentation",
452
+ );
453
+ const facebookRank = ranked.findIndex((s) => s.domain === "facebook.com");
454
+ const docsRank = ranked.findIndex((s) => s.domain === "docs.example.com");
455
+ if (docsRank !== -1 && facebookRank !== -1 && docsRank < facebookRank) {
456
+ passMsg("source ranking: docs outrank multi-engine Facebook/social source");
457
+ } else {
458
+ failMsg(
459
+ `source ranking: unexpected order ${ranked.map((s) => s.domain).join(",")}`,
460
+ );
461
+ }
462
+
463
+ const academicRanked = buildSourceRegistry(
464
+ {
465
+ "semantic-scholar": {
466
+ sources: [
467
+ {
468
+ title:
469
+ "Chain of Thought Prompting Elicits Reasoning in Large Language Models",
470
+ url: "https://arxiv.org/pdf/2201.11903.pdf",
471
+ },
472
+ ],
473
+ },
474
+ },
475
+ "large language models",
476
+ );
477
+ if (
478
+ academicRanked[0]?.engines.includes("semantic-scholar") &&
479
+ academicRanked[0]?.sourceType === "academic"
480
+ ) {
481
+ passMsg("source ranking: semantic-scholar sources are indexed as academic");
482
+ } else {
483
+ failMsg(
484
+ `source ranking: unexpected academic source ${JSON.stringify(academicRanked[0])}`,
485
+ );
486
+ }
487
+
488
+ // Social hard guardrail: a single-engine x.com citation must never be
489
+ // S1. Composite score is high (Google rank #1, x.com matched the
490
+ // "x" letter in "context"), so the smartScore −20 penalty alone
491
+ // isn't enough the post-sort demotion is what keeps socials out
492
+ // of the top 12.
493
+ const socialGuardrail = buildSourceRegistry(
494
+ {
495
+ google: {
496
+ sources: [
497
+ {
498
+ title: "Redis on X",
499
+ url: "https://x.com/Redisinc/status/123",
500
+ },
501
+ {
502
+ title: "Self-Route paper",
503
+ url: "https://arxiv.org/abs/2407.16833",
504
+ },
505
+ ],
506
+ },
507
+ },
508
+ "retrieval augmented generation vs long context LLMs for factual accuracy and hallucination reduction",
509
+ );
510
+ if (
511
+ socialGuardrail[0]?.sourceType !== "social" &&
512
+ socialGuardrail[0]?.domain === "arxiv.org"
513
+ ) {
514
+ passMsg(
515
+ "source ranking: social sources are demoted below academic even with a higher composite score",
516
+ );
517
+ } else {
518
+ failMsg(
519
+ `source ranking: S1 should be arxiv, got ${socialGuardrail[0]?.domain} (${socialGuardrail[0]?.sourceType})`,
520
+ );
521
+ }
522
+
523
+ // ─── Phase 2: Quality Evaluator + Novelty Gate ────────────────────────
524
+
525
+ subsection("Novelty Gate Jaccard similarity");
526
+ const {
527
+ jaccardSimilarity,
528
+ isDuplicateQuery,
529
+ tokenSet,
530
+ buildFallbackQueriesFromGaps,
531
+ } = await import("./src/search/research.mjs");
532
+
533
+ // tokenSet basics
534
+ const tokens1 = tokenSet("hello world");
535
+ const tokens2 = tokenSet("HELLO World");
536
+ if (tokens1.size === 2) passMsg("tokenSet: basic tokenization (2 tokens)");
537
+ else failMsg(`tokenSet: expected 2 tokens, got ${tokens1.size}`);
538
+ if (
539
+ tokens1.size === tokens2.size &&
540
+ [...tokens1].every((t) => tokens2.has(t))
541
+ )
542
+ passMsg("tokenSet: case-insensitive");
543
+ else failMsg("tokenSet: case sensitivity mismatch");
544
+
545
+ // jaccardSimilarity
546
+ const jExact = jaccardSimilarity("hello world", "hello world");
547
+ if (Math.abs(jExact - 1.0) < 0.001) passMsg("jaccard: exact match = 1.0");
548
+ else failMsg(`jaccard: exact match expected 1.0, got ${jExact}`);
549
+
550
+ const jNone = jaccardSimilarity("hello world", "foo bar baz");
551
+ if (Math.abs(jNone - 0.0) < 0.001) passMsg("jaccard: no overlap = 0.0");
552
+ else failMsg(`jaccard: no overlap expected 0.0, got ${jNone}`);
553
+
554
+ const jPartial = jaccardSimilarity(
555
+ "AI browser automation",
556
+ "browser automation testing",
557
+ );
558
+ if (jPartial > 0.0 && jPartial < 1.0)
559
+ passMsg(`jaccard: partial overlap = ${jPartial.toFixed(3)}`);
560
+ else failMsg(`jaccard: partial overlap expected 0<x<1, got ${jPartial}`);
561
+
562
+ const jNearDup = jaccardSimilarity("react hooks tutorial", "react hooks");
563
+ if (jNearDup > 0.6)
564
+ passMsg(`jaccard: near-duplicate = ${jNearDup.toFixed(3)}`);
565
+ else
566
+ failMsg(
567
+ `jaccard: near-duplicate expected >0.6, got ${jNearDup.toFixed(3)}`,
568
+ );
569
+
570
+ // isDuplicateQuery
571
+ const used = new Set();
572
+ used.add("react hooks tutorial 2024");
573
+ used.add("vue composition api");
574
+
575
+ if (
576
+ isDuplicateQuery("React Hooks Tutorial 2024", used, {
577
+ roundIndex: 0,
578
+ originalQuery: "react hooks",
579
+ })
580
+ ) {
581
+ passMsg("isDuplicateQuery: exact dup detected (case-insensitive)");
582
+ } else {
583
+ failMsg("isDuplicateQuery: exact dup not detected");
584
+ }
585
+
586
+ if (
587
+ isDuplicateQuery("react hooks tutorial 2024 guide", used, {
588
+ roundIndex: 2,
589
+ originalQuery: "react hooks",
590
+ })
591
+ ) {
592
+ passMsg("isDuplicateQuery: near-dup rejected (threshold 0.75)");
593
+ } else {
594
+ failMsg("isDuplicateQuery: near-dup not rejected");
595
+ }
596
+
597
+ if (
598
+ !isDuplicateQuery("svelte reactive statements", used, {
599
+ roundIndex: 2,
600
+ originalQuery: "react hooks",
601
+ })
602
+ ) {
603
+ passMsg("isDuplicateQuery: novel query passes");
604
+ } else {
605
+ failMsg("isDuplicateQuery: novel query incorrectly rejected");
606
+ }
607
+
608
+ // Original query rejection after round 1
609
+ if (
610
+ isDuplicateQuery("react hooks", used, {
611
+ roundIndex: 1,
612
+ originalQuery: "react hooks",
613
+ })
614
+ ) {
615
+ passMsg("isDuplicateQuery: original query rejected after round 1");
616
+ } else {
617
+ failMsg("isDuplicateQuery: original query not rejected after round 1");
618
+ }
619
+
620
+ // Original query allowed in round 0
621
+ if (
622
+ !isDuplicateQuery("react hooks", used, {
623
+ roundIndex: 0,
624
+ originalQuery: "react hooks",
625
+ })
626
+ ) {
627
+ passMsg("isDuplicateQuery: original query allowed in round 0");
628
+ } else {
629
+ failMsg("isDuplicateQuery: original query rejected in round 0");
630
+ }
631
+
632
+ // buildFallbackQueriesFromGaps
633
+ const fallbacks = buildFallbackQueriesFromGaps(
634
+ ["API support unknown", "production usage unclear"],
635
+ "Lightpanda browser",
636
+ new Set(["lightpanda browser overview"]),
637
+ 2,
638
+ 1,
639
+ );
640
+ if (fallbacks.length > 0 && fallbacks.length <= 2)
641
+ passMsg(`fallback queries: generated ${fallbacks.length} queries`);
642
+ else failMsg(`fallback queries: expected 1-2, got ${fallbacks.length}`);
643
+ // Gap text is embedded in researchGoal, not query
644
+ const gapTargets = fallbacks.some(
645
+ (f) =>
646
+ f.researchGoal.toLowerCase().includes("api") ||
647
+ f.researchGoal.toLowerCase().includes("production"),
648
+ );
649
+ if (gapTargets) passMsg("fallback queries: targets identified gaps");
650
+ else failMsg("fallback queries: gaps not targeted");
651
+
652
+ // ─────────────────────────────────────────────────────────────────────────
653
+ // Synthesis routing — config-driven live smoke
654
+ //
655
+ // Verifies the `synthesizer` field in ~/.pi/greedyconfig is honored by
656
+ // `engine: "all" --synthesize`. Runs both the default (gemini) and an
657
+ // override (chatgpt). Backups the user's config and restores it after.
658
+ //
659
+ // Mode gating: only runs in "", "all", or "synth". Skipped in unit/quick/
660
+ // smoke because it requires Chrome + network and takes several minutes.
661
+ // ─────────────────────────────────────────────────────────────────────────
662
+ if (["", "all", "synth"].includes(mode)) {
663
+ subsection(
664
+ "Synthesis routing — config-driven live smoke (gemini + chatgpt)",
665
+ );
666
+ const { existsSync, copyFileSync, writeFileSync, unlinkSync } =
667
+ await import("node:fs");
668
+ const { homedir } = await import("node:os");
669
+ const { join } = await import("node:path");
670
+ const cfgDir = join(homedir(), ".pi");
671
+ const cfgFile = join(cfgDir, "greedyconfig");
672
+ const backup = join(cfgDir, "greedyconfig.test-backup");
673
+ const hadOriginal = existsSync(cfgFile);
674
+ if (hadOriginal) copyFileSync(cfgFile, backup);
675
+
676
+ const meaningfulQuery = "Who is Apostolos Mantzaris?";
677
+ const engines = ["perplexity", "google", "chatgpt", "gemini"];
678
+ const results = {};
679
+
680
+ const runSynth = async (synthesizer) => {
681
+ mkdirSync(cfgDir, { recursive: true });
682
+ writeFileSync(
683
+ cfgFile,
684
+ JSON.stringify({ engines, synthesizer }, null, 2) + "\n",
685
+ "utf8",
686
+ );
687
+ const outFile = join(resultsDir, `synth_${synthesizer}.json`);
688
+ const script = `
689
+ import { spawn } from 'node:child_process';
690
+ import { writeFileSync } from 'node:fs';
691
+ const proc = spawn(process.execPath, [
692
+ '${join(__dir, "bin", "search.mjs").replace(/\\/g, "\\\\")}',
693
+ 'all', '--inline', '--stdin', '--headless', '--synthesize'
694
+ ], { stdio: ['pipe', 'pipe', 'pipe'] });
695
+ let out = '', err = '';
696
+ proc.stdout.on('data', d => out += d);
697
+ proc.stderr.on('data', d => err += d);
698
+ proc.stdin.end(${JSON.stringify(meaningfulQuery)});
699
+ proc.on('close', code => {
700
+ writeFileSync(${JSON.stringify(outFile.replace(/\\/g, "\\\\"))}, JSON.stringify({
701
+ code, out, err,
702
+ }, null, 2));
703
+ });
704
+ `;
705
+ const tmp = join(resultsDir, `_synth_${synthesizer}.mjs`);
706
+ writeFileSync(tmp, script, "utf8");
707
+ await runNode([tmp], 240);
708
+ const data = JSON.parse(readFileSync(outFile, "utf8"));
709
+ let parsed = null;
710
+ try {
711
+ parsed = JSON.parse(data.out);
712
+ } catch (e) {
713
+ return {
714
+ synthesized: false,
715
+ synthesizedBy: null,
716
+ parseError: e.message,
717
+ rawOut: data.out.slice(0, 200),
718
+ };
719
+ }
720
+ return {
721
+ synthesized: parsed._synthesis?.synthesized === true,
722
+ synthesizedBy: parsed._synthesis?.synthesizedBy || null,
723
+ engines: Object.keys(parsed).filter((k) => !k.startsWith("_")),
724
+ chatgptAnswer: parsed.chatgpt?.answer || null,
725
+ chatgptError: parsed.chatgpt?.error || null,
726
+ chatgptStage: parsed.chatgpt?._envelope?.lastStage || null,
727
+ chatgptStages: parsed.chatgpt?._envelope?.stages || null,
728
+ answerPreview: String(parsed._synthesis?.answer || "").slice(0, 120),
729
+ };
730
+ };
731
+
732
+ try {
733
+ results.gemini = await runSynth("gemini");
734
+ if (
735
+ results.gemini.synthesized &&
736
+ results.gemini.synthesizedBy === "gemini"
737
+ ) {
738
+ passMsg("synth=gemini: synthesizedBy === gemini");
739
+ } else {
740
+ failMsg(
741
+ `synth=gemini: expected synthesizedBy=gemini, got ${JSON.stringify(results.gemini)}`,
742
+ );
743
+ }
744
+
745
+ results.chatgpt = await runSynth("chatgpt");
746
+ if (
747
+ results.chatgpt.synthesized &&
748
+ results.chatgpt.synthesizedBy === "chatgpt"
749
+ ) {
750
+ passMsg("synth=chatgpt: synthesizedBy === chatgpt");
751
+ } else {
752
+ failMsg(
753
+ `synth=chatgpt: expected synthesizedBy=chatgpt, got ${JSON.stringify(results.chatgpt)}`,
754
+ );
755
+ }
756
+
757
+ // Also assert chatgpt-search succeeded under parallel load — a
758
+ // regression of the throttling fix or the engine budget would
759
+ // re-introduce the "cdp timeout: eval" failure at stream-wait.
760
+ // We require an actual answer (not just a synthesis routing
761
+ // marker) so the test catches the underlying engine problem.
762
+ if (results.gemini.chatgptAnswer) {
763
+ passMsg(
764
+ "chatgpt-search: produced an answer (parallel contention not blocking)",
765
+ );
766
+ } else {
767
+ failMsg(
768
+ `chatgpt-search: no answer — error=${JSON.stringify(results.gemini.chatgptError)} lastStage=${results.gemini.chatgptStage}`,
769
+ );
770
+ }
771
+ } finally {
772
+ if (hadOriginal) {
773
+ copyFileSync(backup, cfgFile);
774
+ try {
775
+ unlinkSync(backup);
776
+ } catch {}
777
+ } else {
778
+ try {
779
+ unlinkSync(cfgFile);
780
+ } catch {}
781
+ }
782
+ }
783
+ }
784
+
785
+ // ─── Phase 3: Action Planner ──────────────────────────────────────────
786
+
787
+ subsection("Action Planner — validation & parsing");
788
+ const { validateAction, parseActionPlan, queriesToActions } = await import(
789
+ "./src/search/research.mjs"
790
+ );
791
+
792
+ // validateAction
793
+ const validSearch = validateAction({
794
+ type: "search",
795
+ query: "React 19 features",
796
+ researchGoal: "Understand new features",
797
+ });
798
+ if (
799
+ validSearch &&
800
+ validSearch.type === "search" &&
801
+ validSearch.query === "React 19 features"
802
+ ) {
803
+ passMsg("validateAction: valid search action");
804
+ } else {
805
+ failMsg(
806
+ `validateAction: search action failed: ${JSON.stringify(validSearch)}`,
807
+ );
808
+ }
809
+
810
+ const validFetch = validateAction({
811
+ type: "fetchUrl",
812
+ url: "https://react.dev/learn",
813
+ researchGoal: "Read official docs",
814
+ });
815
+ if (
816
+ validFetch &&
817
+ validFetch.type === "fetchUrl" &&
818
+ validFetch.url === "https://react.dev/learn"
819
+ ) {
820
+ passMsg("validateAction: valid fetchUrl action");
821
+ } else {
822
+ failMsg(`validateAction: fetchUrl failed: ${JSON.stringify(validFetch)}`);
823
+ }
824
+
825
+ if (!validateAction({ type: "unknown" })) {
826
+ passMsg("validateAction: unknown type rejected");
827
+ } else {
828
+ failMsg("validateAction: unknown type not rejected");
829
+ }
830
+
831
+ if (!validateAction(null)) {
832
+ passMsg("validateAction: null input rejected");
833
+ } else {
834
+ failMsg("validateAction: null not rejected");
835
+ }
836
+
837
+ if (!validateAction({ type: "search" })) {
838
+ passMsg("validateAction: search without query rejected");
839
+ } else {
840
+ failMsg("validateAction: search without query not rejected");
841
+ }
842
+
843
+ if (!validateAction({ type: "fetchUrl" })) {
844
+ passMsg("validateAction: fetchUrl without url rejected");
845
+ } else {
846
+ failMsg("validateAction: fetchUrl without url not rejected");
847
+ }
848
+
849
+ // parseActionPlan
850
+ const planResult = parseActionPlan(
851
+ {
852
+ answer: `BEGIN_JSON
853
+ {"actions": [
854
+ {"type":"search","query":"React 19 server components","researchGoal":"SSR info"},
855
+ {"type":"fetchUrl","url":"https://react.dev/blog","researchGoal":"Blog post"},
856
+ {"type":"search","query":"","researchGoal":"Empty query"}
857
+ ]}
858
+ END_JSON`,
859
+ },
860
+ 3,
861
+ );
862
+ if (planResult.length === 2) {
863
+ passMsg("parseActionPlan: 2 valid actions (empty query filtered)");
864
+ } else {
865
+ failMsg(`parseActionPlan: expected 2 actions, got ${planResult.length}`);
866
+ }
867
+
868
+ // queriesToActions
869
+ const qActions = queriesToActions([
870
+ "react concurrent features",
871
+ { query: "vue 3 setup syntax", researchGoal: "Vue setup" },
872
+ "", // empty
873
+ ]);
874
+ if (qActions.length === 2 && qActions[0].type === "search") {
875
+ passMsg("queriesToActions: converts strings and objects");
876
+ } else {
877
+ failMsg(`queriesToActions: unexpected result: ${JSON.stringify(qActions)}`);
878
+ }
879
+
880
+ // ─── Phase 4: Citation Audit ──────────────────────────────────────────
881
+
882
+ subsection("Citation Audit");
883
+ const { auditCitations } = await import("./src/search/research.mjs");
884
+
885
+ // Test with valid citations
886
+ const sources1 = [
887
+ {
888
+ id: "S1",
889
+ title: "React docs",
890
+ fetch: { ok: true },
891
+ content: "x".repeat(200),
892
+ },
893
+ { id: "S2", title: "MDN", fetch: { ok: true }, content: "x".repeat(200) },
894
+ { id: "S3", title: "Stack Overflow", fetch: { ok: false } },
895
+ ];
896
+
897
+ const audit1 = auditCitations(
898
+ "React hooks are powerful [S1]. See also [S2] for details.",
899
+ sources1,
900
+ );
901
+ if (audit1.cited.includes("S1") && audit1.cited.includes("S2")) {
902
+ passMsg("citation audit: extracts S1, S2 from answer text");
903
+ } else {
904
+ failMsg(
905
+ `citation audit: cited list unexpected: ${JSON.stringify(audit1.cited)}`,
906
+ );
907
+ }
908
+ if (audit1.ok) {
909
+ passMsg("citation audit: ok=true when all cited sources exist");
910
+ } else {
911
+ failMsg("citation audit: ok should be true");
912
+ }
913
+ if (audit1.missing.length === 0) {
914
+ passMsg("citation audit: no missing citations");
915
+ } else {
916
+ failMsg(
917
+ `citation audit: unexpected missing: ${JSON.stringify(audit1.missing)}`,
918
+ );
919
+ }
920
+
921
+ // Test with missing citation
922
+ const audit2 = auditCitations("See reference [S9] for details.", sources1);
923
+ if (!audit2.ok && audit2.missing.length > 0) {
924
+ passMsg("citation audit: missing citation detected");
925
+ } else {
926
+ failMsg("citation audit: missing citation not detected");
927
+ }
928
+
929
+ // Test with unfetched source
930
+ const audit3 = auditCitations("Info from [S3] confirms this.", sources1);
931
+ if (audit3.unfetched.includes("S3")) {
932
+ passMsg("citation audit: unfetched source flagged");
933
+ } else {
934
+ failMsg("citation audit: unfetched source not flagged");
935
+ }
936
+
937
+ // Test with no citations in answer
938
+ const audit4 = auditCitations(
939
+ "This is a plain answer with no citations.",
940
+ sources1,
941
+ );
942
+ if (audit4.ok && audit4.cited.length === 0) {
943
+ passMsg("citation audit: no citations = ok");
944
+ } else {
945
+ failMsg("citation audit: empty citations unexpected");
946
+ }
947
+
948
+ // Test with empty/null inputs
949
+ const audit5 = auditCitations("", []);
950
+ if (audit5.ok && audit5.cited.length === 0) {
951
+ passMsg("citation audit: empty input handled");
952
+ } else {
953
+ failMsg("citation audit: empty input not handled");
954
+ }
955
+
956
+ // Mixed citation IDs (S and F)
957
+ const sourcesMixed = [
958
+ { id: "S1", title: "Source 1", content: "x".repeat(200) },
959
+ { id: "S2", title: "Source 2" },
960
+ ];
961
+ const auditMixed = auditCitations("Refs: [S1] [S2] [S5].", sourcesMixed);
962
+ if (
963
+ auditMixed.cited.includes("S1") &&
964
+ auditMixed.cited.includes("S2") &&
965
+ auditMixed.cited.includes("S5")
966
+ ) {
967
+ passMsg("citation audit: multiple citation IDs extracted");
968
+ } else {
969
+ failMsg(
970
+ `citation audit: unexpected cited: ${JSON.stringify(auditMixed.cited)}`,
971
+ );
972
+ }
973
+ if (auditMixed.unfetched.includes("S2")) {
974
+ passMsg("citation audit: S2 flagged as unfetched (no content)");
975
+ } else {
976
+ failMsg("citation audit: S2 should be flagged as unfetched");
977
+ }
978
+
979
+ subsection("Research Floor and Question Ledger");
980
+ const { computeResearchFloor, createQuestionLedger, updateQuestionLedger } =
981
+ await import("./src/search/research.mjs");
982
+ const floorOk = computeResearchFloor({
983
+ sources: [
984
+ { id: "S1", sourceType: "official-docs" },
985
+ { id: "S2", sourceType: "community" },
986
+ ],
987
+ fetchedSources: [
988
+ { id: "S1", contentChars: 500 },
989
+ { id: "S2", contentChars: 500 },
990
+ { id: "S3", contentChars: 500 },
991
+ ],
992
+ synthesis: {
993
+ claims: [{ claim: "React has docs", sourceIds: ["S1"] }],
994
+ },
995
+ citationAudit: { ok: true, cited: ["S1"], unfetched: [] },
996
+ rounds: [{ round: 1 }],
997
+ qualityScore: 8.2,
998
+ maxSources: 3,
999
+ });
1000
+ if (floorOk.floorMet)
1001
+ passMsg("research floor: passes with evidence and citations");
1002
+ else failMsg(`research floor: expected pass, got ${JSON.stringify(floorOk)}`);
1003
+
1004
+ const floorMissingCitation = computeResearchFloor({
1005
+ sources: [{ id: "S1", sourceType: "official-docs" }],
1006
+ fetchedSources: [{ id: "S1", contentChars: 500 }],
1007
+ synthesis: { claims: [] },
1008
+ citationAudit: { ok: true, cited: [], unfetched: [] },
1009
+ rounds: [{ round: 1 }],
1010
+ qualityScore: 9,
1011
+ maxSources: 1,
1012
+ });
1013
+ if (
1014
+ !floorMissingCitation.floorMet &&
1015
+ !floorMissingCitation.checks.citationsPresent
1016
+ ) {
1017
+ passMsg("research floor: rejects missing citations");
1018
+ } else {
1019
+ failMsg("research floor: missing citations should fail");
1020
+ }
1021
+
1022
+ const ledger = createQuestionLedger("What is React 19?");
1023
+ updateQuestionLedger(ledger, {
1024
+ roundNumber: 1,
1025
+ actions: [
1026
+ {
1027
+ type: "search",
1028
+ query: "React 19 actions",
1029
+ researchGoal: "Find React 19 feature list",
1030
+ },
1031
+ ],
1032
+ learningPayload: {
1033
+ answeredQuestions: [
1034
+ { id: "Q1", evidence: "React 19 is documented", sourceIds: ["S1"] },
1035
+ ],
1036
+ newQuestions: ["Which React 19 features are stable?"],
1037
+ },
1038
+ });
1039
+ const closedQ1 = ledger.find((q) => q.id === "Q1")?.status === "closed";
1040
+ const addedOpen = ledger.some(
1041
+ (q) => q.question.includes("stable") && q.status === "open",
1042
+ );
1043
+ if (closedQ1 && addedOpen) {
1044
+ passMsg("question ledger: closes answered questions and adds follow-ups");
1045
+ } else {
1046
+ failMsg(`question ledger: unexpected ${JSON.stringify(ledger)}`);
1047
+ }
1048
+
1049
+ subsection("Structured JSON parser");
1050
+ const { parseStructuredJson } = await import("./src/search/synthesis.mjs");
1051
+ const parsedLooseJson = parseStructuredJson(`BEGIN_JSON
1052
+ {"answer":"line one
1053
+ line two","claims":[{"claim":"x"}]}
1054
+ END_JSON
1055
+ trailing note`);
1056
+ if (parsedLooseJson?.answer?.includes("line two")) {
1057
+ passMsg("structured JSON: repairs raw newlines inside strings");
1058
+ } else {
1059
+ failMsg(
1060
+ `structured JSON: failed to repair ${JSON.stringify(parsedLooseJson)}`,
1061
+ );
1062
+ }
1063
+ }
1064
+
1065
+ // ─────────────────────────────────────────────────────────────────────────────
1066
+ // Pre-flight Checks
1067
+ // ─────────────────────────────────────────────────────────────────────────────
1068
+
1069
+ section("🔧 Pre-flight Checks");
1070
+
1071
+ // Check CDP module
1072
+ if (!existsSync(join(__dir, "bin", "cdp.mjs"))) {
1073
+ failMsg("bin/cdp.mjs missing - extension not properly installed");
1074
+ process.exit(1);
1075
+ } else {
1076
+ passMsg("CDP module present");
1077
+ }
1078
+
1079
+ // Check Node version
1080
+ const nodeVersion = process.version.match(/v(\d+)/)?.[1];
1081
+ if (nodeVersion && parseInt(nodeVersion) >= 22) {
1082
+ passMsg(`Node.js 22+ (${process.version})`);
1083
+ } else {
1084
+ warnMsg(`Node.js ${process.version} (22+ recommended)`);
1085
+ }
1086
+
1087
+ // Check Chrome launcher
1088
+ if (!existsSync(join(__dir, "bin", "launch.mjs"))) {
1089
+ warnMsg("bin/launch.mjs missing - Chrome auto-launch may fail");
1090
+ } else {
1091
+ passMsg("Chrome launcher present");
1092
+ }
1093
+
1094
+ // ─────────────────────────────────────────────────────────────────────────────
1095
+ // Flag & Option Tests
1096
+ // ─────────────────────────────────────────────────────────────────────────────
1097
+
1098
+ if (["", "all", "flags", "quick", "smoke"].includes(mode)) {
1099
+ section("🏷️ Flag & Option Tests");
1100
+
1101
+ subsection("Testing --inline flag (stdout output)...");
1102
+ const inlineFile = join(resultsDir, "flag_inline.json");
1103
+ const { out: inlineOut } = await runNode(
1104
+ [join(__dir, "bin", "search.mjs"), "perplexity", "what is AI", "--inline"],
1105
+ 90,
1106
+ );
1107
+ if (inlineOut) {
1108
+ writeFileSync(inlineFile, inlineOut, "utf8");
1109
+ const hasAnswer = checkJson(
1110
+ inlineFile,
1111
+ (d) => d.answer || d.perplexity?.answer,
1112
+ );
1113
+ if (hasAnswer) {
1114
+ passMsg("--inline: JSON output to stdout");
1115
+ } else {
1116
+ warnMsg(`--inline: ${hasAnswer}`);
1117
+ }
1118
+ } else {
1119
+ failMsg("--inline: timeout or no output");
1120
+ }
1121
+
1122
+ subsection("Testing engine aliases...");
1123
+ for (const alias of ["p", "g", "b"]) {
1124
+ const aliasFile = join(resultsDir, `alias_${alias}.json`);
1125
+ const { out: _aliasOut } = await runNode(
1126
+ [
1127
+ join(__dir, "bin", "search.mjs"),
1128
+ alias,
1129
+ "test query",
1130
+ "--out",
1131
+ aliasFile,
1132
+ ],
1133
+ 60,
1134
+ );
1135
+ if (existsSync(aliasFile) && aliasFile.length > 0) {
1136
+ passMsg(`alias '${alias}': search completed`);
1137
+ } else {
1138
+ warnMsg(`alias '${alias}': failed (may be expected for some engines)`);
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ // ─────────────────────────────────────────────────────────────────────────────
1144
+ // Edge Case Tests
1145
+ // ─────────────────────────────────────────────────────────────────────────────
1146
+
1147
+ if (["", "all", "edge", "quick"].includes(mode)) {
1148
+ section("🔍 Edge Case Tests");
1149
+
1150
+ subsection("Test 1: Special characters in query...");
1151
+ const specialFile = join(resultsDir, "edge_special.json");
1152
+ await runNode(
1153
+ [
1154
+ join(__dir, "bin", "search.mjs"),
1155
+ "perplexity",
1156
+ "C++ memory management & pointers",
1157
+ "--out",
1158
+ specialFile,
1159
+ ],
1160
+ 90,
1161
+ );
1162
+ if (existsSync(specialFile)) {
1163
+ const queryCheck = checkJson(
1164
+ specialFile,
1165
+ (d) => d.query?.includes("C++") && d.query?.includes("&"),
1166
+ );
1167
+ if (queryCheck) {
1168
+ passMsg("Edge1: special chars preserved");
1169
+ } else {
1170
+ warnMsg("Edge1: query mangled");
1171
+ }
1172
+ } else {
1173
+ warnMsg("Edge1: search failed");
1174
+ }
1175
+
1176
+ subsection("Test 2: Very short query...");
1177
+ const shortFile = join(resultsDir, "edge_short.json");
1178
+ await runNode(
1179
+ [
1180
+ join(__dir, "bin", "search.mjs"),
1181
+ "perplexity",
1182
+ "Docker",
1183
+ "--out",
1184
+ shortFile,
1185
+ ],
1186
+ 90,
1187
+ );
1188
+ if (existsSync(shortFile)) {
1189
+ const hasAnswer = checkJson(shortFile, (d) => d.answer?.length > 10);
1190
+ if (hasAnswer) {
1191
+ passMsg("Edge2: short query handled");
1192
+ } else {
1193
+ warnMsg("Edge2: no answer");
1194
+ }
1195
+ } else {
1196
+ warnMsg("Edge2: timeout");
1197
+ }
1198
+
1199
+ subsection("Test 3: Unicode/international characters...");
1200
+ const unicodeFile = join(resultsDir, "edge_unicode.json");
1201
+ await runNode(
1202
+ [
1203
+ join(__dir, "bin", "search.mjs"),
1204
+ "google",
1205
+ "日本のAI技術について教えて",
1206
+ "--out",
1207
+ unicodeFile,
1208
+ ],
1209
+ 120,
1210
+ );
1211
+ if (existsSync(unicodeFile)) {
1212
+ const unicodeCheck = checkJson(unicodeFile, (d) =>
1213
+ d.query?.includes("日本"),
1214
+ );
1215
+ if (unicodeCheck) {
1216
+ passMsg("Edge3: unicode preserved");
1217
+ } else {
1218
+ warnMsg("Edge3: unicode mangled");
1219
+ }
1220
+ } else {
1221
+ warnMsg("Edge3: timeout");
1222
+ }
1223
+ }
1224
+
1225
+ // ─────────────────────────────────────────────────────────────────────────────
1226
+ // GitHub Fetch Tests
1227
+ // ─────────────────────────────────────────────────────────────────────────────
1228
+
1229
+ if (["", "all", "edge", "quick", "smoke"].includes(mode)) {
1230
+ section("🐙 GitHub Fetch Tests");
1231
+
1232
+ subsection("Test 1: Blob file fetch (raw URL)...");
1233
+ const ghBlobFile = join(resultsDir, "gh_blob.json");
1234
+ const blobScript = `
1235
+ import { fetchGitHubContent } from '../../src/github.mjs';
1236
+ import { writeFileSync } from 'fs';
1237
+ try {
1238
+ const r = await fetchGitHubContent('https://github.com/expressjs/express/blob/master/Readme.md');
1239
+ writeFileSync('${ghBlobFile.replace(/\\/g, "\\\\")}', JSON.stringify(r));
1240
+ } catch(e) {
1241
+ writeFileSync('${ghBlobFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: false, error: e.message }));
1242
+ }
1243
+ `;
1244
+ const blobTmp = join(resultsDir, "_gh_blob_test.mjs");
1245
+ writeFileSync(blobTmp, blobScript, "utf8");
1246
+ await runNode([blobTmp], 20);
1247
+
1248
+ if (existsSync(ghBlobFile)) {
1249
+ const result = checkJson(
1250
+ ghBlobFile,
1251
+ (r) => r.ok && r.content?.length > 100,
1252
+ );
1253
+ if (result) {
1254
+ passMsg("GitHub blob: content fetched");
1255
+ } else {
1256
+ failMsg("GitHub blob: failed");
1257
+ }
1258
+ } else {
1259
+ failMsg("GitHub blob: no output");
1260
+ }
1261
+
1262
+ subsection("Test 2: HTTP fetcher pipeline...");
1263
+ const ghFetchFile = join(resultsDir, "gh_fetcher.json");
1264
+ const fetcherScript = `
1265
+ import { fetchSourceHttp } from '../../src/fetcher.mjs';
1266
+ import { writeFileSync } from 'fs';
1267
+ try {
1268
+ const r = await fetchSourceHttp('https://github.com/expressjs/express/blob/master/Readme.md');
1269
+ writeFileSync('${ghFetchFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: r.ok, length: r.markdown?.length, error: r.error }));
1270
+ } catch(e) {
1271
+ writeFileSync('${ghFetchFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: false, error: e.message }));
1272
+ }
1273
+ `;
1274
+ const fetcherTmp = join(resultsDir, "_gh_fetcher_test.mjs");
1275
+ writeFileSync(fetcherTmp, fetcherScript, "utf8");
1276
+ await runNode([fetcherTmp], 20);
1277
+
1278
+ if (existsSync(ghFetchFile)) {
1279
+ const result = checkJson(ghFetchFile, (r) => r.ok && r.length > 100);
1280
+ if (result) {
1281
+ passMsg("GitHub via fetcher: content fetched");
1282
+ } else {
1283
+ failMsg("GitHub via fetcher: failed");
1284
+ }
1285
+ } else {
1286
+ failMsg("GitHub via fetcher: no output");
1287
+ }
1288
+ }
1289
+
1290
+ // ─────────────────────────────────────────────────────────────────────────────
1291
+ // Summary
1292
+ // ─────────────────────────────────────────────────────────────────────────────
1293
+
1294
+ section("📊 Test Summary");
1295
+
1296
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
1297
+ const reportFile = join(resultsDir, "REPORT.md");
1298
+
1299
+ const report = `# GreedySearch Test Report
1300
+
1301
+ **Date:** ${new Date().toISOString()}
1302
+ **Duration:** ${duration}s
1303
+ **Results Directory:** ${resultsDir}
1304
+ **Test Mode:** ${mode}
1305
+
1306
+ ## Summary
1307
+
1308
+ | Metric | Count |
1309
+ |--------|-------|
1310
+ | ✅ Passed | ${pass} |
1311
+ | ❌ Failed | ${fail} |
1312
+ | ⚠️ Warnings | ${warn} |
1313
+ | ⊘ Skipped | ${skip} |
1314
+ | **Total** | ${pass + fail + warn + skip} |
1315
+
1316
+ ${failures.length ? `### Failures\n${failures.map((f, i) => `${i + 1}. ${f}`).join("\n")}` : ""}
1317
+ ${warnings.length ? `### Warnings\n${warnings.map((w, i) => `${i + 1}. ${w}`).join("\n")}` : ""}
1318
+ `;
1319
+
1320
+ writeFileSync(reportFile, report, "utf8");
1321
+
1322
+ console.log(`\n${C.yellow}═══ Results ═══${C.reset}`);
1323
+ console.log(` ${C.green}Passed: ${pass}${C.reset}`);
1324
+ console.log(` ${C.red}Failed: ${fail}${C.reset}`);
1325
+ console.log(` ${C.yellow}Warnings: ${warn}${C.reset}`);
1326
+ console.log(` ${C.cyan}Skipped: ${skip}${C.reset}`);
1327
+ console.log(` Duration: ${duration}s`);
1328
+ console.log(`\n Results: ${resultsDir}`);
1329
+ console.log(` Report: ${reportFile}\n`);
1330
+
1331
+ if (failures.length) {
1332
+ console.log(`${C.red}Failures:${C.reset}`);
1333
+ failures.forEach((f) => console.log(` ${C.red}•${C.reset} ${f}`));
1334
+ console.log();
1335
+ }
1336
+ if (warnings.length) {
1337
+ console.log(`${C.yellow}Warnings:${C.reset}`);
1338
+ warnings.forEach((w) => console.log(` ${C.yellow}•${C.reset} ${w}`));
1339
+ console.log();
1340
+ }
1341
+
1342
+ process.exit(fail > 0 ? 1 : 0);