@apmantza/greedysearch-pi 1.9.1 → 1.9.2

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,971 @@
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
+
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
+ subsection("Research mode option/query normalization");
257
+ const { clampResearchOptions, normalizeResearchQueries } = await import(
258
+ "./src/search/research.mjs"
259
+ );
260
+ const clamped = clampResearchOptions({
261
+ breadth: 99,
262
+ iterations: 0,
263
+ });
264
+ if (
265
+ clamped.breadth === 5 &&
266
+ clamped.iterations === 1 &&
267
+ clamped.maxSources === 10
268
+ ) {
269
+ passMsg("research options: clamp and fallback values");
270
+ } else {
271
+ failMsg(
272
+ `research options: expected breadth=5 iterations=1 maxSources=10, got ${JSON.stringify(clamped)}`,
273
+ );
274
+ }
275
+
276
+ const researchQueries = normalizeResearchQueries(
277
+ {
278
+ queries: [
279
+ { query: " browser automation AI agents ", researchGoal: "Compare" },
280
+ { query: "browser automation AI agents", researchGoal: "duplicate" },
281
+ "Lightpanda browser CDP automation",
282
+ ],
283
+ },
284
+ "AI browser research",
285
+ 3,
286
+ );
287
+ if (
288
+ researchQueries.length === 3 &&
289
+ researchQueries[0].query === "AI browser research" &&
290
+ researchQueries[1].query === "browser automation AI agents"
291
+ ) {
292
+ passMsg("research queries: prepend original and dedupe planned queries");
293
+ } else {
294
+ failMsg(`research queries: unexpected ${JSON.stringify(researchQueries)}`);
295
+ }
296
+
297
+ const expandedQueries = normalizeResearchQueries(
298
+ null,
299
+ "Lightpanda browser",
300
+ 3,
301
+ );
302
+ if (expandedQueries.length === 3) {
303
+ passMsg("research queries: fallback expansion fills requested breadth");
304
+ } else {
305
+ failMsg(
306
+ `research queries: expected 3 expanded queries, got ${expandedQueries.length}`,
307
+ );
308
+ }
309
+
310
+ const markdownQueries = normalizeResearchQueries(
311
+ {
312
+ queries: [
313
+ "site:[GitHub](https://github.com) Lightpanda",
314
+ "read [official docs](https://example.com/docs) now",
315
+ ],
316
+ },
317
+ "Lightpanda browser",
318
+ 3,
319
+ { includeOriginal: false, expand: false },
320
+ );
321
+ if (
322
+ markdownQueries[0]?.query === "site:GitHub Lightpanda" &&
323
+ markdownQueries[1]?.query === "read official docs now"
324
+ ) {
325
+ passMsg("research queries: markdown links sanitized without regex");
326
+ } else {
327
+ failMsg(
328
+ `research queries: markdown sanitize unexpected ${JSON.stringify(markdownQueries)}`,
329
+ );
330
+ }
331
+
332
+ subsection("Source ranking — social domains are low-priority");
333
+ const { buildSourceRegistry } = await import("./src/search/sources.mjs");
334
+ const ranked = buildSourceRegistry(
335
+ {
336
+ perplexity: {
337
+ sources: [
338
+ {
339
+ title: "Facebook post",
340
+ url: "https://facebook.com/groups/x/posts/1",
341
+ },
342
+ {
343
+ title: "Official docs",
344
+ url: "https://docs.example.com/lightpanda",
345
+ },
346
+ ],
347
+ },
348
+ bing: {
349
+ sources: [
350
+ {
351
+ title: "Facebook mirror",
352
+ url: "https://www.facebook.com/groups/x/posts/1",
353
+ },
354
+ { title: "Project", url: "https://example.com/lightpanda" },
355
+ ],
356
+ },
357
+ },
358
+ "Lightpanda browser documentation",
359
+ );
360
+ const facebookRank = ranked.findIndex((s) => s.domain === "facebook.com");
361
+ const docsRank = ranked.findIndex((s) => s.domain === "docs.example.com");
362
+ if (docsRank !== -1 && facebookRank !== -1 && docsRank < facebookRank) {
363
+ passMsg("source ranking: docs outrank multi-engine Facebook/social source");
364
+ } else {
365
+ failMsg(
366
+ `source ranking: unexpected order ${ranked.map((s) => s.domain).join(",")}`,
367
+ );
368
+ }
369
+
370
+ // ─── Phase 2: Quality Evaluator + Novelty Gate ────────────────────────
371
+
372
+ subsection("Novelty Gate — Jaccard similarity");
373
+ const {
374
+ jaccardSimilarity,
375
+ isDuplicateQuery,
376
+ tokenSet,
377
+ buildFallbackQueriesFromGaps,
378
+ } = await import("./src/search/research.mjs");
379
+
380
+ // tokenSet basics
381
+ const tokens1 = tokenSet("hello world");
382
+ const tokens2 = tokenSet("HELLO World");
383
+ if (tokens1.size === 2) passMsg("tokenSet: basic tokenization (2 tokens)");
384
+ else failMsg(`tokenSet: expected 2 tokens, got ${tokens1.size}`);
385
+ if (
386
+ tokens1.size === tokens2.size &&
387
+ [...tokens1].every((t) => tokens2.has(t))
388
+ )
389
+ passMsg("tokenSet: case-insensitive");
390
+ else failMsg("tokenSet: case sensitivity mismatch");
391
+
392
+ // jaccardSimilarity
393
+ const jExact = jaccardSimilarity("hello world", "hello world");
394
+ if (Math.abs(jExact - 1.0) < 0.001) passMsg("jaccard: exact match = 1.0");
395
+ else failMsg(`jaccard: exact match expected 1.0, got ${jExact}`);
396
+
397
+ const jNone = jaccardSimilarity("hello world", "foo bar baz");
398
+ if (Math.abs(jNone - 0.0) < 0.001) passMsg("jaccard: no overlap = 0.0");
399
+ else failMsg(`jaccard: no overlap expected 0.0, got ${jNone}`);
400
+
401
+ const jPartial = jaccardSimilarity(
402
+ "AI browser automation",
403
+ "browser automation testing",
404
+ );
405
+ if (jPartial > 0.0 && jPartial < 1.0)
406
+ passMsg(`jaccard: partial overlap = ${jPartial.toFixed(3)}`);
407
+ else failMsg(`jaccard: partial overlap expected 0<x<1, got ${jPartial}`);
408
+
409
+ const jNearDup = jaccardSimilarity("react hooks tutorial", "react hooks");
410
+ if (jNearDup > 0.6)
411
+ passMsg(`jaccard: near-duplicate = ${jNearDup.toFixed(3)}`);
412
+ else
413
+ failMsg(
414
+ `jaccard: near-duplicate expected >0.6, got ${jNearDup.toFixed(3)}`,
415
+ );
416
+
417
+ // isDuplicateQuery
418
+ const used = new Set();
419
+ used.add("react hooks tutorial 2024");
420
+ used.add("vue composition api");
421
+
422
+ if (
423
+ isDuplicateQuery("React Hooks Tutorial 2024", used, {
424
+ roundIndex: 0,
425
+ originalQuery: "react hooks",
426
+ })
427
+ ) {
428
+ passMsg("isDuplicateQuery: exact dup detected (case-insensitive)");
429
+ } else {
430
+ failMsg("isDuplicateQuery: exact dup not detected");
431
+ }
432
+
433
+ if (
434
+ isDuplicateQuery("react hooks tutorial 2024 guide", used, {
435
+ roundIndex: 2,
436
+ originalQuery: "react hooks",
437
+ })
438
+ ) {
439
+ passMsg("isDuplicateQuery: near-dup rejected (threshold 0.75)");
440
+ } else {
441
+ failMsg("isDuplicateQuery: near-dup not rejected");
442
+ }
443
+
444
+ if (
445
+ !isDuplicateQuery("svelte reactive statements", used, {
446
+ roundIndex: 2,
447
+ originalQuery: "react hooks",
448
+ })
449
+ ) {
450
+ passMsg("isDuplicateQuery: novel query passes");
451
+ } else {
452
+ failMsg("isDuplicateQuery: novel query incorrectly rejected");
453
+ }
454
+
455
+ // Original query rejection after round 1
456
+ if (
457
+ isDuplicateQuery("react hooks", used, {
458
+ roundIndex: 1,
459
+ originalQuery: "react hooks",
460
+ })
461
+ ) {
462
+ passMsg("isDuplicateQuery: original query rejected after round 1");
463
+ } else {
464
+ failMsg("isDuplicateQuery: original query not rejected after round 1");
465
+ }
466
+
467
+ // Original query allowed in round 0
468
+ if (
469
+ !isDuplicateQuery("react hooks", used, {
470
+ roundIndex: 0,
471
+ originalQuery: "react hooks",
472
+ })
473
+ ) {
474
+ passMsg("isDuplicateQuery: original query allowed in round 0");
475
+ } else {
476
+ failMsg("isDuplicateQuery: original query rejected in round 0");
477
+ }
478
+
479
+ // buildFallbackQueriesFromGaps
480
+ const fallbacks = buildFallbackQueriesFromGaps(
481
+ ["API support unknown", "production usage unclear"],
482
+ "Lightpanda browser",
483
+ new Set(["lightpanda browser overview"]),
484
+ 2,
485
+ 1,
486
+ );
487
+ if (fallbacks.length > 0 && fallbacks.length <= 2)
488
+ passMsg(`fallback queries: generated ${fallbacks.length} queries`);
489
+ else failMsg(`fallback queries: expected 1-2, got ${fallbacks.length}`);
490
+ // Gap text is embedded in researchGoal, not query
491
+ const gapTargets = fallbacks.some(
492
+ (f) =>
493
+ f.researchGoal.toLowerCase().includes("api") ||
494
+ f.researchGoal.toLowerCase().includes("production"),
495
+ );
496
+ if (gapTargets) passMsg("fallback queries: targets identified gaps");
497
+ else failMsg("fallback queries: gaps not targeted");
498
+
499
+ // ─── Phase 3: Action Planner ──────────────────────────────────────────
500
+
501
+ subsection("Action Planner — validation & parsing");
502
+ const { validateAction, parseActionPlan, queriesToActions } = await import(
503
+ "./src/search/research.mjs"
504
+ );
505
+
506
+ // validateAction
507
+ const validSearch = validateAction({
508
+ type: "search",
509
+ query: "React 19 features",
510
+ researchGoal: "Understand new features",
511
+ });
512
+ if (
513
+ validSearch &&
514
+ validSearch.type === "search" &&
515
+ validSearch.query === "React 19 features"
516
+ ) {
517
+ passMsg("validateAction: valid search action");
518
+ } else {
519
+ failMsg(
520
+ `validateAction: search action failed: ${JSON.stringify(validSearch)}`,
521
+ );
522
+ }
523
+
524
+ const validFetch = validateAction({
525
+ type: "fetchUrl",
526
+ url: "https://react.dev/learn",
527
+ researchGoal: "Read official docs",
528
+ });
529
+ if (
530
+ validFetch &&
531
+ validFetch.type === "fetchUrl" &&
532
+ validFetch.url === "https://react.dev/learn"
533
+ ) {
534
+ passMsg("validateAction: valid fetchUrl action");
535
+ } else {
536
+ failMsg(`validateAction: fetchUrl failed: ${JSON.stringify(validFetch)}`);
537
+ }
538
+
539
+ if (!validateAction({ type: "unknown" })) {
540
+ passMsg("validateAction: unknown type rejected");
541
+ } else {
542
+ failMsg("validateAction: unknown type not rejected");
543
+ }
544
+
545
+ if (!validateAction(null)) {
546
+ passMsg("validateAction: null input rejected");
547
+ } else {
548
+ failMsg("validateAction: null not rejected");
549
+ }
550
+
551
+ if (!validateAction({ type: "search" })) {
552
+ passMsg("validateAction: search without query rejected");
553
+ } else {
554
+ failMsg("validateAction: search without query not rejected");
555
+ }
556
+
557
+ if (!validateAction({ type: "fetchUrl" })) {
558
+ passMsg("validateAction: fetchUrl without url rejected");
559
+ } else {
560
+ failMsg("validateAction: fetchUrl without url not rejected");
561
+ }
562
+
563
+ // parseActionPlan
564
+ const planResult = parseActionPlan(
565
+ {
566
+ answer: `BEGIN_JSON
567
+ {"actions": [
568
+ {"type":"search","query":"React 19 server components","researchGoal":"SSR info"},
569
+ {"type":"fetchUrl","url":"https://react.dev/blog","researchGoal":"Blog post"},
570
+ {"type":"search","query":"","researchGoal":"Empty query"}
571
+ ]}
572
+ END_JSON`,
573
+ },
574
+ 3,
575
+ );
576
+ if (planResult.length === 2) {
577
+ passMsg("parseActionPlan: 2 valid actions (empty query filtered)");
578
+ } else {
579
+ failMsg(`parseActionPlan: expected 2 actions, got ${planResult.length}`);
580
+ }
581
+
582
+ // queriesToActions
583
+ const qActions = queriesToActions([
584
+ "react concurrent features",
585
+ { query: "vue 3 setup syntax", researchGoal: "Vue setup" },
586
+ "", // empty
587
+ ]);
588
+ if (qActions.length === 2 && qActions[0].type === "search") {
589
+ passMsg("queriesToActions: converts strings and objects");
590
+ } else {
591
+ failMsg(`queriesToActions: unexpected result: ${JSON.stringify(qActions)}`);
592
+ }
593
+
594
+ // ─── Phase 4: Citation Audit ──────────────────────────────────────────
595
+
596
+ subsection("Citation Audit");
597
+ const { auditCitations } = await import("./src/search/research.mjs");
598
+
599
+ // Test with valid citations
600
+ const sources1 = [
601
+ {
602
+ id: "S1",
603
+ title: "React docs",
604
+ fetch: { ok: true },
605
+ content: "x".repeat(200),
606
+ },
607
+ { id: "S2", title: "MDN", fetch: { ok: true }, content: "x".repeat(200) },
608
+ { id: "S3", title: "Stack Overflow", fetch: { ok: false } },
609
+ ];
610
+
611
+ const audit1 = auditCitations(
612
+ "React hooks are powerful [S1]. See also [S2] for details.",
613
+ sources1,
614
+ );
615
+ if (audit1.cited.includes("S1") && audit1.cited.includes("S2")) {
616
+ passMsg("citation audit: extracts S1, S2 from answer text");
617
+ } else {
618
+ failMsg(
619
+ `citation audit: cited list unexpected: ${JSON.stringify(audit1.cited)}`,
620
+ );
621
+ }
622
+ if (audit1.ok) {
623
+ passMsg("citation audit: ok=true when all cited sources exist");
624
+ } else {
625
+ failMsg("citation audit: ok should be true");
626
+ }
627
+ if (audit1.missing.length === 0) {
628
+ passMsg("citation audit: no missing citations");
629
+ } else {
630
+ failMsg(
631
+ `citation audit: unexpected missing: ${JSON.stringify(audit1.missing)}`,
632
+ );
633
+ }
634
+
635
+ // Test with missing citation
636
+ const audit2 = auditCitations("See reference [S9] for details.", sources1);
637
+ if (!audit2.ok && audit2.missing.length > 0) {
638
+ passMsg("citation audit: missing citation detected");
639
+ } else {
640
+ failMsg("citation audit: missing citation not detected");
641
+ }
642
+
643
+ // Test with unfetched source
644
+ const audit3 = auditCitations("Info from [S3] confirms this.", sources1);
645
+ if (audit3.unfetched.includes("S3")) {
646
+ passMsg("citation audit: unfetched source flagged");
647
+ } else {
648
+ failMsg("citation audit: unfetched source not flagged");
649
+ }
650
+
651
+ // Test with no citations in answer
652
+ const audit4 = auditCitations(
653
+ "This is a plain answer with no citations.",
654
+ sources1,
655
+ );
656
+ if (audit4.ok && audit4.cited.length === 0) {
657
+ passMsg("citation audit: no citations = ok");
658
+ } else {
659
+ failMsg("citation audit: empty citations unexpected");
660
+ }
661
+
662
+ // Test with empty/null inputs
663
+ const audit5 = auditCitations("", []);
664
+ if (audit5.ok && audit5.cited.length === 0) {
665
+ passMsg("citation audit: empty input handled");
666
+ } else {
667
+ failMsg("citation audit: empty input not handled");
668
+ }
669
+
670
+ // Mixed citation IDs (S and F)
671
+ const sourcesMixed = [
672
+ { id: "S1", title: "Source 1", content: "x".repeat(200) },
673
+ { id: "S2", title: "Source 2" },
674
+ ];
675
+ const auditMixed = auditCitations("Refs: [S1] [S2] [S5].", sourcesMixed);
676
+ if (
677
+ auditMixed.cited.includes("S1") &&
678
+ auditMixed.cited.includes("S2") &&
679
+ auditMixed.cited.includes("S5")
680
+ ) {
681
+ passMsg("citation audit: multiple citation IDs extracted");
682
+ } else {
683
+ failMsg(
684
+ `citation audit: unexpected cited: ${JSON.stringify(auditMixed.cited)}`,
685
+ );
686
+ }
687
+ if (auditMixed.unfetched.includes("S2")) {
688
+ passMsg("citation audit: S2 flagged as unfetched (no content)");
689
+ } else {
690
+ failMsg("citation audit: S2 should be flagged as unfetched");
691
+ }
692
+ }
693
+
694
+ // ─────────────────────────────────────────────────────────────────────────────
695
+ // Pre-flight Checks
696
+ // ─────────────────────────────────────────────────────────────────────────────
697
+
698
+ section("🔧 Pre-flight Checks");
699
+
700
+ // Check CDP module
701
+ if (!existsSync(join(__dir, "bin", "cdp.mjs"))) {
702
+ failMsg("bin/cdp.mjs missing - extension not properly installed");
703
+ process.exit(1);
704
+ } else {
705
+ passMsg("CDP module present");
706
+ }
707
+
708
+ // Check Node version
709
+ const nodeVersion = process.version.match(/v(\d+)/)?.[1];
710
+ if (nodeVersion && parseInt(nodeVersion) >= 22) {
711
+ passMsg(`Node.js 22+ (${process.version})`);
712
+ } else {
713
+ warnMsg(`Node.js ${process.version} (22+ recommended)`);
714
+ }
715
+
716
+ // Check Chrome launcher
717
+ if (!existsSync(join(__dir, "bin", "launch.mjs"))) {
718
+ warnMsg("bin/launch.mjs missing - Chrome auto-launch may fail");
719
+ } else {
720
+ passMsg("Chrome launcher present");
721
+ }
722
+
723
+ // ─────────────────────────────────────────────────────────────────────────────
724
+ // Flag & Option Tests
725
+ // ─────────────────────────────────────────────────────────────────────────────
726
+
727
+ if (["", "all", "flags", "quick", "smoke"].includes(mode)) {
728
+ section("🏷️ Flag & Option Tests");
729
+
730
+ subsection("Testing --inline flag (stdout output)...");
731
+ const inlineFile = join(resultsDir, "flag_inline.json");
732
+ const { out: inlineOut } = await runNode(
733
+ [join(__dir, "bin", "search.mjs"), "perplexity", "what is AI", "--inline"],
734
+ 90,
735
+ );
736
+ if (inlineOut) {
737
+ writeFileSync(inlineFile, inlineOut, "utf8");
738
+ const hasAnswer = checkJson(
739
+ inlineFile,
740
+ (d) => d.answer || d.perplexity?.answer,
741
+ );
742
+ if (hasAnswer) {
743
+ passMsg("--inline: JSON output to stdout");
744
+ } else {
745
+ warnMsg(`--inline: ${hasAnswer}`);
746
+ }
747
+ } else {
748
+ failMsg("--inline: timeout or no output");
749
+ }
750
+
751
+ subsection("Testing engine aliases...");
752
+ for (const alias of ["p", "g", "b"]) {
753
+ const aliasFile = join(resultsDir, `alias_${alias}.json`);
754
+ const { out: _aliasOut } = await runNode(
755
+ [
756
+ join(__dir, "bin", "search.mjs"),
757
+ alias,
758
+ "test query",
759
+ "--out",
760
+ aliasFile,
761
+ ],
762
+ 60,
763
+ );
764
+ if (existsSync(aliasFile) && aliasFile.length > 0) {
765
+ passMsg(`alias '${alias}': search completed`);
766
+ } else {
767
+ warnMsg(`alias '${alias}': failed (may be expected for some engines)`);
768
+ }
769
+ }
770
+ }
771
+
772
+ // ─────────────────────────────────────────────────────────────────────────────
773
+ // Edge Case Tests
774
+ // ─────────────────────────────────────────────────────────────────────────────
775
+
776
+ if (["", "all", "edge", "quick"].includes(mode)) {
777
+ section("🔍 Edge Case Tests");
778
+
779
+ subsection("Test 1: Special characters in query...");
780
+ const specialFile = join(resultsDir, "edge_special.json");
781
+ await runNode(
782
+ [
783
+ join(__dir, "bin", "search.mjs"),
784
+ "perplexity",
785
+ "C++ memory management & pointers",
786
+ "--out",
787
+ specialFile,
788
+ ],
789
+ 90,
790
+ );
791
+ if (existsSync(specialFile)) {
792
+ const queryCheck = checkJson(
793
+ specialFile,
794
+ (d) => d.query?.includes("C++") && d.query?.includes("&"),
795
+ );
796
+ if (queryCheck) {
797
+ passMsg("Edge1: special chars preserved");
798
+ } else {
799
+ warnMsg("Edge1: query mangled");
800
+ }
801
+ } else {
802
+ warnMsg("Edge1: search failed");
803
+ }
804
+
805
+ subsection("Test 2: Very short query...");
806
+ const shortFile = join(resultsDir, "edge_short.json");
807
+ await runNode(
808
+ [
809
+ join(__dir, "bin", "search.mjs"),
810
+ "perplexity",
811
+ "Docker",
812
+ "--out",
813
+ shortFile,
814
+ ],
815
+ 90,
816
+ );
817
+ if (existsSync(shortFile)) {
818
+ const hasAnswer = checkJson(shortFile, (d) => d.answer?.length > 10);
819
+ if (hasAnswer) {
820
+ passMsg("Edge2: short query handled");
821
+ } else {
822
+ warnMsg("Edge2: no answer");
823
+ }
824
+ } else {
825
+ warnMsg("Edge2: timeout");
826
+ }
827
+
828
+ subsection("Test 3: Unicode/international characters...");
829
+ const unicodeFile = join(resultsDir, "edge_unicode.json");
830
+ await runNode(
831
+ [
832
+ join(__dir, "bin", "search.mjs"),
833
+ "google",
834
+ "日本のAI技術について教えて",
835
+ "--out",
836
+ unicodeFile,
837
+ ],
838
+ 120,
839
+ );
840
+ if (existsSync(unicodeFile)) {
841
+ const unicodeCheck = checkJson(unicodeFile, (d) =>
842
+ d.query?.includes("日本"),
843
+ );
844
+ if (unicodeCheck) {
845
+ passMsg("Edge3: unicode preserved");
846
+ } else {
847
+ warnMsg("Edge3: unicode mangled");
848
+ }
849
+ } else {
850
+ warnMsg("Edge3: timeout");
851
+ }
852
+ }
853
+
854
+ // ─────────────────────────────────────────────────────────────────────────────
855
+ // GitHub Fetch Tests
856
+ // ─────────────────────────────────────────────────────────────────────────────
857
+
858
+ if (["", "all", "edge", "quick", "smoke"].includes(mode)) {
859
+ section("🐙 GitHub Fetch Tests");
860
+
861
+ subsection("Test 1: Blob file fetch (raw URL)...");
862
+ const ghBlobFile = join(resultsDir, "gh_blob.json");
863
+ const blobScript = `
864
+ import { fetchGitHubContent } from '../../src/github.mjs';
865
+ import { writeFileSync } from 'fs';
866
+ try {
867
+ const r = await fetchGitHubContent('https://github.com/expressjs/express/blob/master/Readme.md');
868
+ writeFileSync('${ghBlobFile.replace(/\\/g, "\\\\")}', JSON.stringify(r));
869
+ } catch(e) {
870
+ writeFileSync('${ghBlobFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: false, error: e.message }));
871
+ }
872
+ `;
873
+ const blobTmp = join(resultsDir, "_gh_blob_test.mjs");
874
+ writeFileSync(blobTmp, blobScript, "utf8");
875
+ await runNode([blobTmp], 20);
876
+
877
+ if (existsSync(ghBlobFile)) {
878
+ const result = checkJson(
879
+ ghBlobFile,
880
+ (r) => r.ok && r.content?.length > 100,
881
+ );
882
+ if (result) {
883
+ passMsg("GitHub blob: content fetched");
884
+ } else {
885
+ failMsg("GitHub blob: failed");
886
+ }
887
+ } else {
888
+ failMsg("GitHub blob: no output");
889
+ }
890
+
891
+ subsection("Test 2: HTTP fetcher pipeline...");
892
+ const ghFetchFile = join(resultsDir, "gh_fetcher.json");
893
+ const fetcherScript = `
894
+ import { fetchSourceHttp } from '../../src/fetcher.mjs';
895
+ import { writeFileSync } from 'fs';
896
+ try {
897
+ const r = await fetchSourceHttp('https://github.com/expressjs/express/blob/master/Readme.md');
898
+ writeFileSync('${ghFetchFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: r.ok, length: r.markdown?.length, error: r.error }));
899
+ } catch(e) {
900
+ writeFileSync('${ghFetchFile.replace(/\\/g, "\\\\")}', JSON.stringify({ ok: false, error: e.message }));
901
+ }
902
+ `;
903
+ const fetcherTmp = join(resultsDir, "_gh_fetcher_test.mjs");
904
+ writeFileSync(fetcherTmp, fetcherScript, "utf8");
905
+ await runNode([fetcherTmp], 20);
906
+
907
+ if (existsSync(ghFetchFile)) {
908
+ const result = checkJson(ghFetchFile, (r) => r.ok && r.length > 100);
909
+ if (result) {
910
+ passMsg("GitHub via fetcher: content fetched");
911
+ } else {
912
+ failMsg("GitHub via fetcher: failed");
913
+ }
914
+ } else {
915
+ failMsg("GitHub via fetcher: no output");
916
+ }
917
+ }
918
+
919
+ // ─────────────────────────────────────────────────────────────────────────────
920
+ // Summary
921
+ // ─────────────────────────────────────────────────────────────────────────────
922
+
923
+ section("📊 Test Summary");
924
+
925
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
926
+ const reportFile = join(resultsDir, "REPORT.md");
927
+
928
+ const report = `# GreedySearch Test Report
929
+
930
+ **Date:** ${new Date().toISOString()}
931
+ **Duration:** ${duration}s
932
+ **Results Directory:** ${resultsDir}
933
+ **Test Mode:** ${mode}
934
+
935
+ ## Summary
936
+
937
+ | Metric | Count |
938
+ |--------|-------|
939
+ | ✅ Passed | ${pass} |
940
+ | ❌ Failed | ${fail} |
941
+ | ⚠️ Warnings | ${warn} |
942
+ | ⊘ Skipped | ${skip} |
943
+ | **Total** | ${pass + fail + warn + skip} |
944
+
945
+ ${failures.length ? `### Failures\n${failures.map((f, i) => `${i + 1}. ${f}`).join("\n")}` : ""}
946
+ ${warnings.length ? `### Warnings\n${warnings.map((w, i) => `${i + 1}. ${w}`).join("\n")}` : ""}
947
+ `;
948
+
949
+ writeFileSync(reportFile, report, "utf8");
950
+
951
+ console.log(`\n${C.yellow}═══ Results ═══${C.reset}`);
952
+ console.log(` ${C.green}Passed: ${pass}${C.reset}`);
953
+ console.log(` ${C.red}Failed: ${fail}${C.reset}`);
954
+ console.log(` ${C.yellow}Warnings: ${warn}${C.reset}`);
955
+ console.log(` ${C.cyan}Skipped: ${skip}${C.reset}`);
956
+ console.log(` Duration: ${duration}s`);
957
+ console.log(`\n Results: ${resultsDir}`);
958
+ console.log(` Report: ${reportFile}\n`);
959
+
960
+ if (failures.length) {
961
+ console.log(`${C.red}Failures:${C.reset}`);
962
+ failures.forEach((f) => console.log(` ${C.red}•${C.reset} ${f}`));
963
+ console.log();
964
+ }
965
+ if (warnings.length) {
966
+ console.log(`${C.yellow}Warnings:${C.reset}`);
967
+ warnings.forEach((w) => console.log(` ${C.yellow}•${C.reset} ${w}`));
968
+ console.log();
969
+ }
970
+
971
+ process.exit(fail > 0 ? 1 : 0);