@molecule/api-ai-tools 1.9.1 → 1.9.3

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
@@ -679,6 +679,31 @@ function isOutputOnlyFilter(segment) {
679
679
  * @returns The located segments and the last suite stage's index among them, or null.
680
680
  */
681
681
  function lastSuitePipeline(command) {
682
+ const run = lastSuiteRun(command);
683
+ if (run === null)
684
+ return null;
685
+ for (let i = run.at + 1; i < run.located.length; i += 1) {
686
+ if ((run.located[i]?.between ?? '').trim() !== '|')
687
+ return null;
688
+ }
689
+ return run;
690
+ }
691
+ /**
692
+ * The command's last suite stage, and the parse's segments around it — whatever follows that
693
+ * statement.
694
+ *
695
+ * {@link lastSuitePipeline} refuses a suite that is not the command's last step, because the code
696
+ * the command exits with is then the following statement's. That is the right answer to "whose
697
+ * exit code is this", and the wrong one to "which stages filter this run's output": a
698
+ * `… --reporter=list 2>&1 | tail -5; echo done` still pipes its log through a tail, and the tail
699
+ * still throws away every failure line the run printed (xr-molecule-flash-m121 +09:14, where the
700
+ * platform's own hold handed that shape back and the first 95-second walk's log held only the
701
+ * tally).
702
+ *
703
+ * @param command - The shell command.
704
+ * @returns The located segments and the last suite stage's index among them, or null.
705
+ */
706
+ function lastSuiteRun(command) {
682
707
  if (commandRunsNoTests(command))
683
708
  return null;
684
709
  const located = locatedSegments(command);
@@ -688,13 +713,7 @@ function lastSuitePipeline(command) {
688
713
  if (segment && SUITE_RUN_COMMANDS.some((re) => re.test(segment.text)))
689
714
  at = i;
690
715
  }
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 };
716
+ return at < 0 ? null : { located, at };
698
717
  }
699
718
  /**
700
719
  * Drop the output-only filters piped after the command's suite runner.
@@ -710,35 +729,55 @@ function lastSuitePipeline(command) {
710
729
  * caller that reports the run's exit code must then treat it as unknown
711
730
  * ({@link suiteRunPipedAfterRunner}).
712
731
  *
732
+ * A statement after the run's own pipeline (`; echo done`, `&& echo ok`) does not protect the
733
+ * filters before it: the filter is thrown away and the trailing statement is kept, so the log the
734
+ * run writes still holds every failure (xr-molecule-flash-m121 — a `| tail -5; echo done` kept its
735
+ * tail, and a 95-second walk's log held only the tally while the platform's own hold had handed
736
+ * that command back).
737
+ *
713
738
  * @param command - The shell command.
714
739
  * @returns The rewritten command and the stages dropped.
715
740
  */
