@apmantza/greedysearch-pi 1.9.0 → 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/CHANGELOG.md +46 -0
- package/README.md +11 -1
- package/bin/launch-visible.mjs +65 -0
- package/bin/launch.mjs +442 -417
- package/bin/search.mjs +757 -679
- package/extractors/bing-copilot.mjs +490 -374
- package/extractors/common.mjs +703 -596
- package/extractors/consent.mjs +421 -388
- package/extractors/selectors.mjs +55 -54
- package/index.ts +176 -177
- package/package.json +8 -3
- package/skills/greedy-search/skill.md +5 -19
- package/src/fetcher.mjs +666 -652
- package/src/formatters/synthesis.ts +1 -5
- package/src/search/output.mjs +23 -1
- package/src/search/research.mjs +1581 -0
- package/src/search/sources.mjs +488 -466
- package/src/search/synthesis-runner.mjs +52 -46
- package/src/tools/greedy-search-handler.ts +298 -124
- package/test.mjs +971 -534
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
} else {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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);
|