@molecule/api-ai-tools 1.9.2 → 1.9.4

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/dist/utilities.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import { posix } from 'path';
8
- import { commandSegments, locatedCommandSegments } from './command-position.js';
8
+ import { commandSegments, locatedCommandSegments, statementArgumentEnd, } from './command-position.js';
9
9
  import { classifyRunFailure } from './failure-kind.js';
10
10
  import { commandRunsNoTests } from './no-test-run.js';
11
11
  import { isDiagnosisRun, scopedRerunCommand, suiteCommandTarget, suiteRunInTheWay, withoutHeredocs, withSuiteConfig, } from './suite-overlap.js';
@@ -422,7 +422,18 @@ export function streamingBackgroundCommand(command) {
422
422
  out = out.slice(0, head.index);
423
423
  changes.push(`Dropped the trailing \`${head[0].trim()}\`: Playwright prints its pass/fail count last, and it would be cut.`);
424
424
  }
425
- const lastStep = out.split(/&&|\|\||;/).at(-1) ?? '';
425
+ // The last step that RUNS something. A trailing group closer is punctuation, not a step: a
426
+ // brace group writes it after a `;` (`{ …; }`), so reading the split's last part literally
427
+ // made `}` the step and withheld every rewrite from a run inside a group.
428
+ const parts = out.split(/&&|\|\||;/);
429
+ let stepIndex = parts.length - 1;
430
+ while (stepIndex > 0 && /^[\s)}]*$/.test(parts[stepIndex] ?? ''))
431
+ stepIndex -= 1;
432
+ const rawLastStep = parts[stepIndex] ?? '';
433
+ // The step's own text, with the punctuation that closes the group it sits in taken off — the
434
+ // options are parsed and placed against this text, while the closer is kept in the command.
435
+ const lastStep = rawLastStep.replace(/[\s)}]+$/, '');
436
+ const stepStart = rawLastStep === '' ? out.length : Math.max(0, out.lastIndexOf(rawLastStep));
426
437
  // The last step runs Playwright — whether or not anything is piped from it. Keying this on
427
438
  // "nothing is piped out of the last step" withheld the reporter, the inline-failure switch and
428
439
  // `--retries=0` from exactly the runs whose output was filtered (xr-molecule-flash-m101
@@ -433,19 +444,27 @@ export function streamingBackgroundCommand(command) {
433
444
  /**
434
445
  * Add a word to the suite runner's own arguments; false when the runner could not be placed.
435
446
  *
436
- * A pipe this rewrite could not drop still follows the runner (`| tee run.log`), so appending at
437
- * the end of the whole command would make the word an operand of the filter — m102's dangling
438
- * `--trace` flag, in the same shape. A suite inside a nested `sh -c '<command>'` string has no
439
- * offsets in this command (they are positions in THAT string), so the command is left exactly as
440
- * written and the caller reports no rewrite: appending at the end would hand the flag to the
441
- * shell, outside the quotes.
447
+ * The word goes where the runner's own arguments end ({@link statementArgumentEnd}), never at
448
+ * the end of the command's text: a pipe this rewrite could not drop still follows the runner
449
+ * (`| tee run.log`), and a statement can end on a group closer (`( … )`, `{ …; }`) or a
450
+ * redirection (`… 2>&1`). Appending after the `)` of a subshell was a shell syntax error that
451
+ * killed the run at once (`sh: Syntax error: word unexpected`) and pushed the model off the
452
+ * platform's runner shape (xr-molecule-flash-m129 +34:29); appending after a pipe hands the
453
+ * word to the filter (m102's dangling `--trace` flag, in the same shape).
454
+ *
455
+ * A suite the parse could not place — one inside a nested `sh -c '<command>'` string, whose
456
+ * offsets are positions in THAT string, or a runner that is not the command's last step — is
457
+ * left exactly as written and the caller reports no rewrite. There is nowhere in this command
458
+ * the word belongs, and guessing at the text is what produced both failures above.
442
459
  */
443
460
  const addToRun = (flag) => {
444
461
  const pipeline = lastSuitePipeline(out);
445
462
  const runner = pipeline ? pipeline.located[pipeline.at] : undefined;
446
- if (runner && runner.end === null)
463
+ if (!runner)
464
+ return false;
465
+ const at = statementArgumentEnd(out, runner.start, runner.end);
466
+ if (at === null)
447
467
  return false;
448
- const at = runner?.end ?? out.trimEnd().length;
449
468
  out = `${out.slice(0, at)} ${flag}${out.slice(at)}`;
450
469
  return true;
451
470
  };
@@ -479,12 +498,15 @@ export function streamingBackgroundCommand(command) {
479
498
  return 'list';
480
499
  })
481
500
  .filter((v) => v !== null);
