@molecule/api-ai-tools 1.5.18 → 1.5.20
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/README.md +320 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/no-test-run.d.ts +45 -0
- package/dist/no-test-run.d.ts.map +1 -0
- package/dist/no-test-run.js +125 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +5 -3
- package/dist/system-prompt.js +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +155 -7
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utilities.d.ts +207 -1
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +513 -15
- package/package.json +2 -2
package/dist/utilities.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
import { posix } from 'path';
|
|
8
|
+
import { commandRunsNoTests } from './no-test-run.js';
|
|
8
9
|
/**
|
|
9
10
|
* Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes),
|
|
10
11
|
* single-quoted strings prevent command substitution ($(), backticks) and variable expansion.
|
|
@@ -307,12 +308,27 @@ const LINE_REPORTER_FLAG = /--reporter([= ])line\b/;
|
|
|
307
308
|
* `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
|
|
308
309
|
* 8.3-minute walk of one suite could never trip the fail-fast note.
|
|
309
310
|
*
|
|
310
|
-
*
|
|
311
|
+
* A run being ITERATED on (capped, narrowed, or by file) gets `list,line` instead
|
|
312
|
+
* of `list` alone. `list` holds every failure's error for the summary at the end,
|
|
313
|
+
* so mid-run its log says only how long each failure took; `line` writes the body
|
|
314
|
+
* as the failure happens, and a look at a running suite (`wait_for_task`'s
|
|
315
|
+
* repeating-cause note, `suite-fail-fast.ts`) can then name the repeating cause by
|
|
316
|
+
* its error message. X1 flash-m76 walked 15 tests for 4.0 minutes, every one a
|
|
317
|
+
* `TimeoutError: page.waitForURL` from one missing `navigate('/')`.
|
|
318
|
+
*
|
|
319
|
+
* Only the command's last step is touched, and only its reporter, and only when that step runs
|
|
320
|
+
* something: `npx playwright test --list` takes two seconds and writes nothing per test, so
|
|
321
|
+
* neither reporter nor `--retries=0` is added to it ({@link isPlaywrightRunCommand}).
|
|
311
322
|
*
|
|
312
323
|
* @param command - The command as the executor sent it.
|
|
313
324
|
* @returns The command to run and one sentence per change made (empty when none).
|
|
314
325
|
*/
|
|
315
326
|
export function streamingBackgroundCommand(command) {
|
|
327
|
+
// A command that runs no test is not a run to keep a growing log for: nothing about it is
|
|
328
|
+
// rewritten (flash-m79's `--list` was read as a whole-suite run end to end, and got a ~4 KB
|
|
329
|
+
// refusal to send itself again with `--max-failures=15`).
|
|
330
|
+
if (commandRunsNoTests(command))
|
|
331
|
+
return { command, changes: [] };
|
|
316
332
|
const changes = [];
|
|
317
333
|
let out = command;
|
|
318
334
|
const tail = TRAILING_TAIL.exec(out);
|
|
@@ -324,38 +340,143 @@ export function streamingBackgroundCommand(command) {
|
|
|
324
340
|
// A Playwright run behind `| head -N` loses its tally, which prints last (X1
|
|
325
341
|
// flash-m32 counted ✓/✘ marks by hand); the full log comes back anyway.
|
|
326
342
|
const head = /\s*\|\s*head(?:\s+-n)?\s+-?\d+\s*$/.exec(out);
|
|
327
|
-
if (head &&
|
|
343
|
+
if (head && isPlaywrightRunCommand(out)) {
|
|
328
344
|
out = out.slice(0, head.index);
|
|
329
345
|
changes.push(`Dropped the trailing \`${head[0].trim()}\`: Playwright prints its pass/fail count last, and it would be cut.`);
|
|
330
346
|
}
|
|
331
347
|
const lastStep = out.split(/&&|\|\||;/).at(-1) ?? '';
|
|
332
|
-
const finalPlaywrightRun =
|
|
348
|
+
const finalPlaywrightRun = isPlaywrightRunCommand(lastStep) &&
|
|
333
349
|
// `2>&1` only joins stderr to the log; any other pipe or redirect sends the output elsewhere.
|
|
334
350
|
!/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''));
|
|
351
|
+
// A diagnosis or a re-run (capped, last-failed, grepped, or named files) walks each failure
|
|
352
|
+
// once: the suite's own `retries` doubled every failure of X1 flash-m71's first whole run,
|
|
353
|
+
// which had dropped the `--retries=0` it used one command earlier. The uncapped proof run
|
|
354
|
+
// keeps the suite's retries.
|
|
355
|
+
const iterating = /--max-failures\b|--last-failed\b|(?:^|\s)(?:-g|--grep)\b|\S+\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+)?\b/.test(lastStep);
|
|
356
|
+
// `list` is the shape the platform reads a background suite back in (the fail-fast note and
|
|
357
|
+
// the Tests bar both parse its ` ✓/✘ <n> … (…)` lines), and it prints each failure's error
|
|
358
|
+
// only in the summary at the END. A run being iterated on is the one whose failures are read
|
|
359
|
+
// mid-run and stopped early, and for that the log has to carry the error as it happens —
|
|
360
|
+
// `line` writes exactly that, so an iterating run gets both reporters and a proof run, which
|
|
361
|
+
// nothing stops early, keeps `list` alone rather than doubling its log.
|
|
362
|
+
const wanted = iterating ? 'list,line' : 'list';
|
|
335
363
|
if (finalPlaywrightRun && LINE_REPORTER_FLAG.test(lastStep)) {
|
|
336
364
|
// Only the last step is rewritten, and `lastStep` is a suffix of `out`.
|
|
337
365
|
out =
|
|
338
366
|
out.slice(0, out.length - lastStep.length) +
|
|
339
|
-
lastStep.replace(LINE_REPORTER_FLAG,
|
|
340
|
-
changes.push(
|
|
341
|
-
'finishes, and it is the shape the platform reads back from a background run.'
|
|
367
|
+
lastStep.replace(LINE_REPORTER_FLAG, `--reporter$1${wanted}`);
|
|
368
|
+
changes.push(`Switched \`--reporter=line\` to \`--reporter=${wanted}\`: it also writes each test result as it ` +
|
|
369
|
+
'finishes, and it is the shape the platform reads back from a background run.' +
|
|
370
|
+
(iterating
|
|
371
|
+
? ' `line` stays alongside it so each failure writes its error to the log as it happens — that is what a mid-run look reads.'
|
|
372
|
+
: ''));
|
|
373
|
+
}
|
|
374
|
+
else if (finalPlaywrightRun && iterating && /--reporter([= ])list\b/.test(lastStep)) {
|
|
375
|
+
// Only the last step's reporter, the same rule as above.
|
|
376
|
+
out =
|
|
377
|
+
out.slice(0, out.length - lastStep.length) +
|
|
378
|
+
lastStep.replace(/--reporter([= ])list\b/, '--reporter$1list,line');
|
|
379
|
+
changes.push('Added `line` to your `--reporter=list`: `list` writes each test result as it finishes but ' +
|
|
380
|
+
'holds every failure’s error for the summary at the end, and `line` writes it as the failure ' +
|
|
381
|
+
'happens — which is what names a cause that repeats while this run is still going.');
|
|
342
382
|
}
|
|
343
383
|
else if (finalPlaywrightRun && !/--reporter\b/.test(lastStep)) {
|
|
344
|
-
out = `${out.trimEnd()} --reporter
|
|
345
|
-
changes.push(
|
|
346
|
-
'(the config reporter may print nothing until the end).'
|
|
384
|
+
out = `${out.trimEnd()} --reporter=${wanted}`;
|
|
385
|
+
changes.push(`Added \`--reporter=${wanted}\` so each test result is written to the log as it finishes ` +
|
|
386
|
+
'(the config reporter may print nothing until the end).' +
|
|
387
|
+
(iterating
|
|
388
|
+
? ' `line` writes each failure’s error as it happens, which is what a mid-run look reads.'
|
|
389
|
+
: ''));
|
|
347
390
|
}
|
|
348
|
-
// A diagnosis or a re-run (capped, last-failed, grepped, or named files) walks each failure
|
|
349
|
-
// once: the suite's own `retries` doubled every failure of X1 flash-m71's first whole run,
|
|
350
|
-
// which had dropped the `--retries=0` it used one command earlier. The uncapped proof run
|
|
351
|
-
// keeps the suite's retries.
|
|
352
|
-
const iterating = /--max-failures\b|--last-failed\b|(?:^|\s)(?:-g|--grep)\b|\S+\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+)?\b/.test(lastStep);
|
|
353
391
|
if (finalPlaywrightRun && iterating && !/--retries\b/.test(lastStep)) {
|
|
354
392
|
out = `${out.trimEnd()} --retries=0`;
|
|
355
393
|
changes.push('Added `--retries=0`: a diagnosis or re-run walks each failure once; keep the suite’s retries for the proof run.');
|
|
356
394
|
}
|
|
357
395
|
return { command: out, changes };
|
|
358
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* How a shell command starts each runner whose whole-suite run saturates the
|
|
399
|
+
* sandbox it shares. The three are the runners this platform runs whole suites
|
|
400
|
+
* of from a sandbox; `suiteRunKind` (molecule-dev `api/src/ai/suite-proof.ts`)
|
|
401
|
+
* is the platform's fuller list of URL-testing runners, and the shapes here are
|
|
402
|
+
* its shapes.
|
|
403
|
+
*/
|
|
404
|
+
const SUITE_RUN_COMMANDS = [
|
|
405
|
+
// `playwright test`, not a bare `playwright` (`--version` is not a run).
|
|
406
|
+
/(?:^|[\s;&|(])(?:npx\s+(?:--no-install\s+)?)?(?:\S*\/)?playwright\s+test\b/,
|
|
407
|
+
// Vitest, however it is invoked (`vitest`, `npx vitest run`).
|
|
408
|
+
/(?:^|[\s;&|(])(?:npx\s+(?:--no-install\s+)?)?(?:\S*\/)?vitest\b/,
|
|
409
|
+
// `hurl --test …` or `hurl … x.hurl`.
|
|
410
|
+
/(?:^|[\s;&|(])(?:\S*\/)?hurl\s+[^|\n]*(?:--test|\.hurl)/,
|
|
411
|
+
];
|
|
412
|
+
/**
|
|
413
|
+
* Whether a command runs a whole test suite — a Playwright, Vitest or Hurl run
|
|
414
|
+
* — rather than a build, an install or a one-off script.
|
|
415
|
+
*
|
|
416
|
+
* A command that invokes a runner only to print is not one of these
|
|
417
|
+
* ({@link commandRunsNoTests}): `npx playwright test --list` prints the tests it would run and
|
|
418
|
+
* exits, and `npx playwright install`, `vitest list` and `hurl --help` start no test either.
|
|
419
|
+
*
|
|
420
|
+
* @param command - The shell command.
|
|
421
|
+
* @returns True when the command starts one of those runners.
|
|
422
|
+
*/
|
|
423
|
+
export function isSuiteRunCommand(command) {
|
|
424
|
+
return !commandRunsNoTests(command) && SUITE_RUN_COMMANDS.some((re) => re.test(command));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Whether a command starts a Playwright run — `playwright test` in a form that runs something.
|
|
428
|
+
*
|
|
429
|
+
* `npx playwright test --list` matched every `\bplaywright\s+test\b` test in the platform and was
|
|
430
|
+
* then treated as a run: held (flash-m79's config check), backgrounded for a run that outlasts
|
|
431
|
+
* the tool's ceiling, and rewritten with `--reporter`. A list takes two seconds, prints its lines
|
|
432
|
+
* and exits, so it is none of those; {@link commandRunsNoTests} is what decides.
|
|
433
|
+
*
|
|
434
|
+
* @param command - The shell command.
|
|
435
|
+
* @returns True when the command starts a Playwright run.
|
|
436
|
+
*/
|
|
437
|
+
export function isPlaywrightRunCommand(command) {
|
|
438
|
+
return /\bplaywright\s+test\b/.test(command) && !commandRunsNoTests(command);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Start a background suite run at lower CPU and IO priority, so the sandbox it
|
|
442
|
+
* shares with everything else stays responsive to what the executor does next.
|
|
443
|
+
*
|
|
444
|
+
* A whole-suite Playwright run saturates the one container the app's Vite
|
|
445
|
+
* server, its API process and every other command also live in: on X1 flash-m75
|
|
446
|
+
* the model backgrounded a 139-test run at +36:23 and the two-word shell
|
|
447
|
+
* command it sent next — tick its plan file — returned nothing for 2 min 40 s,
|
|
448
|
+
* measured on the chat payload and on the live-stream follower independently.
|
|
449
|
+
* The backgrounding note is right that the executor should go do other work;
|
|
450
|
+
* what it cannot promise is that the sandbox will let it.
|
|
451
|
+
*
|
|
452
|
+
* `nice -n 10` lowers the run's CPU share and `ionice -c3` its IO class. Both
|
|
453
|
+
* are PROBED in the sandbox by RUNNING them, not with `command -v`: a syscall a
|
|
454
|
+
* container's seccomp policy refuses leaves `-v` passing while the real run
|
|
455
|
+
* fails. Each is applied only if its probe passed, so a sandbox with neither,
|
|
456
|
+
* or with only one, runs the suite exactly as it did before. Playwright's own
|
|
457
|
+
* workers are children of this shell and inherit both (niceness and IO class
|
|
458
|
+
* are inherited across fork/exec).
|
|
459
|
+
*
|
|
460
|
+
* Only a command the caller has already detached reaches this: foreground
|
|
461
|
+
* commands are never touched.
|
|
462
|
+
*
|
|
463
|
+
* @param command - The command as the executor sent it; the suite detection reads this.
|
|
464
|
+
* @param launch - The shell line that would start it, with no prefix of its own.
|
|
465
|
+
* @returns The line to run, and one sentence for the tool note ('' when unchanged).
|
|
466
|
+
*/
|
|
467
|
+
export function suiteRunPriorityLaunch(command, launch) {
|
|
468
|
+
if (!isSuiteRunCommand(command))
|
|
469
|
+
return { launch, note: '' };
|
|
470
|
+
return {
|
|
471
|
+
// `$p` is deliberately unquoted: it expands to the prefix words, and to
|
|
472
|
+
// nothing at all when neither probe passed.
|
|
473
|
+
launch: "p=''; nice -n 10 true >/dev/null 2>&1 && p='nice -n 10'; " +
|
|
474
|
+
'ionice -c3 true >/dev/null 2>&1 && p="$p ionice -c3"; ' +
|
|
475
|
+
`$p ${launch}`,
|
|
476
|
+
note: 'Started at lower CPU and IO priority, so commands you run while it works stay ' +
|
|
477
|
+
'responsive (the suite itself may take a little longer).',
|
|
478
|
+
};
|
|
479
|
+
}
|
|
359
480
|
// ── Command blocking ──────────────────────────────────────────────────────────
|
|
360
481
|
/**
|
|
361
482
|
* Commands that dump environment variables — blocked to prevent secret leakage.
|
|
@@ -474,6 +595,64 @@ export function isValidGlob(pattern) {
|
|
|
474
595
|
// `@` has no meaning to either.
|
|
475
596
|
return /^[A-Za-z0-9@*?._/()[\]-]+$/.test(pattern);
|
|
476
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* The `find` predicate for a `find_files` pattern.
|
|
600
|
+
*
|
|
601
|
+
* `find -name` compares a pattern against a file's BASENAME, so a pattern
|
|
602
|
+
* containing a slash (`helpers/*`, `e2e/*.spec.ts`, `src` + `**` + `/App.tsx`)
|
|
603
|
+
* can never match and the tool answers `[]` (xr-molecule-flash-m77, on two
|
|
604
|
+
* calls in one run). Such a pattern is matched with `-path` instead: an fnmatch
|
|
605
|
+
* glob over the whole printed path, whose `*` crosses `/` — the rule
|
|
606
|
+
* `.gitignore` uses, and the reason a bare `helpers/*` means "a `helpers/`
|
|
607
|
+
* directory anywhere below the root". The pattern is anchored with a leading
|
|
608
|
+
* `*` then `/` ("any parent") so the search root — an absolute
|
|
609
|
+
* `/workspace/<app>` in a sandbox — need not be spelled out.
|
|
610
|
+
*
|
|
611
|
+
* A doubled-star directory segment keeps its usual meaning of ZERO or more
|
|
612
|
+
* directories; `-path` alone would demand at least one, so the pattern is also
|
|
613
|
+
* emitted with each such segment removed.
|
|
614
|
+
*
|
|
615
|
+
* @param pattern - A pattern already accepted by {@link isValidGlob}.
|
|
616
|
+
* @returns A `find` predicate: `-name '…'` for a bare name, else one or more
|
|
617
|
+
* `-path '…'` alternatives, ORed inside parens when there is more than one.
|
|
618
|
+
*/
|
|
619
|
+
export function findPatternArgs(pattern) {
|
|
620
|
+
if (!pattern.includes('/'))
|
|
621
|
+
return `-name ${shellQuote(pattern)}`;
|
|
622
|
+
const paths = pathPatternVariants(pattern).map((p) => `-path ${shellQuote(`*/${p}`)}`);
|
|
623
|
+
return paths.length === 1 ? paths[0] : `\\( ${paths.join(' -o ')} \\)`;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The `-path` globs for a pattern that contains a slash, one per way each
|
|
627
|
+
* doubled-star directory segment can stand for zero directories.
|
|
628
|
+
*
|
|
629
|
+
* Dropping the segment is not a convenience: `*` already crosses `/` in a
|
|
630
|
+
* `-path` glob, so the KEPT form (`src` + `**` + `/App.tsx`) matches one or more
|
|
631
|
+
* intervening directories at any depth, and only the DROPPED form
|
|
632
|
+
* (`src/App.tsx`) matches the file sitting directly in `src/`. Both are what
|
|
633
|
+
* the glob means to a caller.
|
|
634
|
+
*
|
|
635
|
+
* @param pattern - The glob, e.g. `src` + `**` + `/App.tsx`.
|
|
636
|
+
* @returns The path globs, each without the leading "any parent" anchor (added
|
|
637
|
+
* by {@link findPatternArgs}).
|
|
638
|
+
*/
|
|
639
|
+
function pathPatternVariants(pattern) {
|
|
640
|
+
const rooted = pattern.replace(/^(?:\.?\/)+/, '');
|
|
641
|
+
const out = new Set();
|
|
642
|
+
const pending = [{ text: rooted, from: 0 }];
|
|
643
|
+
while (pending.length > 0) {
|
|
644
|
+
const { text, from } = pending.pop();
|
|
645
|
+
const at = text.indexOf('**/', from);
|
|
646
|
+
if (at === -1) {
|
|
647
|
+
out.add(text);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
pending.push({ text, from: at + '**/'.length }); // keep it: one or more dirs
|
|
651
|
+
// Drop it — the zero-directories case, which `-path` cannot express on its own.
|
|
652
|
+
pending.push({ text: text.slice(0, at) + text.slice(at + '**/'.length), from: at });
|
|
653
|
+
}
|
|
654
|
+
return [...out];
|
|
655
|
+
}
|
|
477
656
|
/**
|
|
478
657
|
* Validate a file tool's `path` argument is a non-empty string. A weak model
|
|
479
658
|
* sometimes omits it or passes a non-string, which would otherwise crash
|
|
@@ -592,6 +771,92 @@ export const MAX_READ_RETURN_CHARS = 80 * 1024;
|
|
|
592
771
|
* file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
|
|
593
772
|
*/
|
|
594
773
|
export const SMALL_FILE_WHOLE_CHARS = MAX_READ_RETURN_CHARS;
|
|
774
|
+
/**
|
|
775
|
+
* How large an HTML document must be before a whole-file read is replaced by its
|
|
776
|
+
* outLINE — much smaller than {@link MAX_READ_RETURN_CHARS}, because the cost of
|
|
777
|
+
* an HTML read is almost never worth it.
|
|
778
|
+
*
|
|
779
|
+
* An entry document is boilerplate the reader rarely needs in full: meta tags,
|
|
780
|
+
* stylesheet and preload links, a `<style>` block, and a handful of `<script>`
|
|
781
|
+
* tags. The parts an executor acts on — the body's structure, where a script is
|
|
782
|
+
* loaded — are all visible in an outline of one line per tag. X0 flash-m77 paid
|
|
783
|
+
* 63,735 chars for one such read: ~60 KB of it was the IDE's own preview bridges,
|
|
784
|
+
* and the read pushed the transcript past its context cap, so the e2e specs the
|
|
785
|
+
* executor had just read were trimmed to summaries and it read the whole suite
|
|
786
|
+
* again — twice (findings, +01:23 and +02:56). The scaffold now keeps those
|
|
787
|
+
* bridges in separate files, but a template's own head is still ~5-12 KB, and a
|
|
788
|
+
* static-site or SSR entry can be far more.
|
|
789
|
+
*
|
|
790
|
+
* The outline is not a truncation: the note beside it names the exact
|
|
791
|
+
* offset/limit that returns the whole document in one call.
|
|
792
|
+
*/
|
|
793
|
+
export const MAX_HTML_INLINE_CHARS = 16 * 1024;
|
|
794
|
+
/** Most lines an HTML outline prints before it stops and says so. */
|
|
795
|
+
export const MAX_HTML_OUTLINE_LINES = 200;
|
|
796
|
+
/** Longest a single outline line may be before it is cut. */
|
|
797
|
+
const MAX_HTML_OUTLINE_LINE_CHARS = 200;
|
|
798
|
+
/** Whether `text` is an HTML document (a doctype or an `<html` root tag). */
|
|
799
|
+
export function looksLikeHtml(text) {
|
|
800
|
+
return /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(text);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* An OUTLINE of an HTML document: its head's tags and the tags that open each
|
|
804
|
+
* body section, one per line, with line numbers.
|
|
805
|
+
*
|
|
806
|
+
* Line-based on purpose — a parser would be a dependency and an attack surface
|
|
807
|
+
* for a read that only has to be *navigable*. A line is shown when it starts with
|
|
808
|
+
* `<` after trimming, which is what keeps the inside of a `<style>` block, a
|
|
809
|
+
* `<script>` body or a prerendered text section out of the outline while keeping
|
|
810
|
+
* every tag the document is built from. Long lines (a minified tag, a data URI)
|
|
811
|
+
* are cut, and the whole outline is capped, so a pathological document cannot
|
|
812
|
+
* turn one read into another wall of text.
|
|
813
|
+
*
|
|
814
|
+
* @param text - The document's full text.
|
|
815
|
+
* @param label - What to call the document in the outline's header (its path).
|
|
816
|
+
* @returns The outline — or null when `text` is not an HTML document.
|
|
817
|
+
*/
|
|
818
|
+
export function htmlOutline(text, label = 'document') {
|
|
819
|
+
if (!looksLikeHtml(text))
|
|
820
|
+
return null;
|
|
821
|
+
// "0 KB" for anything under a kilobyte reads like a bug; say the real unit.
|
|
822
|
+
const size = (chars) => chars >= 1024 ? `${Math.round(chars / 1024)} KB` : `${chars} char${chars === 1 ? '' : 's'}`;
|
|
823
|
+
const lines = text.split('\n');
|
|
824
|
+
const out = [];
|
|
825
|
+
let skipped = 0;
|
|
826
|
+
let skippedChars = 0;
|
|
827
|
+
let truncated = false;
|
|
828
|
+
const flushSkipped = () => {
|
|
829
|
+
if (skipped === 0)
|
|
830
|
+
return;
|
|
831
|
+
out.push(` … ${skipped} more line${skipped === 1 ? '' : 's'} (${size(skippedChars)}) …`);
|
|
832
|
+
skipped = 0;
|
|
833
|
+
skippedChars = 0;
|
|
834
|
+
};
|
|
835
|
+
for (let i = 0; i < lines.length; i++) {
|
|
836
|
+
const line = lines[i] ?? '';
|
|
837
|
+
const trimmed = line.trim();
|
|
838
|
+
if (trimmed !== '' && trimmed.startsWith('<')) {
|
|
839
|
+
if (out.length >= MAX_HTML_OUTLINE_LINES) {
|
|
840
|
+
truncated = true;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
flushSkipped();
|
|
844
|
+
const shown = trimmed.length > MAX_HTML_OUTLINE_LINE_CHARS
|
|
845
|
+
? `${trimmed.slice(0, MAX_HTML_OUTLINE_LINE_CHARS)}… (${trimmed.length} chars on this line)`
|
|
846
|
+
: trimmed;
|
|
847
|
+
out.push(`${String(i + 1).padStart(6)} ${shown}`);
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
skipped++;
|
|
851
|
+
skippedChars += line.length + 1;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
flushSkipped();
|
|
855
|
+
const header = `OUTLINE of ${label} — ${size(text.length)}, ${lines.length} lines. ` +
|
|
856
|
+
`Its tags and the tags that open each section, one line each; tag bodies (CSS, JS, text) are not shown.` +
|
|
857
|
+
(truncated ? ` (Outline capped at ${MAX_HTML_OUTLINE_LINES} lines.)` : '');
|
|
858
|
+
return `${header}\n\n${out.join('\n')}`;
|
|
859
|
+
}
|
|
595
860
|
/** Max search results. */
|
|
596
861
|
export const MAX_SEARCH_RESULTS = 50;
|
|
597
862
|
/** Max find results. */
|
|
@@ -713,6 +978,16 @@ function truncateMiddleBody(s, maxLength) {
|
|
|
713
978
|
`\n\n... [middle truncated — ${omitted} chars omitted; the head AND the tail are shown, a failing command's error is usually near the end] ...\n\n` +
|
|
714
979
|
s.slice(s.length - tailLen));
|
|
715
980
|
}
|
|
981
|
+
/**
|
|
982
|
+
* A Playwright failure block's header line:
|
|
983
|
+
* ` 3) [chromium] › e2e/a.spec.ts:5:1 › home › shows the title ────────`.
|
|
984
|
+
*
|
|
985
|
+
* Two things vary and both are optional here. The `[<project>] › ` half is absent from a run
|
|
986
|
+
* whose config declares no projects, and the `line` reporter draws a rule after the title on
|
|
987
|
+
* the same line. Both forms name a title path, so a header is only a header when the line
|
|
988
|
+
* carries a `›` — a plain numbered list in a build log must not read as test failures.
|
|
989
|
+
*/
|
|
990
|
+
const FAILURE_BLOCK_HEADER = /^\s*\d+\)\s+(?:\[[^\]\n]*\]\s*›\s*)?(?=[^\n]*›)(.+?)\s*─*\s*$/;
|
|
716
991
|
/**
|
|
717
992
|
* The numbered failure blocks of a Playwright report (` 1) [chromium] › file:line › title`
|
|
718
993
|
* up to the next block or the tally).
|
|
@@ -725,7 +1000,7 @@ export function failureBlocks(s) {
|
|
|
725
1000
|
const blocks = [];
|
|
726
1001
|
let current = null;
|
|
727
1002
|
for (const line of lines) {
|
|
728
|
-
if (
|
|
1003
|
+
if (FAILURE_BLOCK_HEADER.test(line)) {
|
|
729
1004
|
if (current)
|
|
730
1005
|
blocks.push(current.join('\n').trimEnd());
|
|
731
1006
|
current = [line];
|
|
@@ -770,6 +1045,229 @@ export function runningOutputSliceCommand(log, own) {
|
|
|
770
1045
|
return (`{ ${read} | grep -aE ${shellQuote(RUNNING_FAILURE_LINE_RE)} | head -${RUNNING_FAILURE_LINES} | cut -c1-300; ` +
|
|
771
1046
|
`echo; ${read} | tail -c ${RUNNING_TAIL_BYTES}; }`);
|
|
772
1047
|
}
|
|
1048
|
+
/** Bytes of a running log a repeating-cause look reads back. */
|
|
1049
|
+
export const REPEATING_CAUSE_TAIL_BYTES = 40_000;
|
|
1050
|
+
/**
|
|
1051
|
+
* The shell command whose stdout is enough of a running log to tell whether one cause is
|
|
1052
|
+
* repeating: its tail, {@link REPEATING_CAUSE_TAIL_BYTES} of it.
|
|
1053
|
+
*
|
|
1054
|
+
* {@link runningOutputSliceCommand} is the wrong read for this: it keeps only the FIRST 40
|
|
1055
|
+
* failure lines plus 1 000 bytes, so on a run whose failures print a body each — which is what
|
|
1056
|
+
* a `line` reporter does, and the only form that names the cause as an error message rather than
|
|
1057
|
+
* a duration — three bodies do not fit, and the repeat is invisible. This read is not shown to
|
|
1058
|
+
* anyone; it is parsed.
|
|
1059
|
+
*
|
|
1060
|
+
* @param log - The task log path.
|
|
1061
|
+
* @param own - A file the command redirects its own output to, if any.
|
|
1062
|
+
* @returns The shell command, safe to hand to the execution backend as-is.
|
|
1063
|
+
*/
|
|
1064
|
+
export function runningCauseCommand(log, own) {
|
|
1065
|
+
const read = `cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null`;
|
|
1066
|
+
return `${read} | tail -c ${REPEATING_CAUSE_TAIL_BYTES}`;
|
|
1067
|
+
}
|
|
1068
|
+
/** Failures that must share one signature before a running suite's repeating cause is named. */
|
|
1069
|
+
export const REPEATING_FAILURE_MIN = 3;
|
|
1070
|
+
/** ANSI SGR sequences — the `line` reporter writes cursor moves even into a file. */
|
|
1071
|
+
// eslint-disable-next-line no-control-regex -- an escape sequence IS a control character
|
|
1072
|
+
const ANSI_SGR = /\u001B\[[0-9;]*[A-Za-z]/g;
|
|
1073
|
+
/**
|
|
1074
|
+
* A `list`-reporter result line: ` ✘ 3 [chromium] › e2e/a.spec.ts:5:1 › A › does x (15.6s)`.
|
|
1075
|
+
* The `[<project>]` half is optional (a config with no projects prints none), as is the title
|
|
1076
|
+
* path's `›` tail. Durations are Playwright's own: `ms`, `s`, `m`.
|
|
1077
|
+
*/
|
|
1078
|
+
const LIST_RESULT_LINE = /^\s*([✓✘])\s+\d+\s+(?:\[[^\]\n]*\]\s*›\s*)?(.+?)\s+\((\d+(?:\.\d+)?)\s*(ms|s|m)\)\s*$/;
|
|
1079
|
+
/** A title path's location segment (`…/articles.spec.ts:32:3`). */
|
|
1080
|
+
const TITLE_LOCATION = /^(.*?:\d+)(?::\d+)?$/;
|
|
1081
|
+
/**
|
|
1082
|
+
* Split a Playwright title path (`…/a.spec.ts:5:1 › Home › shows the title`) into the location
|
|
1083
|
+
* and the test's own title, dropping the column (a re-run names `file:line`).
|
|
1084
|
+
*
|
|
1085
|
+
* @param titlePath - The title path the log writes.
|
|
1086
|
+
* @returns The location ('' when the path names none) and the title.
|
|
1087
|
+
*/
|
|
1088
|
+
function splitTitlePath(titlePath) {
|
|
1089
|
+
const parts = titlePath.split(' › ');
|
|
1090
|
+
const location = TITLE_LOCATION.exec(parts[0]?.trim() ?? '')?.[1];
|
|
1091
|
+
if (location)
|
|
1092
|
+
return { where: location, title: parts.length > 1 ? (parts.at(-1) ?? '').trim() : '' };
|
|
1093
|
+
return { where: '', title: parts.join(' › ').trim() };
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* The seconds a Playwright duration is worth, in Playwright's units.
|
|
1097
|
+
*
|
|
1098
|
+
* @param value - The digits the log printed.
|
|
1099
|
+
* @param unit - `ms`, `s` or `m`.
|
|
1100
|
+
* @returns Seconds.
|
|
1101
|
+
*/
|
|
1102
|
+
function durationSeconds(value, unit) {
|
|
1103
|
+
const n = Number(value);
|
|
1104
|
+
if (unit === 'ms')
|
|
1105
|
+
return n / 1000;
|
|
1106
|
+
return unit === 'm' ? n * 60 : n;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Every failed test a running log names, in the order it names them.
|
|
1110
|
+
*
|
|
1111
|
+
* @param log - The log so far (ANSI already stripped).
|
|
1112
|
+
* @returns The failed tests.
|
|
1113
|
+
*/
|
|
1114
|
+
function failedTests(log) {
|
|
1115
|
+
const out = [];
|
|
1116
|
+
for (const line of log.split('\n')) {
|
|
1117
|
+
const m = LIST_RESULT_LINE.exec(line);
|
|
1118
|
+
if (!m || m[1] !== '✘')
|
|
1119
|
+
continue;
|
|
1120
|
+
const { where, title } = splitTitlePath(m[2]);
|
|
1121
|
+
out.push({ where, title, seconds: durationSeconds(m[3], m[4]) });
|
|
1122
|
+
}
|
|
1123
|
+
return out;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* The error a failure block reports: the first line of its body that names a thrown error
|
|
1127
|
+
* (`TimeoutError: page.waitForURL: Timeout 10000ms exceeded.`), else its first real line.
|
|
1128
|
+
*
|
|
1129
|
+
* The body's first line is usually the runner's own sentence (`Test timeout of 15000ms exceeded
|
|
1130
|
+
* while running "beforeEach" hook.`), which changes with the hook; the thrown error below it is
|
|
1131
|
+
* what repeats verbatim, so it is preferred.
|
|
1132
|
+
*
|
|
1133
|
+
* @param block - One block from {@link failureBlocks}.
|
|
1134
|
+
* @returns The error line, or null when the block has no body.
|
|
1135
|
+
*/
|
|
1136
|
+
function failureMessage(block) {
|
|
1137
|
+
const body = block
|
|
1138
|
+
.split('\n')
|
|
1139
|
+
.slice(1)
|
|
1140
|
+
.map((l) => l.replace(ANSI_SGR, '').trim())
|
|
1141
|
+
// Progress lines the `list` reporter interleaves with the `line` reporter's blocks are not
|
|
1142
|
+
// the block's own words, and a code frame or an `at …` line is where the error happened, not
|
|
1143
|
+
// what it said.
|
|
1144
|
+
.filter((l) => l &&
|
|
1145
|
+
!LIST_RESULT_LINE.test(l) &&
|
|
1146
|
+
!/^(?:at\s|\d+ \||>\s*\d+ \||\|)/.test(l) &&
|
|
1147
|
+
!/^Error Context:/.test(l));
|
|
1148
|
+
const thrown = body.find((l) => /^[A-Za-z_$][\w.$]*(?:Error|Exception)\b/.test(l));
|
|
1149
|
+
return thrown ?? body[0] ?? null;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* The largest group of items sharing a key.
|
|
1153
|
+
*
|
|
1154
|
+
* @param groups - Keyed groups, in the order they were built.
|
|
1155
|
+
* @returns The largest group's key and members, or null when there are none.
|
|
1156
|
+
*/
|
|
1157
|
+
function largestGroup(groups) {
|
|
1158
|
+
let best = null;
|
|
1159
|
+
for (const [key, members] of groups) {
|
|
1160
|
+
if (!best || members.length > best.members.length)
|
|
1161
|
+
best = { key, members };
|
|
1162
|
+
}
|
|
1163
|
+
return best;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The cause repeating among a running suite's failures, or null.
|
|
1167
|
+
*
|
|
1168
|
+
* Playwright's `list` reporter prints one line per test as it finishes — its mark, its title
|
|
1169
|
+
* path and how long it ran — and defers each failure's error to the summary at the end, so
|
|
1170
|
+
* mid-run the only signature those lines carry is the duration. A `line` reporter prints the
|
|
1171
|
+
* failure bodies as they happen, and there the repeated error message itself is the signature,
|
|
1172
|
+
* which is stronger (it needs no assumption about how long a failure should take), so the
|
|
1173
|
+
* message is looked for first.
|
|
1174
|
+
*
|
|
1175
|
+
* Either way the point is the same: a suite whose failures repeat one cause spends its whole
|
|
1176
|
+
* runtime proving that one cause, and the executor watching it can be told so at the third
|
|
1177
|
+
* failure instead of at the last. X1 flash-m76 walked 15 tests in 4.0 minutes where 15.6 s
|
|
1178
|
+
* each was the test timeout and one missing `navigate('/')` was the cause of all of them.
|
|
1179
|
+
*
|
|
1180
|
+
* @param log - The log so far.
|
|
1181
|
+
* @returns The repeating cause, or null when no signature has {@link REPEATING_FAILURE_MIN}
|
|
1182
|
+
* failures behind it.
|
|
1183
|
+
*/
|
|
1184
|
+
export function repeatingFailure(log) {
|
|
1185
|
+
const text = log.replace(ANSI_SGR, '');
|
|
1186
|
+
// The failure BODIES first: a message repeating verbatim names the cause itself.
|
|
1187
|
+
const byMessage = new Map();
|
|
1188
|
+
for (const block of failureBlocks(text)) {
|
|
1189
|
+
const message = failureMessage(block);
|
|
1190
|
+
if (message === null)
|
|
1191
|
+
continue;
|
|
1192
|
+
const m = FAILURE_BLOCK_HEADER.exec(block.split('\n')[0] ?? '');
|
|
1193
|
+
const { where, title } = splitTitlePath(m?.[1] ?? '');
|
|
1194
|
+
const group = byMessage.get(message);
|
|
1195
|
+
if (group)
|
|
1196
|
+
group.push({ where, title, seconds: null });
|
|
1197
|
+
else
|
|
1198
|
+
byMessage.set(message, [{ where, title, seconds: null }]);
|
|
1199
|
+
}
|
|
1200
|
+
const messageGroup = largestGroup(byMessage);
|
|
1201
|
+
if (messageGroup && messageGroup.members.length >= REPEATING_FAILURE_MIN)
|
|
1202
|
+
return {
|
|
1203
|
+
count: messageGroup.members.length,
|
|
1204
|
+
message: messageGroup.key,
|
|
1205
|
+
seconds: null,
|
|
1206
|
+
tests: messageGroup.members,
|
|
1207
|
+
};
|
|
1208
|
+
// Otherwise the durations: the largest set of failures that took the same time to within the
|
|
1209
|
+
// band. A test that runs to its timeout prints slightly different durations on each attempt
|
|
1210
|
+
// (15.6 s, 15.1 s, 15.0 s), so the band is 0.5 s or 5% of the duration, whichever is larger.
|
|
1211
|
+
const tests = failedTests(text);
|
|
1212
|
+
const withSeconds = tests.filter((t) => t.seconds !== null);
|
|
1213
|
+
let best = [];
|
|
1214
|
+
for (const t of withSeconds) {
|
|
1215
|
+
const band = Math.max(0.5, t.seconds * 0.05);
|
|
1216
|
+
const near = withSeconds.filter((x) => Math.abs(x.seconds - t.seconds) <= band);
|
|
1217
|
+
if (near.length > best.length)
|
|
1218
|
+
best = near;
|
|
1219
|
+
}
|
|
1220
|
+
if (best.length < REPEATING_FAILURE_MIN)
|
|
1221
|
+
return null;
|
|
1222
|
+
const mean = best.reduce((sum, t) => sum + t.seconds, 0) / best.length;
|
|
1223
|
+
return { count: best.length, message: null, seconds: Math.round(mean * 10) / 10, tests: best };
|
|
1224
|
+
}
|
|
1225
|
+
/** How many of a repeating cause's failures the note lists before it says "and N more". */
|
|
1226
|
+
export const REPEATING_FAILURE_LISTED = 5;
|
|
1227
|
+
/**
|
|
1228
|
+
* The shortest unambiguous tail of a logged path (`…/specs/e2e/articles.spec.ts:32` →
|
|
1229
|
+
* `e2e/articles.spec.ts:32`).
|
|
1230
|
+
*
|
|
1231
|
+
* @param where - The location the log wrote.
|
|
1232
|
+
* @returns The last two path segments.
|
|
1233
|
+
*/
|
|
1234
|
+
function shortWhere(where) {
|
|
1235
|
+
const parts = where.split('/');
|
|
1236
|
+
return parts.slice(-2).join('/');
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* The sentence that names a cause repeating among a running suite's failures, and the command
|
|
1240
|
+
* that stops the run so the cause can be fixed first.
|
|
1241
|
+
*
|
|
1242
|
+
* Kept here, beside {@link repeatingFailure}, so the two stay in step: every word below is about
|
|
1243
|
+
* what that function could actually see in the log.
|
|
1244
|
+
*
|
|
1245
|
+
* @param found - What {@link repeatingFailure} found.
|
|
1246
|
+
* @param o - The run's facts: its `taskId`, its `log` path, and `stop` — the shell command that
|
|
1247
|
+
* stops it (the pids the platform's own pid file leads to).
|
|
1248
|
+
* @returns The note.
|
|
1249
|
+
*/
|
|
1250
|
+
export function repeatingFailureNote(found, o) {
|
|
1251
|
+
const listed = found.tests.slice(0, REPEATING_FAILURE_LISTED);
|
|
1252
|
+
const rows = listed
|
|
1253
|
+
.map((t) => ` ✘ ${[t.where && shortWhere(t.where), t.title].filter(Boolean).join(' › ')}` +
|
|
1254
|
+
(t.seconds === null ? '' : ` (${t.seconds}s)`))
|
|
1255
|
+
.join('\n');
|
|
1256
|
+
const more = found.count > listed.length ? `\n … and ${found.count - listed.length} more` : '';
|
|
1257
|
+
const signature = found.message !== null
|
|
1258
|
+
? `every one with \`${found.message}\``
|
|
1259
|
+
: `each after ~${found.seconds}s — the same duration to within half a second`;
|
|
1260
|
+
const first = found.tests[0];
|
|
1261
|
+
const reproduce = first?.where && !found.message
|
|
1262
|
+
? ` \`${first.where}\` alone reproduces the first one.`
|
|
1263
|
+
: first?.where
|
|
1264
|
+
? ` Re-run \`${first.where}\` alone to see its error in full.`
|
|
1265
|
+
: '';
|
|
1266
|
+
return (`${found.count} of this run’s tests have already failed the same way — ${signature} — so one ` +
|
|
1267
|
+
`cause is repeating and the rest of the run only repeats it:\n${rows}${more}\n` +
|
|
1268
|
+
`Stop this run and fix that cause before the rest: \`exec_command: ${o.stop}\`. ` +
|
|
1269
|
+
`It is still going (task ${o.taskId}, log ${o.log}) — do not wait for the rest of it.${reproduce}`);
|
|
1270
|
+
}
|
|
773
1271
|
/**
|
|
774
1272
|
* Attempt a whitespace-tolerant replacement when an exact `old_string` match
|
|
775
1273
|
* failed. Finds a contiguous run of lines in `content` whose per-line
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-ai-tools",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.20",
|
|
4
4
|
"description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@molecule/api-ai": "^1.0.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@molecule/api-ai": "1.3.
|
|
34
|
+
"@molecule/api-ai": "1.3.2",
|
|
35
35
|
"@types/node": "26.1.2",
|
|
36
36
|
"esbuild": "0.28.2",
|
|
37
37
|
"typescript": "6.0.3",
|