716
741
  export function stripTrailingSuiteFilters(command) {
717
- const pipeline = lastSuitePipeline(command);
718
- if (pipeline === null)
742
+ const run = lastSuiteRun(command);
743
+ if (run === null)
719
744
  return { command, dropped: '' };
720
- const { located, at } = pipeline;
721
- let first = located.length;
722
- for (let i = located.length - 1; i > at; i -= 1) {
745
+ const { located, at } = run;
746
+ // Where the suite's own pipeline ends: the first stage after the runner that is not joined to
747
+ // its predecessor by a single `|`. Everything from there on is another statement, kept as it is.
748
+ let end = at;
749
+ for (let i = at + 1; i < located.length; i += 1) {
750
+ if ((located[i]?.between ?? '').trim() !== '|')
751
+ break;
752
+ end = i;
753
+ }
754
+ let first = end + 1;
755
+ for (let i = end; i > at; i -= 1) {
723
756
  const stage = located[i];
724
757
  if (!stage || !isOutputOnlyFilter(stage.segment))
725
758
  break;
726
759
  first = i;
727
760
  }
728
- if (first === located.length)
761
+ if (first === end + 1)
729
762
  return { command, dropped: '' };
730
763
  const previous = located[first - 1];
731
764
  const stage = located[first];
732
- if (!previous || !stage)
765
+ const lastDropped = located[end];
766
+ if (!previous || !stage || !lastDropped)
733
767
  return { command, dropped: '' };
734
768
  // 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)
769
+ if (previous.end === null || stage.start === null || lastDropped.end === null)
736
770
  return { command, dropped: '' };
737
771
  const pipe = stage.between.indexOf('|');
738
772
  if (pipe < 0)
739
773
  return { command, dropped: '' };
740
774
  const cut = previous.end + pipe;
741
- return { command: command.slice(0, cut).trimEnd(), dropped: command.slice(cut).trim() };
775
+ // The stages dropped are a middle slice when a statement follows, so the text after them is
776
+ // kept — `; echo done` still runs.
777
+ return {
778
+ command: `${command.slice(0, cut).trimEnd()}${command.slice(lastDropped.end)}`,
779
+ dropped: command.slice(cut, lastDropped.end).trim(),
780
+ };
742
781
  }
743
782
  /**
744
783
  * Where the suite runner's exit code comes from: the runner itself, or a stage piped after it.
@@ -1744,6 +1783,17 @@ const FAILURE_EVIDENCE_CHARS = 200;
1744
1783
  */
1745
1784
  function failureEvidence(body, message) {
1746
1785
  const carried = (line) => message !== null && message.includes(line);
1786
+ // A strict-mode violation lists the elements it resolved to, and that list is the fix's own
1787
+ // address: `1) <button class="… action-btn">` and `2) <button class="…">` name where each
1788
+ // duplicate is written. They were dropped here (the first body line is the signature, and the
1789
+ // only later `Error:` line this read for is absent), so xr-molecule-flash-m122's run read
1790
+ // "resolved to 2 elements:" followed by nothing and fixed a duplicate on a page its failing
1791
+ // test never visits. Kept whole — a locator list is what the executor has to match to a file.
1792
+ if (message !== null && /resolved to \d+ elements?/i.test(message)) {
1793
+ const elements = body.filter((line) => /^\d+\)\s/.test(line));
1794
+ if (elements.length > 0)
1795
+ return elements.slice(0, 2);
1796
+ }
1747
1797
  const expected = body.find((l) => /^Expected\b/.test(l));
1748
1798
  const received = body.find((l) => /^Received\b/.test(l));
1749
1799
  if (expected !== undefined && received !== undefined)
@@ -2051,29 +2101,135 @@ function failureBlockTests(text) {
2051
2101
  * property of the run rather than of a page.
2052
2102
  */
2053
2103
  export const UNIVERSAL_CAUSE_FILES = 3;
2104
+ /**
2105
+ * How many tests a connection-class cause must cover before the run is stopped even when they
2106
+ * are all in one file — see {@link universalCause}.
2107
+ */
2108
+ export const CONNECTION_CAUSE_TESTS = 3;
2109
+ /**
2110
+ * The failures that mean the browser never reached the app: neither the page's code nor a
2111
+ * selector can be the cause, and every remaining test can only repeat it.
2112
+ */
2113
+ const CONNECTION_CAUSE = /\bpage\.goto\b[^\n]*\btimeout\b|\bnet::ERR_[A-Z0-9_]+\b|\bECONNREFUSED\b/i;
2114
+ /** A Playwright positional argument that names one test file (`e2e/auth.spec.ts:19:3`). */
2115
+ const SPEC_FILE_ARG = /\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+(?::\d+)?)?$/;
2116
+ /**
2117
+ * The `playwright test` flags that take the next word as their value when they are not written
2118
+ * `--flag=value`. A flag NOT in this set is a switch (`--headed`, `--last-failed`), so the word
2119
+ * after it is a positional filter and not a value.
2120
+ */
2121
+ const PLAYWRIGHT_VALUE_FLAGS = new Set([
2122
+ '-c',
2123
+ '-g',
2124
+ '-j',
2125
+ '-x',
2126
+ '--config',
2127
+ '--global-timeout',
2128
+ '--grep',
2129
+ '--grep-invert',
2130
+ '--max-failures',
2131
+ '--output',
2132
+ '--project',
2133
+ '--repeat-each',
2134
+ '--reporter',
2135
+ '--retries',
2136
+ '--shard',
2137
+ '--timeout',
2138
+ '--trace',
2139
+ '--ui-host',
2140
+ '--ui-port',
2141
+ '--workers',
2142
+ ]);
2143
+ /**
2144
+ * How many test FILES a Playwright run's own command narrows it to, or null when it selects the
2145
+ * whole suite (or its selection cannot be read).
2146
+ *
2147
+ * {@link universalCause}'s file threshold is written for a whole-suite run, which a narrowed run
2148
+ * can never reach. xr-molecule-flash-m119 (+42:08) re-ran ONE spec file against a hand-started
2149
+ * `vite preview` the browser could not reach, and every test in it failed `page.goto: Timeout` —
2150
+ * but one file is below {@link UNIVERSAL_CAUSE_FILES}, so the wait sat out its entire 280 s
2151
+ * budget on a run that had already measured all it could.
2152
+ *
2153
+ * The command is read through {@link commandSegments}, so a `playwright test` named as an
2154
+ * argument is not a run. A {@link PLAYWRIGHT_VALUE_FLAGS} flag written without `=` takes the next
2155
+ * word as its value, so a `--config app/e2e-suite.config.ts` is never counted as a spec file; a
2156
+ * directory or a bare substring filter names files the command does not list, which answers null
2157
+ * (the whole-suite threshold) rather than a count of the wrong files.
2158
+ *
2159
+ * @param command - The run's command.
2160
+ * @returns The number of distinct test files it names, or null when it names none (the whole
2161
+ * suite) or the selection cannot be told.
2162
+ */
2163
+ export function specFilesSelected(command) {
2164
+ for (const segment of commandSegments(command)) {
2165
+ if (!/^playwright\s+test\b/.test(segment.text))
2166
+ continue;
2167
+ const files = new Set();
2168
+ let flagValue = false;
2169
+ for (const word of segment.args.slice(1)) {
2170
+ const token = word.replace(/^(['"])(.*)\1$/, '$2');
2171
+ if (flagValue) {
2172
+ flagValue = false;
2173
+ continue;
2174
+ }
2175
+ if (token.startsWith('-')) {
2176
+ const flag = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
2177
+ flagValue = !token.includes('=') && PLAYWRIGHT_VALUE_FLAGS.has(flag);
2178
+ continue;
2179
+ }
2180
+ if (SPEC_FILE_ARG.test(token)) {
2181
+ files.add(token.replace(/:\d+(?::\d+)?$/, ''));
2182
+ continue;
2183
+ }
2184
+ // A directory (`e2e/`), a glob, or a bare substring filter (`npx playwright test login`):
2185
+ // the command does not name the files it selects, so there is no count to take.
2186
+ return null;
2187
+ }
2188
+ return files.size > 0 ? files.size : null;
2189
+ }
2190
+ return null;
2191
+ }
2054
2192
  /**
2055
2193
  * Whether a repeating cause is failing the whole run — the one case where stopping it is the
2056
2194
  * right call.
2057
2195
  *
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.
2196
+ * The test is: the cause accounts for every failure the log names, it has hit the spec-file
2197
+ * threshold, and nothing has passed since its first failure. The threshold is
2198
+ * {@link UNIVERSAL_CAUSE_FILES} for a whole-suite run but the number of files the command
2199
+ * SELECTED for a narrowed one ({@link specFilesSelected}): a one-file run can never span three,
2200
+ * and xr-molecule-flash-m119 (+42:08) waited out its whole budget on exactly that shape. An app
2201
+ * that is not up, a 5xx on every request, or a selector that is missing on every page all look
2202
+ * like that, and every remaining test will fail the same way — so the run's remaining minutes
2203
+ * measure nothing. A CONNECTION-class cause ({@link CONNECTION_CAUSE}) needs no file span at all:
2204
+ * three tests that could not reach the app, with nothing passing since, is the app-unreachable
2205
+ * case however few files the run walks.
2206
+ *
2207
+ * Anything less is NOT this: a cause that stops at one file of a multi-file run, or that leaves
2208
+ * other tests passing, or that shares the run with failures of another shape, leaves the run with
2209
+ * something to say, and the run is the only thing that will say it. X1 flash-m83 (2026-10-16)
2210
+ * stopped eight local runs at their first repeating cause over 14.5 minutes and never finished
2211
+ * one; three of its deploys each fixed a single cause that a finished run would have listed
2212
+ * alongside the others.
2068
2213
  *
2069
2214
  * @param log - The log so far.
2070
2215
  * @param found - The repeating cause {@link repeatingFailure} found in it.
2216
+ * @param command - How the run was started, when the caller knows it: its spec-file selection
2217
+ * scales the file threshold ({@link specFilesSelected}).
2071
2218
  * @returns The cause's count and files, or null when the run is not failing whole.
2072
2219
  */
2073
- export function universalCause(log, found) {
2220
+ export function universalCause(log, found, command) {
2074
2221
  const text = log.replace(ANSI_SGR, '');
2075
2222
  const files = [...new Set(found.tests.map((t) => specFile(t.where)).filter(Boolean))];
2076
- if (files.length < UNIVERSAL_CAUSE_FILES)
2223
+ // The threshold is what THIS run selects, not the suite's total: a run narrowed to one file
2224
+ // can never span three of them, however whole its failure is.
2225
+ const selected = command ? specFilesSelected(command) : null;
2226
+ const threshold = selected === null ? UNIVERSAL_CAUSE_FILES : Math.min(UNIVERSAL_CAUSE_FILES, selected);
2227
+ // The app never answered: three tests of that with nothing passing since is the whole of what
2228
+ // a whole-suite run would have said, whatever the file count (xr-molecule-flash-m119).
2229
+ const unreachable = found.message !== null &&
2230
+ CONNECTION_CAUSE.test(found.message) &&
2231
+ found.count >= CONNECTION_CAUSE_TESTS;
2232
+ if (!unreachable && files.length < threshold)
2077
2233
  return null;
2078
2234
  // Every failure the log names has to be this cause: another cause anywhere in the run means
2079
2235
  // the run still has something to say, and stopping it throws that away.
@@ -2094,8 +2250,10 @@ export function universalCause(log, found) {
2094
2250
  *
2095
2251
  * Both halves are load-bearing, each of them learned from a run that lost minutes to the other.
2096
2252
  *
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
2253
+ * - **Failing the WHOLE run** is {@link universalCause}: every failure the log names, across the
2254
+ * spec-file threshold — {@link UNIVERSAL_CAUSE_FILES}, or the files a narrowed run's own
2255
+ * command selects, or {@link CONNECTION_CAUSE_TESTS} tests of an app that never answered — with
2256
+ * nothing passing since the first. That is the
2099
2257
  * dont-stop-runs rule. X1 flash-m83 (2026-10-16) stopped eight local runs at their first
2100
2258
  * repeating cause in 14.5 minutes and never finished one, and a run that still has other causes
2101
2259
  * left to name is the only thing that will name them.
@@ -2236,7 +2394,7 @@ function flakyTestsDeclared(text) {
2236
2394
  * @returns The causes to fix, the distinct tests dropped as excluded, and the distinct tests the
2237
2395
  * runner counted as flaky (a retried pass is not a cause to fix).
2238
2396
  */
2239
- function groupFailures(log, excluded) {
2397
+ function groupFailures(log, excluded, pageErrors = []) {
2240
2398
  const text = log.replace(ANSI_SGR, '');
2241
2399
  const flakyTests = flakyTestsDeclared(text);
2242
2400
  const flaky = new Set(flakyTests.map(testKey));
@@ -2272,9 +2430,21 @@ function groupFailures(log, excluded) {
2272
2430
  });
2273
2431
  }
2274
2432
  const causes = [];
2433
+ // The page's own words for a test, keyed the way both sides spell the location: the log's
2434
+ // `file:line` and the artifact's `file:line` are the same string from one run.
2435
+ const showedBy = new Map();
2436
+ for (const entry of pageErrors)
2437
+ if (entry.items.length > 0)
2438
+ showedBy.set(entry.where, entry.items);
2275
2439
  for (const [message, group] of byMessage) {
2276
2440
  const tests = distinctTests(group.tests);
2277
- if (tests.length > 0)
2441
+ if (tests.length > 0) {
2442
+ // The first test of the cause that has page errors speaks for it: tests grouped under one
2443
+ // error were failing the same way, and every one of m122's eight snapshots carried the same
2444
+ // two blank-field alerts.
2445
+ const showed = tests
2446
+ .map((t) => showedBy.get(t.where))
2447
+ .find((items) => items !== undefined);
2278
2448
  causes.push({
2279
2449
  message,
2280
2450
  seconds: null,
@@ -2284,7 +2454,9 @@ function groupFailures(log, excluded) {
2284
2454
  // no layer of its own is read as an assertion (unknown is never `connection`).
2285
2455
  kind: group.kind,
2286
2456
  ...(group.budgetMs === null ? {} : { budgetMs: group.budgetMs }),
2457
+ ...(showed === undefined ? {} : { pageShowed: [...showed] }),
2287
2458
  });
2459
+ }
2288
2460
  }
2289
2461
  // The failures with no body of their own — a `list` log with no error blocks in it, from a run
2290
2462
  // not started in the background — are grouped by how long they took: a band is opened by its
@@ -2329,6 +2501,20 @@ function groupFailures(log, excluded) {
2329
2501
  * xr-molecule-flash-m113 (+20:34) got ten of these and a list that called them ten causes.
2330
2502
  */
2331
2503
  const NO_BASE_URL_ERROR = /Cannot navigate to invalid URL/i;
2504
+ /** The browser's own `net::ERR_*`, as Playwright prints it for a document that never loaded. */
2505
+ const NETWORK_ERROR_TEXT = /\bnet::ERR_[A-Z0-9_]+\b/;
2506
+ /**
2507
+ * Whether every cause this run grouped is a `page.goto` that ran out of time.
2508
+ *
2509
+ * @param causes - The run's grouped causes.
2510
+ * @returns True when the run measured no page at all.
2511
+ */
2512
+ function everyCauseIsNavigationTimeout(causes) {
2513
+ return (causes.length > 0 &&
2514
+ causes.every((cause) => cause.message !== null &&
2515
+ (/\bpage\.goto\b/.test(cause.message) || /navigating to/.test(cause.message)) &&
2516
+ /timeout|timed out/i.test(cause.message)));
2517
+ }
2332
2518
  /**
2333
2519
  * The one-line note for a run whose EVERY failure is {@link NO_BASE_URL_ERROR}: a configuration
2334
2520
  * failure, not a result about the app.
@@ -2390,8 +2576,8 @@ function noBaseUrlConfigNote(causes, run, excluded) {
2390
2576
  * "left out" sentence greps when more causes exist than the result has room for.
2391
2577
  * @returns The note, or null.
2392
2578
  */
2393
- export function failureCausesNote(log, excluded = [], run) {
2394
- const { causes, ignored, flaky } = groupFailures(log, excluded);
2579
+ export function failureCausesNote(log, excluded = [], run, pageErrors = []) {
2580
+ const { causes, ignored, flaky } = groupFailures(log, excluded, pageErrors);
2395
2581
  const total = causes.reduce((n, c) => n + c.tests.length, 0);
2396
2582
  if (total < 2)
2397
2583
  return null;
@@ -2406,6 +2592,23 @@ export function failureCausesNote(log, excluded = [], run) {
2406
2592
  // cannot report them again.
2407
2593
  const locations = [...new Set(causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
2408
2594
  const rerun = run ? rerunOfRunningSuite(run, locations, excluded) : null;
2595
+ // A document the browser could not load explains every failure at once, and it is not in the
2596
+ // app: m118 (+37:09) read 14 `page.goto` timeouts as app failures while the page load had ended
2597
+ // `net::ERR_CONTENT_DECODING_FAILED`. The cause list is the wrong shape here — there is one
2598
+ // cause and it is the server — so the note leads with the browser's own line, not a fix round.
2599
+ const networkError = NETWORK_ERROR_TEXT.exec(log)?.[0];
2600
+ if (networkError !== undefined && everyCauseIsNavigationTimeout(causes)) {
2601
+ return (`${total} failure${total === 1 ? '' : 's'} in this run, and every one is a \`page.goto\` ` +
2602
+ `that never loaded while the browser reported \`${networkError}\` — the document request ` +
2603
+ `itself failed, so none of these is the app’s code or a selector. Fix the server first` +
2604
+ (networkError === 'net::ERR_CONTENT_DECODING_FAILED'
2605
+ ? ': a body whose gzip `content-encoding` no longer matches its bytes does this, and a ' +
2606
+ '`vite preview` over a build must be started with ' +
2607
+ '`MOL_E2E_PREVIEW_PLUGIN=0 npx vite preview --port 4173 --strictPort`'
2608
+ : ' (what the run hit, and why it refused the connection)') +
2609
+ (rerun ? `. Then send exactly this: \`${rerun}\`` : '') +
2610
+ (locations.length > 0 ? `. Failing tests: ${locations.join(', ')}.` : ''));
2611
+ }
2409
2612
  const ignoredTitles = [...new Set(ignored.map((t) => t.title || t.where).filter(Boolean))];
2410
2613
  const flakyTitles = [...new Set(flaky.map((t) => t.where || t.title).filter(Boolean))];
2411
2614
  // A cause that never reached the app is not a cause the app's code can fix: the transport
@@ -2421,8 +2624,22 @@ export function failureCausesNote(log, excluded = [], run) {
2421
2624
  "if the failure repeats, read the app's own log (`read_logs`) — a connection error is never a " +
2422
2625
  'handler shape, a header or CORS.');
2423
2626
  }
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`;
2627
+ // A text that is the TAIL of a longer log says so in its first line (`…(N earlier characters
2628
+ // omitted)`, the shape `background-tasks` writes when a log is capped): what it holds is not
2629
+ // the run's list of failures, only the part that survived the cut. Saying "N failures in this
2630
+ // run" over it reads as complete, and did: xr-molecule-flash-m123's collected 42 KB diagnosis
2631
+ // log was clipped to its last 8 KB, and this note grouped 2 of the run's 14 failures under
2632
+ // "fix every cause below" while the 11 that shared the run's one real cause were gone
2633
+ // (2026-10-17). The root fix is to hand this function the whole log; this says so when a
2634
+ // caller cannot.
2635
+ const clipped = /^…\((\d+) earlier characters omitted\)/.exec(log);
2636
+ const header = clipped !== null
2637
+ ? `${total} failure${total === 1 ? '' : 's'} in the LAST part of this run’s log — its ` +
2638
+ `first ${clipped[1]} characters were clipped, so a cause that failed earlier is ` +
2639
+ `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' : ''}, ` +
2640
+ `and read the whole run log before concluding:\n`
2641
+ : `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
2642
+ `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
2643
  // The flaky sentence sits before the ignored one: the ignored sentence is the LAST thing the
2427
2644
  // note says, and callers assert on that ending.
2428
2645
  const tail = (rerun ? ` Re-run just these once they are fixed: \`${rerun}\`` : '') +
@@ -2479,10 +2696,21 @@ function failureCauseLine(cause, index) {
2479
2696
  // twice; the rest are the values the normalised signature replaced with `<n>`.
2480
2697
  const body = cause.evidence.filter((line) => cause.message === null || !cause.message.includes(line));
2481
2698
  const shortened = body.map((line) => line.length > FAILURE_EVIDENCE_CHARS ? `${line.slice(0, FAILURE_EVIDENCE_CHARS - 1)}…` : line);
2699
+ // The page's own words at the failure (xr-molecule-flash-m122 +23:39): the error line names the
2700
+ // symptom, and these name what the app was saying while it happened. The parenthetical is said
2701
+ // only for the shape the items themselves show — validation copy on a form — where "submitted
2702
+ // before its values loaded" is the reading, and never for arbitrary page text.
2703
+ const showed = cause.pageShowed?.length
2704
+ ? `\n the page showed: ${cause.pageShowed.map((t) => `\`${t}\``).join(', ')}` +
2705
+ (cause.pageShowed.some((t) => /\b(?:blank|required|invalid|must be)\b/i.test(t))
2706
+ ? ' (the action ran before the form’s values had loaded)'
2707
+ : '')
2708
+ : '';
2482
2709
  return (` ${index + 1}. ${cause.kind === 'connection' ? 'CONNECTION error, not the app’s — ' : ''}${signature} — ` +
2483
2710
  `${cause.tests.length} test${cause.tests.length === 1 ? '' : 's'} ` +
2484
2711
  `in ${files} file${files === 1 ? '' : 's'}: ${tests.join(', ')}${more}` +
2485
- (shortened.length ? `\n ${shortened.join(' · ')}` : ''));
2712
+ (shortened.length ? `\n ${shortened.join(' · ')}` : '') +
2713
+ showed);
2486
2714
  }
2487
2715
  /**
2488
2716
  * The sentence for the causes a recap could not fit: exactly which `file:line` each one was
@@ -2538,11 +2766,11 @@ function omittedCausesRecap(omitted, run, excluded) {
2538
2766
  * is not a Playwright run this can narrow.
2539
2767
  *
2540
2768
  * 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}).
2769
+ * that run started in, so the composer resolves them there ({@link scopedRerunCommand}): the
2770
+ * command names that directory unless it already changes into one of its own, because the
2771
+ * executor's next command starts wherever it starts. The returned command is for AFTER the run
2772
+ * ends — it is a second run against the same app and database, and that is exactly what is
2773
+ * refused while the run walks ({@link suiteRunInTheWay}).
2546
2774
  *
2547
2775
  * @param run - The run in flight: the command it was started with, as it was run, the directory
2548
2776
  * that command ran in, and the config it loaded when the caller knows it.
@@ -2554,7 +2782,10 @@ function omittedCausesRecap(omitted, run, excluded) {
2554
2782
  export function rerunOfRunningSuite(run, locations, excluded = []) {
2555
2783
  if (locations.length === 0)
2556
2784
  return null;
2557
- const scoped = scopedRerunCommand(run.command, locations, excluded, run.config);
2785
+ // The run's own directory is the base a relative failure location resolves against: the
2786
+ // reporter prints it from where the runner started, and Playwright matches file arguments
2787
+ // against absolute paths (xr-molecule-flash-m118 +16:34, m124 +18:38).
2788
+ const scoped = scopedRerunCommand(run.command, locations, excluded, run.config, run.cwd);
2558
2789
  if (scoped === run.command)
2559
2790
  return null;
2560
2791
  if (!run.cwd || commandSegments(withoutHeredocs(run.command)).some((s) => s.program === 'cd'))
@@ -2592,8 +2823,10 @@ export function rerunOfRunningSuite(run, locations, excluded = []) {
2592
2823
  * @param found - What {@link repeatingFailure} found.
2593
2824
  * @param o - The run's facts: its `taskId`, its `log` path, `stop` — the shell command that
2594
2825
  * 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.
2826
+ * {@link universalCause} found (absent means the run is not failing whole), `run`, how the
2827
+ * run was started ({@link rerunOfRunningSuite}) when the caller knows it, and
2828
+ * `editsInvalidateRun`, true when the run measures the sandbox's own app (a local run, where an
2829
+ * edit now changes what the suite is testing and its result stops counting).
2597
2830
  * @returns The note.
2598
2831
  */
2599
2832
  export function repeatingFailureNote(found, o) {
@@ -2656,8 +2889,11 @@ export function repeatingFailureNote(found, o) {
2656
2889
  const files = stop.files.map(shortFile);
2657
2890
  const shown = files.slice(0, 4).join(', ');
2658
2891
  const moreFiles = files.length > 4 ? `, … and ${files.length - 4} more` : '';
2892
+ const span = files.length === 0
2893
+ ? ''
2894
+ : ` — across ${files.length} file${files.length === 1 ? '' : 's'} (${shown}${moreFiles})`;
2659
2895
  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 ` +
2896
+ `them, ${signature}${span}, with nothing ` +
2661
2897
  `passing since the first:\n${rows}${more}\n` +
2662
2898
  `Every remaining test will fail the same way too, so walking the rest of this run only ` +
2663
2899
  `repeats this one cause. Stop it and fix that cause first: \`exec_command: ${o.stop}\`, ` +
@@ -2674,7 +2910,9 @@ export function repeatingFailureNote(found, o) {
2674
2910
  if (isCause)
2675
2911
  return (`${found.count} different tests in this run have already failed the same way — ${signature} — ` +
2676
2912
  `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 ` +
2913
+ (o.editsInvalidateRun
2914
+ ? `Work out the fix now: apply it when the run ends, not while it walks. Stop it only if every test it walks `
2915
+ : `Start fixing it now, while the run keeps walking. Stop it only if every test it walks `) +
2678
2916
  `fails this same way (the app unreachable, a 5xx on every request, the same selector timing ` +
2679
2917
  `out on every test of every file so far).${reproduce} ${keepGoing}${thenRun}`);
2680
2918
  return (`${found.count} different tests in this run each failed after ~${found.seconds}s — the same ` +
@@ -2682,7 +2920,10 @@ export function repeatingFailureNote(found, o) {
2682
2920
  `config sets a timeout spends that timeout on every failure that waits for anything, so ` +
2683
2921
  `different missing elements land on the same number. Read these failures before fixing one ` +
2684
2922
  `of them:\n${rows}${more}\n` +
2685
- `Start on them now, while the run keeps walking.${reproduce} ${keepGoing}${thenRun}`);
2923
+ (o.editsInvalidateRun
2924
+ ? `Work out the fixes now: apply them when the run ends, not while it walks.`
2925
+ : `Start on them now, while the run keeps walking.`) +
2926
+ `${reproduce} ${keepGoing}${thenRun}`);
2686
2927
  }
2687
2928
  /**
2688
2929
  * 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.1",
3
+ "version": "1.9.3",
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",