482
- // Only the last step is rewritten, and `lastStep` is a suffix of `out`.
501
+ // Only the last step is rewritten, and its offsets are the parse's own. The punctuation
502
+ // after the step (`; }`, or the `)` attached to it) is kept verbatim.
483
503
  out =
484
- out.slice(0, out.length - lastStep.length) +
504
+ out.slice(0, stepStart) +
485
505
  lastStep.slice(0, reporters.start) +
486
506
  `--reporter${reporters.separator}${kept.join(',')}` +
487
- lastStep.slice(reporters.end);
507
+ lastStep.slice(reporters.end) +
508
+ rawLastStep.slice(lastStep.length) +
509
+ out.slice(stepStart + rawLastStep.length);
488
510
  changes.push(
489
511
  // `list` was already there and a second printer sat beside it: that one is dropped. When
490
512
  // no `list` was named at all, the printer is SWITCHED to it (flash-m62's `line`).
@@ -679,6 +701,31 @@ function isOutputOnlyFilter(segment) {
679
701
  * @returns The located segments and the last suite stage's index among them, or null.
680
702
  */
681
703
  function lastSuitePipeline(command) {
704
+ const run = lastSuiteRun(command);
705
+ if (run === null)
706
+ return null;
707
+ for (let i = run.at + 1; i < run.located.length; i += 1) {
708
+ if ((run.located[i]?.between ?? '').trim() !== '|')
709
+ return null;
710
+ }
711
+ return run;
712
+ }
713
+ /**
714
+ * The command's last suite stage, and the parse's segments around it — whatever follows that
715
+ * statement.
716
+ *
717
+ * {@link lastSuitePipeline} refuses a suite that is not the command's last step, because the code
718
+ * the command exits with is then the following statement's. That is the right answer to "whose
719
+ * exit code is this", and the wrong one to "which stages filter this run's output": a
720
+ * `… --reporter=list 2>&1 | tail -5; echo done` still pipes its log through a tail, and the tail
721
+ * still throws away every failure line the run printed (xr-molecule-flash-m121 +09:14, where the
722
+ * platform's own hold handed that shape back and the first 95-second walk's log held only the
723
+ * tally).
724
+ *
725
+ * @param command - The shell command.
726
+ * @returns The located segments and the last suite stage's index among them, or null.
727
+ */
728
+ function lastSuiteRun(command) {
682
729
  if (commandRunsNoTests(command))
683
730
  return null;
684
731
  const located = locatedSegments(command);
@@ -688,13 +735,7 @@ function lastSuitePipeline(command) {
688
735
  if (segment && SUITE_RUN_COMMANDS.some((re) => re.test(segment.text)))
689
736
  at = i;
690
737
  }
691
- if (at < 0)
692
- return null;
693
- for (let i = at + 1; i < located.length; i += 1) {
694
- if ((located[i]?.between ?? '').trim() !== '|')
695
- return null;
696
- }
697
- return { located, at };
738
+ return at < 0 ? null : { located, at };
698
739
  }
699
740
  /**
700
741
  * Drop the output-only filters piped after the command's suite runner.
@@ -710,35 +751,55 @@ function lastSuitePipeline(command) {
710
751
  * caller that reports the run's exit code must then treat it as unknown
711
752
  * ({@link suiteRunPipedAfterRunner}).
712
753
  *
754
+ * A statement after the run's own pipeline (`; echo done`, `&& echo ok`) does not protect the
755
+ * filters before it: the filter is thrown away and the trailing statement is kept, so the log the
756
+ * run writes still holds every failure (xr-molecule-flash-m121 — a `| tail -5; echo done` kept its
757
+ * tail, and a 95-second walk's log held only the tally while the platform's own hold had handed
758
+ * that command back).
759
+ *
713
760
  * @param command - The shell command.
714
761
  * @returns The rewritten command and the stages dropped.
715
762
  */
716
763
  export function stripTrailingSuiteFilters(command) {
717
- const pipeline = lastSuitePipeline(command);
718
- if (pipeline === null)
764
+ const run = lastSuiteRun(command);
765
+ if (run === null)
719
766
  return { command, dropped: '' };
720
- const { located, at } = pipeline;
721
- let first = located.length;
722
- for (let i = located.length - 1; i > at; i -= 1) {
767
+ const { located, at } = run;
768
+ // Where the suite's own pipeline ends: the first stage after the runner that is not joined to
769
+ // its predecessor by a single `|`. Everything from there on is another statement, kept as it is.
770
+ let end = at;
771
+ for (let i = at + 1; i < located.length; i += 1) {
772
+ if ((located[i]?.between ?? '').trim() !== '|')
773
+ break;
774
+ end = i;
775
+ }
776
+ let first = end + 1;
777
+ for (let i = end; i > at; i -= 1) {
723
778
  const stage = located[i];
724
779
  if (!stage || !isOutputOnlyFilter(stage.segment))
725
780
  break;
726
781
  first = i;
727
782
  }
728
- if (first === located.length)
783
+ if (first === end + 1)
729
784
  return { command, dropped: '' };
730
785
  const previous = located[first - 1];
731
786
  const stage = located[first];
732
- if (!previous || !stage)
787
+ const lastDropped = located[end];
788
+ if (!previous || !stage || !lastDropped)
733
789
  return { command, dropped: '' };
734
790
  // A stage inside a nested `sh -c` string has no offsets in this command: nothing can be cut.
735
- if (previous.end === null || stage.start === null)
791
+ if (previous.end === null || stage.start === null || lastDropped.end === null)
736
792
  return { command, dropped: '' };
737
793
  const pipe = stage.between.indexOf('|');
738
794
  if (pipe < 0)
739
795
  return { command, dropped: '' };
740
796
  const cut = previous.end + pipe;
741
- return { command: command.slice(0, cut).trimEnd(), dropped: command.slice(cut).trim() };
797
+ // The stages dropped are a middle slice when a statement follows, so the text after them is
798
+ // kept — `; echo done` still runs.
799
+ return {
800
+ command: `${command.slice(0, cut).trimEnd()}${command.slice(lastDropped.end)}`,
801
+ dropped: command.slice(cut, lastDropped.end).trim(),
802
+ };
742
803
  }
743
804
  /**
744
805
  * Where the suite runner's exit code comes from: the runner itself, or a stage piped after it.
@@ -1744,6 +1805,17 @@ const FAILURE_EVIDENCE_CHARS = 200;
1744
1805
  */
1745
1806
  function failureEvidence(body, message) {
1746
1807
  const carried = (line) => message !== null && message.includes(line);
1808
+ // A strict-mode violation lists the elements it resolved to, and that list is the fix's own
1809
+ // address: `1) <button class="… action-btn">` and `2) <button class="…">` name where each
1810
+ // duplicate is written. They were dropped here (the first body line is the signature, and the
1811
+ // only later `Error:` line this read for is absent), so xr-molecule-flash-m122's run read
1812
+ // "resolved to 2 elements:" followed by nothing and fixed a duplicate on a page its failing
1813
+ // test never visits. Kept whole — a locator list is what the executor has to match to a file.
1814
+ if (message !== null && /resolved to \d+ elements?/i.test(message)) {
1815
+ const elements = body.filter((line) => /^\d+\)\s/.test(line));
1816
+ if (elements.length > 0)
1817
+ return elements.slice(0, 2);
1818
+ }
1747
1819
  const expected = body.find((l) => /^Expected\b/.test(l));
1748
1820
  const received = body.find((l) => /^Received\b/.test(l));
1749
1821
  if (expected !== undefined && received !== undefined)
@@ -2051,29 +2123,135 @@ function failureBlockTests(text) {
2051
2123
  * property of the run rather than of a page.
2052
2124
  */
2053
2125
  export const UNIVERSAL_CAUSE_FILES = 3;
2126
+ /**
2127
+ * How many tests a connection-class cause must cover before the run is stopped even when they
2128
+ * are all in one file — see {@link universalCause}.
2129
+ */
2130
+ export const CONNECTION_CAUSE_TESTS = 3;
2131
+ /**
2132
+ * The failures that mean the browser never reached the app: neither the page's code nor a
2133
+ * selector can be the cause, and every remaining test can only repeat it.
2134
+ */
2135
+ const CONNECTION_CAUSE = /\bpage\.goto\b[^\n]*\btimeout\b|\bnet::ERR_[A-Z0-9_]+\b|\bECONNREFUSED\b/i;
2136
+ /** A Playwright positional argument that names one test file (`e2e/auth.spec.ts:19:3`). */
2137
+ const SPEC_FILE_ARG = /\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+(?::\d+)?)?$/;
2138
+ /**
2139
+ * The `playwright test` flags that take the next word as their value when they are not written
2140
+ * `--flag=value`. A flag NOT in this set is a switch (`--headed`, `--last-failed`), so the word
2141
+ * after it is a positional filter and not a value.
2142
+ */
2143
+ const PLAYWRIGHT_VALUE_FLAGS = new Set([
2144
+ '-c',
2145
+ '-g',
2146
+ '-j',
2147
+ '-x',
2148
+ '--config',
2149
+ '--global-timeout',
2150
+ '--grep',
2151
+ '--grep-invert',
2152
+ '--max-failures',
2153
+ '--output',
2154
+ '--project',
2155
+ '--repeat-each',
2156
+ '--reporter',
2157
+ '--retries',
2158
+ '--shard',
2159
+ '--timeout',
2160
+ '--trace',
2161
+ '--ui-host',
2162
+ '--ui-port',
2163
+ '--workers',
2164
+ ]);
2165
+ /**
2166
+ * How many test FILES a Playwright run's own command narrows it to, or null when it selects the
2167
+ * whole suite (or its selection cannot be read).
2168
+ *
2169
+ * {@link universalCause}'s file threshold is written for a whole-suite run, which a narrowed run
2170
+ * can never reach. xr-molecule-flash-m119 (+42:08) re-ran ONE spec file against a hand-started
2171
+ * `vite preview` the browser could not reach, and every test in it failed `page.goto: Timeout` —
2172
+ * but one file is below {@link UNIVERSAL_CAUSE_FILES}, so the wait sat out its entire 280 s
2173
+ * budget on a run that had already measured all it could.
2174
+ *
2175
+ * The command is read through {@link commandSegments}, so a `playwright test` named as an
2176
+ * argument is not a run. A {@link PLAYWRIGHT_VALUE_FLAGS} flag written without `=` takes the next
2177
+ * word as its value, so a `--config app/e2e-suite.config.ts` is never counted as a spec file; a
2178
+ * directory or a bare substring filter names files the command does not list, which answers null
2179
+ * (the whole-suite threshold) rather than a count of the wrong files.
2180
+ *
2181
+ * @param command - The run's command.
2182
+ * @returns The number of distinct test files it names, or null when it names none (the whole
2183
+ * suite) or the selection cannot be told.
2184
+ */
2185
+ export function specFilesSelected(command) {
2186
+ for (const segment of commandSegments(command)) {
2187
+ if (!/^playwright\s+test\b/.test(segment.text))
2188
+ continue;
2189
+ const files = new Set();
2190
+ let flagValue = false;
2191
+ for (const word of segment.args.slice(1)) {
2192
+ const token = word.replace(/^(['"])(.*)\1$/, '$2');
2193
+ if (flagValue) {
2194
+ flagValue = false;
2195
+ continue;
2196
+ }
2197
+ if (token.startsWith('-')) {
2198
+ const flag = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
2199
+ flagValue = !token.includes('=') && PLAYWRIGHT_VALUE_FLAGS.has(flag);
2200
+ continue;
2201
+ }
2202
+ if (SPEC_FILE_ARG.test(token)) {
2203
+ files.add(token.replace(/:\d+(?::\d+)?$/, ''));
2204
+ continue;
2205
+ }
2206
+ // A directory (`e2e/`), a glob, or a bare substring filter (`npx playwright test login`):
2207
+ // the command does not name the files it selects, so there is no count to take.
2208
+ return null;
2209
+ }
2210
+ return files.size > 0 ? files.size : null;
2211
+ }
2212
+ return null;
2213
+ }
2054
2214
  /**
2055
2215
  * Whether a repeating cause is failing the whole run — the one case where stopping it is the
2056
2216
  * right call.
2057
2217
  *
2058
- * The test is: the cause accounts for every failure the log names, it has hit
2059
- * {@link UNIVERSAL_CAUSE_FILES} spec files or more, and nothing has passed since its first
2060
- * failure. An app that is not up, a 5xx on every request, or a selector that is missing on every
2061
- * page all look like that, and every remaining test will fail the same way — so the run's
2062
- * remaining minutes measure nothing. Anything less is NOT this: a cause that stops at one file,
2063
- * or that leaves other tests passing, or that shares the run with failures of another shape,
2064
- * leaves the run with something to say, and the run is the only thing that will say it. X1
2065
- * flash-m83 (2026-10-16) stopped eight local runs at their first repeating cause over 14.5
2066
- * minutes and never finished one; three of its deploys each fixed a single cause that a finished
2067
- * run would have listed alongside the others.
2218
+ * The test is: the cause accounts for every failure the log names, it has hit the spec-file
2219
+ * threshold, and nothing has passed since its first failure. The threshold is
2220
+ * {@link UNIVERSAL_CAUSE_FILES} for a whole-suite run but the number of files the command
2221
+ * SELECTED for a narrowed one ({@link specFilesSelected}): a one-file run can never span three,
2222
+ * and xr-molecule-flash-m119 (+42:08) waited out its whole budget on exactly that shape. An app
2223
+ * that is not up, a 5xx on every request, or a selector that is missing on every page all look
2224
+ * like that, and every remaining test will fail the same way — so the run's remaining minutes
2225
+ * measure nothing. A CONNECTION-class cause ({@link CONNECTION_CAUSE}) needs no file span at all:
2226
+ * three tests that could not reach the app, with nothing passing since, is the app-unreachable
2227
+ * case however few files the run walks.
2228
+ *
2229
+ * Anything less is NOT this: a cause that stops at one file of a multi-file run, or that leaves
2230
+ * other tests passing, or that shares the run with failures of another shape, leaves the run with
2231
+ * something to say, and the run is the only thing that will say it. X1 flash-m83 (2026-10-16)
2232
+ * stopped eight local runs at their first repeating cause over 14.5 minutes and never finished
2233
+ * one; three of its deploys each fixed a single cause that a finished run would have listed
2234
+ * alongside the others.
2068
2235
  *
2069
2236
  * @param log - The log so far.
2070
2237
  * @param found - The repeating cause {@link repeatingFailure} found in it.
2238
+ * @param command - How the run was started, when the caller knows it: its spec-file selection
2239
+ * scales the file threshold ({@link specFilesSelected}).
2071
2240
  * @returns The cause's count and files, or null when the run is not failing whole.
2072
2241
  */
2073
- export function universalCause(log, found) {
2242
+ export function universalCause(log, found, command) {
2074
2243
  const text = log.replace(ANSI_SGR, '');
2075
2244
  const files = [...new Set(found.tests.map((t) => specFile(t.where)).filter(Boolean))];
2076
- if (files.length < UNIVERSAL_CAUSE_FILES)
2245
+ // The threshold is what THIS run selects, not the suite's total: a run narrowed to one file
2246
+ // can never span three of them, however whole its failure is.
2247
+ const selected = command ? specFilesSelected(command) : null;
2248
+ const threshold = selected === null ? UNIVERSAL_CAUSE_FILES : Math.min(UNIVERSAL_CAUSE_FILES, selected);
2249
+ // The app never answered: three tests of that with nothing passing since is the whole of what
2250
+ // a whole-suite run would have said, whatever the file count (xr-molecule-flash-m119).
2251
+ const unreachable = found.message !== null &&
2252
+ CONNECTION_CAUSE.test(found.message) &&
2253
+ found.count >= CONNECTION_CAUSE_TESTS;
2254
+ if (!unreachable && files.length < threshold)
2077
2255
  return null;
2078
2256
  // Every failure the log names has to be this cause: another cause anywhere in the run means
2079
2257
  // the run still has something to say, and stopping it throws that away.
@@ -2094,8 +2272,10 @@ export function universalCause(log, found) {
2094
2272
  *
2095
2273
  * Both halves are load-bearing, each of them learned from a run that lost minutes to the other.
2096
2274
  *
2097
- * - **Failing the WHOLE run** is {@link universalCause}: every failure the log names, across
2098
- * {@link UNIVERSAL_CAUSE_FILES} spec files or more, with nothing passing since the first — the
2275
+ * - **Failing the WHOLE run** is {@link universalCause}: every failure the log names, across the
2276
+ * spec-file threshold — {@link UNIVERSAL_CAUSE_FILES}, or the files a narrowed run's own
2277
+ * command selects, or {@link CONNECTION_CAUSE_TESTS} tests of an app that never answered — with
2278
+ * nothing passing since the first. That is the
2099
2279
  * dont-stop-runs rule. X1 flash-m83 (2026-10-16) stopped eight local runs at their first
2100
2280
  * repeating cause in 14.5 minutes and never finished one, and a run that still has other causes
2101
2281
  * left to name is the only thing that will name them.
@@ -2236,7 +2416,7 @@ function flakyTestsDeclared(text) {
2236
2416
  * @returns The causes to fix, the distinct tests dropped as excluded, and the distinct tests the
2237
2417
  * runner counted as flaky (a retried pass is not a cause to fix).
2238
2418
  */
2239
- function groupFailures(log, excluded) {
2419
+ function groupFailures(log, excluded, pageErrors = []) {
2240
2420
  const text = log.replace(ANSI_SGR, '');
2241
2421
  const flakyTests = flakyTestsDeclared(text);
2242
2422
  const flaky = new Set(flakyTests.map(testKey));
@@ -2272,9 +2452,21 @@ function groupFailures(log, excluded) {
2272
2452
  });
2273
2453
  }
2274
2454
  const causes = [];
2455
+ // The page's own words for a test, keyed the way both sides spell the location: the log's
2456
+ // `file:line` and the artifact's `file:line` are the same string from one run.
2457
+ const showedBy = new Map();
2458
+ for (const entry of pageErrors)
2459
+ if (entry.items.length > 0)
2460
+ showedBy.set(entry.where, entry.items);
2275
2461
  for (const [message, group] of byMessage) {
2276
2462
  const tests = distinctTests(group.tests);
2277
- if (tests.length > 0)
2463
+ if (tests.length > 0) {
2464
+ // The first test of the cause that has page errors speaks for it: tests grouped under one
2465
+ // error were failing the same way, and every one of m122's eight snapshots carried the same
2466
+ // two blank-field alerts.
2467
+ const showed = tests
2468
+ .map((t) => showedBy.get(t.where))
2469
+ .find((items) => items !== undefined);
2278
2470
  causes.push({
2279
2471
  message,
2280
2472
  seconds: null,
@@ -2284,7 +2476,9 @@ function groupFailures(log, excluded) {
2284
2476
  // no layer of its own is read as an assertion (unknown is never `connection`).
2285
2477
  kind: group.kind,
2286
2478
  ...(group.budgetMs === null ? {} : { budgetMs: group.budgetMs }),
2479
+ ...(showed === undefined ? {} : { pageShowed: [...showed] }),
2287
2480
  });
2481
+ }
2288
2482
  }
2289
2483
  // The failures with no body of their own — a `list` log with no error blocks in it, from a run
2290
2484
  // not started in the background — are grouped by how long they took: a band is opened by its
@@ -2329,6 +2523,20 @@ function groupFailures(log, excluded) {
2329
2523
  * xr-molecule-flash-m113 (+20:34) got ten of these and a list that called them ten causes.
2330
2524
  */
2331
2525
  const NO_BASE_URL_ERROR = /Cannot navigate to invalid URL/i;
2526
+ /** The browser's own `net::ERR_*`, as Playwright prints it for a document that never loaded. */
2527
+ const NETWORK_ERROR_TEXT = /\bnet::ERR_[A-Z0-9_]+\b/;
2528
+ /**
2529
+ * Whether every cause this run grouped is a `page.goto` that ran out of time.
2530
+ *
2531
+ * @param causes - The run's grouped causes.
2532
+ * @returns True when the run measured no page at all.
2533
+ */
2534
+ function everyCauseIsNavigationTimeout(causes) {
2535
+ return (causes.length > 0 &&
2536
+ causes.every((cause) => cause.message !== null &&
2537
+ (/\bpage\.goto\b/.test(cause.message) || /navigating to/.test(cause.message)) &&
2538
+ /timeout|timed out/i.test(cause.message)));
2539
+ }
2332
2540
  /**
2333
2541
  * The one-line note for a run whose EVERY failure is {@link NO_BASE_URL_ERROR}: a configuration
2334
2542
  * failure, not a result about the app.
@@ -2390,8 +2598,8 @@ function noBaseUrlConfigNote(causes, run, excluded) {
2390
2598
  * "left out" sentence greps when more causes exist than the result has room for.
2391
2599
  * @returns The note, or null.
2392
2600
  */
2393
- export function failureCausesNote(log, excluded = [], run) {
2394
- const { causes, ignored, flaky } = groupFailures(log, excluded);
2601
+ export function failureCausesNote(log, excluded = [], run, pageErrors = []) {
2602
+ const { causes, ignored, flaky } = groupFailures(log, excluded, pageErrors);
2395
2603
  const total = causes.reduce((n, c) => n + c.tests.length, 0);
2396
2604
  if (total < 2)
2397
2605
  return null;
@@ -2406,6 +2614,23 @@ export function failureCausesNote(log, excluded = [], run) {
2406
2614
  // cannot report them again.
2407
2615
  const locations = [...new Set(causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
2408
2616
  const rerun = run ? rerunOfRunningSuite(run, locations, excluded) : null;
2617
+ // A document the browser could not load explains every failure at once, and it is not in the
2618
+ // app: m118 (+37:09) read 14 `page.goto` timeouts as app failures while the page load had ended
2619
+ // `net::ERR_CONTENT_DECODING_FAILED`. The cause list is the wrong shape here — there is one
2620
+ // cause and it is the server — so the note leads with the browser's own line, not a fix round.
2621
+ const networkError = NETWORK_ERROR_TEXT.exec(log)?.[0];
2622
+ if (networkError !== undefined && everyCauseIsNavigationTimeout(causes)) {
2623
+ return (`${total} failure${total === 1 ? '' : 's'} in this run, and every one is a \`page.goto\` ` +
2624
+ `that never loaded while the browser reported \`${networkError}\` — the document request ` +
2625
+ `itself failed, so none of these is the app’s code or a selector. Fix the server first` +
2626
+ (networkError === 'net::ERR_CONTENT_DECODING_FAILED'
2627
+ ? ': a body whose gzip `content-encoding` no longer matches its bytes does this, and a ' +
2628
+ '`vite preview` over a build must be started with ' +
2629
+ '`MOL_E2E_PREVIEW_PLUGIN=0 npx vite preview --port 4173 --strictPort`'
2630
+ : ' (what the run hit, and why it refused the connection)') +
2631
+ (rerun ? `. Then send exactly this: \`${rerun}\`` : '') +
2632
+ (locations.length > 0 ? `. Failing tests: ${locations.join(', ')}.` : ''));
2633
+ }
2409
2634
  const ignoredTitles = [...new Set(ignored.map((t) => t.title || t.where).filter(Boolean))];
2410
2635
  const flakyTitles = [...new Set(flaky.map((t) => t.where || t.title).filter(Boolean))];
2411
2636
  // A cause that never reached the app is not a cause the app's code can fix: the transport
@@ -2421,8 +2646,22 @@ export function failureCausesNote(log, excluded = [], run) {
2421
2646
  "if the failure repeats, read the app's own log (`read_logs`) — a connection error is never a " +
2422
2647
  'handler shape, a header or CORS.');
2423
2648
  }
2424
- const header = `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
2425
- `below${connections.length > 0 ? ' the app’s code can produce, and re-send the connection error once as it is' : ' before the next deploy'}:\n`;
2649
+ // A text that is the TAIL of a longer log says so in its first line (`…(N earlier characters
2650
+ // omitted)`, the shape `background-tasks` writes when a log is capped): what it holds is not
2651
+ // the run's list of failures, only the part that survived the cut. Saying "N failures in this
2652
+ // run" over it reads as complete, and did: xr-molecule-flash-m123's collected 42 KB diagnosis
2653
+ // log was clipped to its last 8 KB, and this note grouped 2 of the run's 14 failures under
2654
+ // "fix every cause below" while the 11 that shared the run's one real cause were gone
2655
+ // (2026-10-17). The root fix is to hand this function the whole log; this says so when a
2656
+ // caller cannot.
2657
+ const clipped = /^…\((\d+) earlier characters omitted\)/.exec(log);
2658
+ const header = clipped !== null
2659
+ ? `${total} failure${total === 1 ? '' : 's'} in the LAST part of this run’s log — its ` +
2660
+ `first ${clipped[1]} characters were clipped, so a cause that failed earlier is ` +
2661
+ `missing here. Fix every cause below${connections.length > 0 ? ' the app’s code can produce, and re-send the connection error once as it is' : ''}, ` +
2662
+ `and read the whole run log before concluding:\n`
2663
+ : `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
2664
+ `below${connections.length > 0 ? ' the app’s code can produce, and re-send the connection error once as it is' : ' before the next deploy'}:\n`;
2426
2665
  // The flaky sentence sits before the ignored one: the ignored sentence is the LAST thing the
2427
2666
  // note says, and callers assert on that ending.
2428
2667
  const tail = (rerun ? ` Re-run just these once they are fixed: \`${rerun}\`` : '') +
@@ -2479,10 +2718,21 @@ function failureCauseLine(cause, index) {
2479
2718
  // twice; the rest are the values the normalised signature replaced with `<n>`.
2480
2719
  const body = cause.evidence.filter((line) => cause.message === null || !cause.message.includes(line));
2481
2720
  const shortened = body.map((line) => line.length > FAILURE_EVIDENCE_CHARS ? `${line.slice(0, FAILURE_EVIDENCE_CHARS - 1)}…` : line);
2721
+ // The page's own words at the failure (xr-molecule-flash-m122 +23:39): the error line names the
2722
+ // symptom, and these name what the app was saying while it happened. The parenthetical is said
2723
+ // only for the shape the items themselves show — validation copy on a form — where "submitted
2724
+ // before its values loaded" is the reading, and never for arbitrary page text.
2725
+ const showed = cause.pageShowed?.length
2726
+ ? `\n the page showed: ${cause.pageShowed.map((t) => `\`${t}\``).join(', ')}` +
2727
+ (cause.pageShowed.some((t) => /\b(?:blank|required|invalid|must be)\b/i.test(t))
2728
+ ? ' (the action ran before the form’s values had loaded)'
2729
+ : '')
2730
+ : '';
2482
2731
  return (` ${index + 1}. ${cause.kind === 'connection' ? 'CONNECTION error, not the app’s — ' : ''}${signature} — ` +
2483
2732
  `${cause.tests.length} test${cause.tests.length === 1 ? '' : 's'} ` +
2484
2733
  `in ${files} file${files === 1 ? '' : 's'}: ${tests.join(', ')}${more}` +
2485
- (shortened.length ? `\n ${shortened.join(' · ')}` : ''));
2734
+ (shortened.length ? `\n ${shortened.join(' · ')}` : '') +
2735
+ showed);
2486
2736
  }
2487
2737
  /**
2488
2738
  * The sentence for the causes a recap could not fit: exactly which `file:line` each one was
@@ -2538,11 +2788,11 @@ function omittedCausesRecap(omitted, run, excluded) {
2538
2788
  * is not a Playwright run this can narrow.
2539
2789
  *
2540
2790
  * The `locations` are the paths the run's own log printed, which are relative to the directory
2541
- * that run started in, so a re-run has to resolve them there: the command names that directory
2542
- * unless it already changes into one of its own, because the executor's next command starts
2543
- * wherever it starts. The returned command is for AFTER the run ends — it is a second run against
2544
- * the same app and database, and that is exactly what is refused while the run walks
2545
- * ({@link suiteRunInTheWay}).
2791
+ * that run started in, so the composer resolves them there ({@link scopedRerunCommand}): the
2792
+ * command names that directory unless it already changes into one of its own, because the
2793
+ * executor's next command starts wherever it starts. The returned command is for AFTER the run
2794
+ * ends — it is a second run against the same app and database, and that is exactly what is
2795
+ * refused while the run walks ({@link suiteRunInTheWay}).
2546
2796
  *
2547
2797
  * @param run - The run in flight: the command it was started with, as it was run, the directory
2548
2798
  * that command ran in, and the config it loaded when the caller knows it.
@@ -2554,7 +2804,10 @@ function omittedCausesRecap(omitted, run, excluded) {
2554
2804
  export function rerunOfRunningSuite(run, locations, excluded = []) {
2555
2805
  if (locations.length === 0)
2556
2806
  return null;
2557
- const scoped = scopedRerunCommand(run.command, locations, excluded, run.config);
2807
+ // The run's own directory is the base a relative failure location resolves against: the
2808
+ // reporter prints it from where the runner started, and Playwright matches file arguments
2809
+ // against absolute paths (xr-molecule-flash-m118 +16:34, m124 +18:38).
2810
+ const scoped = scopedRerunCommand(run.command, locations, excluded, run.config, run.cwd);
2558
2811
  if (scoped === run.command)
2559
2812
  return null;
2560
2813
  if (!run.cwd || commandSegments(withoutHeredocs(run.command)).some((s) => s.program === 'cd'))
@@ -2592,8 +2845,10 @@ export function rerunOfRunningSuite(run, locations, excluded = []) {
2592
2845
  * @param found - What {@link repeatingFailure} found.
2593
2846
  * @param o - The run's facts: its `taskId`, its `log` path, `stop` — the shell command that
2594
2847
  * stops it, printed only for an {@link earlyReturnCause} — `universal`, what
2595
- * {@link universalCause} found (absent means the run is not failing whole), and `run`, how the
2596
- * run was started ({@link rerunOfRunningSuite}) when the caller knows it.
2848
+ * {@link universalCause} found (absent means the run is not failing whole), `run`, how the
2849
+ * run was started ({@link rerunOfRunningSuite}) when the caller knows it, and
2850
+ * `editsInvalidateRun`, true when the run measures the sandbox's own app (a local run, where an
2851
+ * edit now changes what the suite is testing and its result stops counting).
2597
2852
  * @returns The note.
2598
2853
  */
2599
2854
  export function repeatingFailureNote(found, o) {
@@ -2656,8 +2911,11 @@ export function repeatingFailureNote(found, o) {
2656
2911
  const files = stop.files.map(shortFile);
2657
2912
  const shown = files.slice(0, 4).join(', ');
2658
2913
  const moreFiles = files.length > 4 ? `, … and ${files.length - 4} more` : '';
2914
+ const span = files.length === 0
2915
+ ? ''
2916
+ : ` — across ${files.length} file${files.length === 1 ? '' : 's'} (${shown}${moreFiles})`;
2659
2917
  return (`Every test this run has walked so far has failed the same way — all ${found.count} of ` +
2660
- `them, ${signature} — across ${files.length} files (${shown}${moreFiles}), with nothing ` +
2918
+ `them, ${signature}${span}, with nothing ` +
2661
2919
  `passing since the first:\n${rows}${more}\n` +
2662
2920
  `Every remaining test will fail the same way too, so walking the rest of this run only ` +
2663
2921
  `repeats this one cause. Stop it and fix that cause first: \`exec_command: ${o.stop}\`, ` +
@@ -2674,7 +2932,9 @@ export function repeatingFailureNote(found, o) {
2674
2932
  if (isCause)
2675
2933
  return (`${found.count} different tests in this run have already failed the same way — ${signature} — ` +
2676
2934
  `so one cause is repeating:\n${rows}${more}\n` +
2677
- `Start fixing it now, while the run keeps walking. Stop it only if every test it walks ` +
2935
+ (o.editsInvalidateRun
2936
+ ? `Work out the fix now: apply it when the run ends, not while it walks. Stop it only if every test it walks `
2937
+ : `Start fixing it now, while the run keeps walking. Stop it only if every test it walks `) +
2678
2938
  `fails this same way (the app unreachable, a 5xx on every request, the same selector timing ` +
2679
2939
  `out on every test of every file so far).${reproduce} ${keepGoing}${thenRun}`);
2680
2940
  return (`${found.count} different tests in this run each failed after ~${found.seconds}s — the same ` +
@@ -2682,7 +2942,10 @@ export function repeatingFailureNote(found, o) {
2682
2942
  `config sets a timeout spends that timeout on every failure that waits for anything, so ` +
2683
2943
  `different missing elements land on the same number. Read these failures before fixing one ` +
2684
2944
  `of them:\n${rows}${more}\n` +
2685
- `Start on them now, while the run keeps walking.${reproduce} ${keepGoing}${thenRun}`);
2945
+ (o.editsInvalidateRun
2946
+ ? `Work out the fixes now: apply them when the run ends, not while it walks.`
2947
+ : `Start on them now, while the run keeps walking.`) +
2948
+ `${reproduce} ${keepGoing}${thenRun}`);
2686
2949
  }
2687
2950
  /**
2688
2951
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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",