@molecule/api-ai-tools 1.8.2 → 1.9.1

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,9 +5,10 @@
5
5
  * @module
6
6
  */
7
7
  import { posix } from 'path';
8
- import { commandSegments } from './command-position.js';
8
+ import { commandSegments, locatedCommandSegments } from './command-position.js';
9
+ import { classifyRunFailure } from './failure-kind.js';
9
10
  import { commandRunsNoTests } from './no-test-run.js';
10
- import { isDiagnosisRun, scopedRerunCommand, suiteCommandTarget, suiteRunInTheWay, withoutHeredocs, } from './suite-overlap.js';
11
+ import { isDiagnosisRun, scopedRerunCommand, suiteCommandTarget, suiteRunInTheWay, withoutHeredocs, withSuiteConfig, } from './suite-overlap.js';
11
12
  /**
12
13
  * Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes),
13
14
  * single-quoted strings prevent command substitution ($(), backticks) and variable expansion.
@@ -430,16 +431,23 @@ export function streamingBackgroundCommand(command) {
430
431
  // joins stderr to it.
431
432
  const finalPlaywrightRun = isPlaywrightRunCommand(lastStep) && !/>/.test(lastStep.replace(/\d?>&\d/g, ''));
432
433
  /**
433
- * Add a word to the suite runner's own arguments. A pipe this rewrite could not drop still
434
- * follows the runner (`| tee run.log`), so appending at the end of the whole command would make
435
- * the word an operand of the filter — m102's dangling `--trace` flag, in the same shape.
434
+ * Add a word to the suite runner's own arguments; false when the runner could not be placed.
435
+ *
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.
436
442
  */
437
443
  const addToRun = (flag) => {
438
444
  const pipeline = lastSuitePipeline(out);
439
445
  const runner = pipeline ? pipeline.located[pipeline.at] : undefined;
440
- out = runner
441
- ? `${out.slice(0, runner.end)} ${flag}${out.slice(runner.end)}`
442
- : `${out.trimEnd()} ${flag}`;
446
+ if (runner && runner.end === null)
447
+ return false;
448
+ const at = runner?.end ?? out.trimEnd().length;
449
+ out = `${out.slice(0, at)} ${flag}${out.slice(at)}`;
450
+ return true;
443
451
  };
444
452
  // A diagnosis or a re-run (capped, last-failed, grepped, or named files) walks each failure
445
453
  // once: the suite's own `retries` doubled every failure of X1 flash-m71's first whole run,
@@ -493,8 +501,7 @@ export function streamingBackgroundCommand(command) {
493
501
  'write every result into the log twice (X1 flash-m83).');
494
502
  }
495
503
  }
496
- else if (finalPlaywrightRun) {
497
- addToRun('--reporter=list');
504
+ else if (finalPlaywrightRun && addToRun('--reporter=list')) {
498
505
  changes.push('Added `--reporter=list` so each test result is written to the log as it finishes ' +
499
506
  '(the config reporter may print nothing until the end).');
500
507
  }
@@ -513,8 +520,8 @@ export function streamingBackgroundCommand(command) {
513
520
  'and you need not wait for it to end to start on them.');
514
521
  }
515
522
  if (finalPlaywrightRun && diagnosis && !/--retries\b/.test(lastStep)) {
516
- addToRun('--retries=0');
517
- changes.push('Added `--retries=0`: a diagnosis or re-run walks each failure once; keep the suite’s retries for the proof run.');
523
+ if (addToRun('--retries=0'))
524
+ changes.push('Added `--retries=0`: a diagnosis or re-run walks each failure once; keep the suite’s retries for the proof run.');
518
525
  }
519
526
  return { command: out, changes };
520
527
  }
@@ -604,32 +611,34 @@ const OUTPUT_ONLY_FILTERS = new Set([
604
611
  /** A word that sends output to a file (`> f`, `>> f`, `2> f`); `2>&1` only joins stderr, so it is not one. */
605
612
  const OUTPUT_REDIRECT = /^(?:\d*|&)>>?(?!&)/;
606
613
  /**
607
- * The shell parse's segments with their offsets in the command, or null when one cannot be placed.
614
+ * The shell parse's segments with their offsets in the command.
608
615
  *
609
- * {@link commandSegments} answers which program each stage runs, but re-joins the words with single
610
- * spaces — so a segment written with irregular whitespace, or a program spelled with a directory,
611
- * has no offset a caller can cut at. Null is the honest answer there: every caller then leaves the
612
- * command exactly as written rather than cutting into a guess.
616
+ * {@link commandSegments} answers which program each stage runs; this answers where it is. The
617
+ * offsets come from the parse's own token positions ({@link locatedCommandSegments}), never from
618
+ * searching the command for the re-joined text: the words are joined back with single spaces for
619
+ * matching, so `command.indexOf(segment.text)` comes up empty on irregular spacing, on a grouping
620
+ * brace, or on anything a heredoc body shifts — and the caller then cut at a guess or refused to
621
+ * cut at all (xr-molecule-flash-m105 +08:12, where a heredoc body turned a finished run into
622
+ * "piped" for the rest of the session).
623
+ *
624
+ * A statement inside a nested `sh -c '<command>'` string is listed with null offsets: its
625
+ * positions are positions in THAT string, so nothing may be spliced at them — but its separator
626
+ * is known, which is all a pipeline question needs.
613
627
  *
614
628
  * @param command - The shell command.
615
- * @returns The located segments, in order, or null.
629
+ * @returns The located segments, in order.
616
630
  */
617
631
  function locatedSegments(command) {
618
632
  const out = [];
619
- let cursor = 0;
620
- let previousEnd = null;
621
- for (const segment of commandSegments(command)) {
622
- const at = command.indexOf(segment.text, cursor);
623
- if (at < 0)
624
- return null;
633
+ for (const located of locatedCommandSegments(command)) {
634
+ if (located.segment === null)
635
+ continue;
625
636
  out.push({
626
- segment,
627
- start: at,
628
- end: at + segment.text.length,
629
- between: previousEnd === null ? '' : command.slice(previousEnd, at),
637
+ segment: located.segment,
638
+ start: located.start,
639
+ end: located.end,
640
+ between: located.separator,
630
641
  });
631
- cursor = at + segment.text.length;
632
- previousEnd = cursor;
633
642
  }
634
643
  return out;
635
644
  }
@@ -673,8 +682,6 @@ function lastSuitePipeline(command) {
673
682
  if (commandRunsNoTests(command))
674
683
  return null;
675
684
  const located = locatedSegments(command);
676
- if (located === null)
677
- return null;
678
685
  let at = -1;
679
686
  for (let i = 0; i < located.length; i += 1) {
680
687
  const segment = located[i]?.segment;
@@ -724,33 +731,59 @@ export function stripTrailingSuiteFilters(command) {
724
731
  const stage = located[first];
725
732
  if (!previous || !stage)
726
733
  return { command, dropped: '' };
734
+ // 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)
736
+ return { command, dropped: '' };
727
737
  const pipe = stage.between.indexOf('|');
728
738
  if (pipe < 0)
729
739
  return { command, dropped: '' };
730
740
  const cut = previous.end + pipe;
731
741
  return { command: command.slice(0, cut).trimEnd(), dropped: command.slice(cut).trim() };
732
742
  }
743
+ /**
744
+ * Where the suite runner's exit code comes from: the runner itself, or a stage piped after it.
745
+ *
746
+ * {@link suiteRunPipedAfterRunner}'s one question, with the honest third answer kept apart. The
747
+ * code a finished run reports is its LAST pipeline stage's, so a stage after the runner makes the
748
+ * code the stage's — but only when such a stage EXISTS. The platform used to report "piped" for
749
+ * any run whose statements it could not place in the text, which one heredoc body was enough to
750
+ * cause: on X1 xr-molecule-flash-m105 (+08:12) a config written in the same `exec_command` made
751
+ * the run come back with no exit code at all, and the note under it claimed a stage after the
752
+ * runner that was not there. The parse now places every statement (`locatedCommandSegments`), so
753
+ * "unplaced" is the rare, named case rather than a guess dressed as an answer.
754
+ *
755
+ * @param command - The shell command, after the streaming rewrite when one was applied.
756
+ * @returns Whether a stage follows the runner, and whether the run could be placed at all.
757
+ */
758
+ export function suiteRunPipelinePlacement(command) {
759
+ const located = locatedSegments(command);
760
+ const pipeline = lastSuitePipeline(command);
761
+ if (pipeline !== null) {
762
+ const stages = pipeline.located.slice(pipeline.at);
763
+ // Every stage of the pipeline has to sit somewhere in THIS command to be read as a pipeline
764
+ // of it: a suite inside a `sh -c '<command>'` string has no offsets here.
765
+ const unplaced = stages.some((stage) => stage.start === null);
766
+ return { piped: pipeline.located.length > pipeline.at + 1, unplaced };
767
+ }
768
+ if (commandRunsNoTests(command))
769
+ return { piped: false, unplaced: false };
770
+ const runner = located.find((stage) => SUITE_RUN_COMMANDS.some((re) => re.test(stage.segment.text)));
771
+ // No suite at all, or a suite that is not the command's last step (a `; echo done` follows it):
772
+ // either way no stage after the RUNNER produces this command's code.
773
+ if (!runner)
774
+ return { piped: false, unplaced: false };
775
+ return { piped: false, unplaced: runner.start === null };
776
+ }
733
777
  /**
734
778
  * Whether the command's last step runs a suite whose output still passes through a stage after
735
779
  * the runner — one {@link stripTrailingSuiteFilters} could not prove is output-only (`| tee log`,
736
780
  * `| xargs …`). The pipeline's exit status is that stage's, not the run's.
737
781
  *
738
- * A command whose stages cannot be placed in the text (irregular whitespace inside the run) is
739
- * reported as piped too: whether a pipe follows the runner is then UNKNOWN, and an unknown exit
740
- * code that gets trusted is a failing run read as a pass — the failure this rule exists to stop.
741
- *
742
782
  * @param command - The shell command, after the streaming rewrite when one was applied.
743
- * @returns True when a pipe this tool must not drop still follows the suite runner, or when the
744
- * command runs a suite the parse cannot place.
783
+ * @returns True when a pipe this tool must not drop still follows the suite runner.
745
784
  */
746
785
  export function suiteRunPipedAfterRunner(command) {
747
- const pipeline = lastSuitePipeline(command);
748
- if (pipeline !== null)
749
- return pipeline.located.length > pipeline.at + 1;
750
- // No placed pipeline: either there is no suite at all, or its stages cannot be located.
751
- if (commandRunsNoTests(command) || locatedSegments(command) !== null)
752
- return false;
753
- return commandSegments(command).some((segment) => SUITE_RUN_COMMANDS.some((re) => re.test(segment.text)));
786
+ return suiteRunPipelinePlacement(command).piped;
754
787
  }
755
788
  /**
756
789
  * Start a background suite run at lower CPU and IO priority, so the sandbox it
@@ -1753,6 +1786,32 @@ function normalizedSignature(s) {
1753
1786
  .replace(/\b\d{5,}\b/g, '<n>')
1754
1787
  .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>'));
1755
1788
  }
1789
+ /**
1790
+ * The per-test budget a failure block's own message names, in ms, or null. Only the runner's
1791
+ * budget kill (`Test timeout of 15000ms exceeded`) is read: a locator wait's own
1792
+ * `Timeout 5000ms exceeded` is the config's action timeout, not the budget a test body has to fit
1793
+ * its repeated work into.
1794
+ *
1795
+ * @param block - One failure block, as {@link failureBlocks} splits them.
1796
+ * @returns The budget in ms, or null.
1797
+ */
1798
+ function failureBudgetMs(block) {
1799
+ const m = /\bTest timeout of (\d+)ms exceeded\b/.exec(block.replace(ANSI_SGR, ''));
1800
+ return m ? Number(m[1]) : null;
1801
+ }
1802
+ /**
1803
+ * The cause signature with the budget it normalized away put back: `Test timeout of <n> exceeded`
1804
+ * becomes `Test timeout of 15000ms exceeded`. Every other `<n>` stays — those are the values that
1805
+ * differ between tests of one cause, while the budget is the one that does not, and it is what
1806
+ * turns "a timeout" into "15 creates in 15 s" (xr-molecule-flash-m110 +20:44).
1807
+ *
1808
+ * @param message - The normalized signature.
1809
+ * @param budgetMs - The budget the cause's first block printed.
1810
+ * @returns The signature to print.
1811
+ */
1812
+ function signatureWithBudget(message, budgetMs) {
1813
+ return message.replace(/Test timeout of <n> exceeded/, `Test timeout of ${budgetMs}ms exceeded`);
1814
+ }
1756
1815
  /**
1757
1816
  * What separates one cause from another in a `playwright test` log: the error CLASS plus the
1758
1817
  * normalized message — the error line, the thing it waited for or asserted on, and the values
@@ -2124,17 +2183,63 @@ export function failuresByCause(log, excluded = []) {
2124
2183
  return groupFailures(log, excluded).causes;
2125
2184
  }
2126
2185
  /**
2127
- * The causes a run's log names, and the failures the request excludes out of them.
2186
+ * A finished run's own count of tests that failed their first attempt and passed on the retry:
2187
+ * Playwright's ` 1 flaky`, printed in its closing tally.
2188
+ */
2189
+ const FLAKY_TALLY_LINE = /^\s*[1-9]\d*\s+flaky\b/;
2190
+ /**
2191
+ * The tests a finished run's tally declares FLAKY (` 1 flaky` followed by their headings), keyed
2192
+ * the way a failure block is.
2193
+ *
2194
+ * Playwright prints a numbered failure block for the FIRST attempt of a test that then passes on
2195
+ * its retry, and also declares the test under `N flaky` — so a reader that only looks at the
2196
+ * blocks counts a flake as a cause to fix. xr-molecule-flash-m105's proof run against the deployed
2197
+ * URL reported ten `waitForURL` timing flakes as "causes" of a red run (+24:54), and the executor
2198
+ * spent its next minutes re-running them one by one until three passed untouched. The
2199
+ * declaration under the count is the runner's own answer, and its heading carries the same words
2200
+ * as the failure block's ({@link splitTitlePath} reads both the same way).
2201
+ *
2202
+ * @param text - The run's output, ANSI already stripped.
2203
+ * @returns Each flaky test, first seen first.
2204
+ */
2205
+ function flakyTestsDeclared(text) {
2206
+ const tests = [];
2207
+ let underFlakyCount = false;
2208
+ for (const line of text.split('\n')) {
2209
+ if (FLAKY_TALLY_LINE.test(line)) {
2210
+ underFlakyCount = true;
2211
+ continue;
2212
+ }
2213
+ if (!underFlakyCount)
2214
+ continue;
2215
+ // The declaration lines are the reporter's own headings (` [chromium] › a.spec.ts:5:1 › …`);
2216
+ // the first line that is not one ends them (the next tally line, or the end of the report).
2217
+ const heading = /^\s+\[[^\]\n]+\]\s*›\s*(.+)$/.exec(line)?.[1];
2218
+ if (heading === undefined) {
2219
+ underFlakyCount = false;
2220
+ continue;
2221
+ }
2222
+ const { where, title } = splitTitlePath(heading.replace(/[\s─]+$/, ''));
2223
+ tests.push({ where, title, seconds: null });
2224
+ }
2225
+ return distinctTests(tests);
2226
+ }
2227
+ /**
2228
+ * The causes a run's log names, the failures the request excludes, and the failures the runner
2229
+ * itself counted as flaky out of them.
2128
2230
  *
2129
2231
  * One grouping pass, because a note that lists the causes must know what it left out to say so
2130
2232
  * ({@link failureCausesNote}).
2131
2233
  *
2132
2234
  * @param log - The log of a finished or running run.
2133
2235
  * @param excluded - The test titles the request carves out.
2134
- * @returns The causes to fix, and the distinct tests dropped as excluded.
2236
+ * @returns The causes to fix, the distinct tests dropped as excluded, and the distinct tests the
2237
+ * runner counted as flaky (a retried pass is not a cause to fix).
2135
2238
  */
2136
2239
  function groupFailures(log, excluded) {
2137
2240
  const text = log.replace(ANSI_SGR, '');
2241
+ const flakyTests = flakyTestsDeclared(text);
2242
+ const flaky = new Set(flakyTests.map(testKey));
2138
2243
  const byMessage = new Map();
2139
2244
  const named = [];
2140
2245
  for (const block of failureBlocks(text)) {
@@ -2144,6 +2249,10 @@ function groupFailures(log, excluded) {
2144
2249
  const m = FAILURE_BLOCK_HEADER.exec(block.split('\n')[0] ?? '');
2145
2250
  const { where, title } = splitTitlePath(m?.[1] ?? '');
2146
2251
  const test = { where, title, seconds: null };
2252
+ // The runner declared this test flaky: the block above is its first attempt, which it
2253
+ // passed on the retry, so it is neither a cause nor a failure to re-run.
2254
+ if (flaky.has(testKey(test)))
2255
+ continue;
2147
2256
  named.push(test);
2148
2257
  if (matchingExcludedTitles(title, excluded).length > 0)
2149
2258
  continue;
@@ -2151,18 +2260,31 @@ function groupFailures(log, excluded) {
2151
2260
  if (group)
2152
2261
  group.tests.push(test);
2153
2262
  // The FIRST block with this signature carries the evidence: a later block of the same cause
2154
- // says the same thing, and the first is the one the note's own line refers to.
2263
+ // says the same thing, and the first is the one the note's own line refers to. Its LAYER
2264
+ // rides along too: a cause that never reached the app is not an app-code fix
2265
+ // (`failure-kind.ts`).
2155
2266
  else
2156
2267
  byMessage.set(message, {
2157
2268
  tests: [test],
2158
2269
  evidence: failureEvidence(failureBody(block), message),
2270
+ budgetMs: failureBudgetMs(block),
2271
+ kind: classifyRunFailure(block)?.kind ?? 'assertion',
2159
2272
  });
2160
2273
  }
2161
2274
  const causes = [];
2162
2275
  for (const [message, group] of byMessage) {
2163
2276
  const tests = distinctTests(group.tests);
2164
2277
  if (tests.length > 0)
2165
- causes.push({ message, seconds: null, tests, evidence: group.evidence });
2278
+ causes.push({
2279
+ message,
2280
+ seconds: null,
2281
+ tests,
2282
+ evidence: group.evidence,
2283
+ // A duration group carries no block, so nothing says the transport failed: a cause with
2284
+ // no layer of its own is read as an assertion (unknown is never `connection`).
2285
+ kind: group.kind,
2286
+ ...(group.budgetMs === null ? {} : { budgetMs: group.budgetMs }),
2287
+ });
2166
2288
  }
2167
2289
  // The failures with no body of their own — a `list` log with no error blocks in it, from a run
2168
2290
  // not started in the background — are grouped by how long they took: a band is opened by its
@@ -2173,6 +2295,9 @@ function groupFailures(log, excluded) {
2173
2295
  const claimed = new Set(causes.flatMap((c) => c.tests.map(testKey)));
2174
2296
  const unclaimed = distinctTests(listed).filter((t) => t.seconds !== null &&
2175
2297
  !claimed.has(testKey(t)) &&
2298
+ // A flaky test's `✘` row is its first attempt; its `✓` retry follows it in the same log,
2299
+ // and the runner's tally already said the test passed.
2300
+ !flaky.has(testKey(t)) &&
2176
2301
  matchingExcludedTitles(t.title, excluded).length === 0);
2177
2302
  const byDuration = [];
2178
2303
  for (const t of unclaimed) {
@@ -2184,9 +2309,59 @@ function groupFailures(log, excluded) {
2184
2309
  byDuration.push({ seconds: t.seconds, tests: [t] });
2185
2310
  }
2186
2311
  for (const g of byDuration)
2187
- causes.push({ message: null, seconds: g.seconds, tests: g.tests, evidence: [] });
2312
+ causes.push({
2313
+ message: null,
2314
+ seconds: g.seconds,
2315
+ tests: g.tests,
2316
+ evidence: [],
2317
+ kind: 'assertion',
2318
+ });
2188
2319
  const ignored = distinctTests(named).filter((t) => matchingExcludedTitles(t.title, excluded).length > 0);
2189
- return { causes: causes.sort((a, b) => b.tests.length - a.tests.length), ignored };
2320
+ return {
2321
+ causes: causes.sort((a, b) => b.tests.length - a.tests.length),
2322
+ ignored,
2323
+ flaky: flakyTests,
2324
+ };
2325
+ }
2326
+ /**
2327
+ * The error every test of a Playwright run reports when the run loaded no base URL: `page.goto('/')`
2328
+ * has nothing to resolve against, so the browser refuses the navigation before any page loads.
2329
+ * xr-molecule-flash-m113 (+20:34) got ten of these and a list that called them ten causes.
2330
+ */
2331
+ const NO_BASE_URL_ERROR = /Cannot navigate to invalid URL/i;
2332
+ /**
2333
+ * The one-line note for a run whose EVERY failure is {@link NO_BASE_URL_ERROR}: a configuration
2334
+ * failure, not a result about the app.
2335
+ *
2336
+ * A list of causes is the wrong shape here — there is one cause, and it is not in the app: the
2337
+ * command loaded no base URL. The line names the config the run should have loaded (the caller
2338
+ * passes the one the platform recorded for this suite) and hands over the exact narrowed command
2339
+ * with it, so the next run measures the app instead of spending another six minutes on the same
2340
+ * configuration mistake (xr-molecule-flash-m113 ran four such runs, 07:06:17 → 07:12:03).
2341
+ *
2342
+ * @param causes - The run's failures, all carrying the same error.
2343
+ * @param run - How the run was started, when the caller knows it.
2344
+ * @param excluded - The test titles the request carves out (inverted out of the re-run).
2345
+ * @returns The one-line sentence.
2346
+ */
2347
+ function noBaseUrlConfigNote(causes, run, excluded) {
2348
+ const total = causes.reduce((n, c) => n + c.tests.length, 0);
2349
+ const locations = [...new Set(causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
2350
+ const config = run?.config ?? null;
2351
+ // The command is only worth handing over WITH the config: the same command without it is the
2352
+ // run that just failed this way. Where the caller does not know the config, the line says what
2353
+ // to find instead of repeating the mistake.
2354
+ const rerun = run && config ? rerunOfRunningSuite(run, locations, excluded) : null;
2355
+ const command = rerun ?? (run && config ? withSuiteConfig(run.command, config) : null);
2356
+ return (`Every one of these ${total} failures is the same configuration failure, not a result about ` +
2357
+ `the app: the run loaded no base URL, so every \`page.goto\` was refused with ` +
2358
+ `\`Cannot navigate to invalid URL\` before a page opened. Nothing here is a defect to fix. ` +
2359
+ (config
2360
+ ? `\`${config}\` is the config that sets \`use.baseURL\`, and the run before this one loaded ` +
2361
+ `it; this command left it out. `
2362
+ : 'The app’s own Playwright config (the one that sets `use.baseURL`) has to be passed ' +
2363
+ 'with `--config`; this command names none. ') +
2364
+ (command ? `Send exactly this: \`${command}\`` : ''));
2190
2365
  }
2191
2366
  /**
2192
2367
  * The one-line-per-cause summary of a finished run's failures, or null when the run named fewer
@@ -2197,22 +2372,33 @@ function groupFailures(log, excluded) {
2197
2372
  * stopped early happened to reach.
2198
2373
  *
2199
2374
  * A test the request excludes is not one of the causes — it is dropped and named once, at the
2200
- * end, so a result never asks to fix a test it also says to ignore. Where the caller knows how
2201
- * the run was started, the note also hands over the narrowed re-run of exactly those tests
2202
- * ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the whole suite.
2375
+ * end, so a result never asks to fix a test it also says to ignore. A test the runner itself
2376
+ * declared `flaky` (it failed once and passed on its config retry) is not a cause either: it is
2377
+ * named apart from them, once, as what the tally called it ({@link flakyTestsDeclared}). Where
2378
+ * the caller knows how the run was started, the note also hands over the narrowed re-run of
2379
+ * exactly those tests ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the
2380
+ * whole suite.
2381
+ *
2382
+ * A run whose failures are ALL {@link NO_BASE_URL_ERROR} is not a set of causes at all: it is one
2383
+ * configuration failure, said in one line ({@link noBaseUrlConfigNote}) rather than a list that
2384
+ * reads as ten defects in the app.
2203
2385
  *
2204
2386
  * @param log - The finished run's output.
2205
2387
  * @param excluded - The test titles the request carves out, if the caller knows them.
2206
2388
  * @param run - How the run was started, when the caller knows it: the narrowed re-run is built
2207
- * from `command`/`cwd`, and `log` (the path the output was written to) is what the "left out"
2208
- * sentence greps when more causes exist than the result has room for.
2389
+ * from `command`/`cwd`/`config`, and `log` (the path the output was written to) is what the
2390
+ * "left out" sentence greps when more causes exist than the result has room for.
2209
2391
  * @returns The note, or null.
2210
2392
  */
2211
2393
  export function failureCausesNote(log, excluded = [], run) {
2212
- const { causes, ignored } = groupFailures(log, excluded);
2394
+ const { causes, ignored, flaky } = groupFailures(log, excluded);
2213
2395
  const total = causes.reduce((n, c) => n + c.tests.length, 0);
2214
2396
  if (total < 2)
2215
2397
  return null;
2398
+ // One cause that is not in the app: every failure is the browser refusing a navigation with no
2399
+ // base URL. The list shape is what m113 read as ten causes, so it is never printed for this.
2400
+ if (causes.every((c) => c.message !== null && NO_BASE_URL_ERROR.test(c.message)))
2401
+ return noBaseUrlConfigNote(causes, run, excluded);
2216
2402
  // The narrowed run, so the next fix round costs two tests instead of the whole suite
2217
2403
  // (xr-molecule-flash-m101 +12:13 re-ran all 139, ~4 minutes, to check two). The command is the
2218
2404
  // run's own invocation narrowed by the ONE composer the mid-run note also uses
@@ -2221,10 +2407,36 @@ export function failureCausesNote(log, excluded = [], run) {
2221
2407
  const locations = [...new Set(causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
2222
2408
  const rerun = run ? rerunOfRunningSuite(run, locations, excluded) : null;
2223
2409
  const ignoredTitles = [...new Set(ignored.map((t) => t.title || t.where).filter(Boolean))];
2410
+ const flakyTitles = [...new Set(flaky.map((t) => t.where || t.title).filter(Boolean))];
2411
+ // A cause that never reached the app is not a cause the app's code can fix: the transport
2412
+ // failed, so the one action is a re-run (xr-molecule-flash-m110 +18:42 — a 20 s connection
2413
+ // timeout was read as an assertion and answered with a CORS middleware). Every cause being one
2414
+ // is its own sentence: nothing the app produced was measured, so there is no fix round at all.
2415
+ const connections = causes.filter((cause) => cause.kind === 'connection');
2416
+ if (causes.length > 0 && connections.length === causes.length) {
2417
+ const at = [...new Set(connections.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
2418
+ return (`${total} failure${total === 1 ? '' : 's'} in this run, and every one is a CONNECTION error — ` +
2419
+ `the requests never reached the app, so nothing here is an app-code cause${at.length ? `: ${at.join(', ')}` : ''}. ` +
2420
+ `Send the same file${at.length === 1 ? '' : 's'} once more as ${at.length === 1 ? 'it is' : 'they are'}; ` +
2421
+ "if the failure repeats, read the app's own log (`read_logs`) — a connection error is never a " +
2422
+ 'handler shape, a header or CORS.');
2423
+ }
2224
2424
  const header = `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
2225
- `below before the next deploy:\n`;
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`;
2426
+ // The flaky sentence sits before the ignored one: the ignored sentence is the LAST thing the
2427
+ // note says, and callers assert on that ending.
2226
2428
  const tail = (rerun ? ` Re-run just these once they are fixed: \`${rerun}\`` : '') +
2227
- (ignoredTitles.length ? ` Ignored (excluded by the request): ${ignoredTitles.join(', ')}.` : '');
2429
+ (flakyTitles.length
2430
+ ? ' Flaky (failed once, passed on the config retry — the runner counts these as flaky, not ' +
2431
+ `as failures): ${flakyTitles.join(', ')}.`
2432
+ : '') +
2433
+ (ignoredTitles.length
2434
+ ? ` Ignored (excluded by the request): ${ignoredTitles.join(', ')}.`
2435
+ : '') +
2436
+ (connections.length > 0
2437
+ ? ` The connection error is not app code: send that file once more as it is, and if it repeats, ` +
2438
+ "read the app's own log (`read_logs`) — it never reached the app."
2439
+ : '');
2228
2440
  // This list is the part of the result that SURVIVES the platform's tool-output cut: the stdout
2229
2441
  // above it is cut in the MIDDLE at {@link TOOL_RESULT_CHARS}, which is why the recap must fit
2230
2442
  // whole ({@link FAILURE_CAUSES_CHARS}) instead of being capped at a line count that left 3 of
@@ -2260,14 +2472,15 @@ function failureCauseLine(cause, index) {
2260
2472
  const tests = cause.tests.slice(0, FAILURE_CAUSE_TESTS_LISTED).map((t) => t.where || t.title);
2261
2473
  const more = cause.tests.length > tests.length ? `, … and ${cause.tests.length - tests.length} more` : '';
2262
2474
  const signature = cause.message !== null
2263
- ? `\`${cause.message}\``
2475
+ ? `\`${cause.budgetMs === undefined ? cause.message : signatureWithBudget(cause.message, cause.budgetMs)}\``
2264
2476
  : `each after ~${cause.seconds}s (a shared duration, not a proven cause)`;
2265
2477
  const files = new Set(cause.tests.map((t) => specFile(t.where)).filter(Boolean)).size;
2266
2478
  // A line the signature already carries (a timeout's `waiting for` locator, say) is not printed
2267
2479
  // twice; the rest are the values the normalised signature replaced with `<n>`.
2268
2480
  const body = cause.evidence.filter((line) => cause.message === null || !cause.message.includes(line));
2269
2481
  const shortened = body.map((line) => line.length > FAILURE_EVIDENCE_CHARS ? `${line.slice(0, FAILURE_EVIDENCE_CHARS - 1)}…` : line);
2270
- return (` ${index + 1}. ${signature} — ${cause.tests.length} test${cause.tests.length === 1 ? '' : 's'} ` +
2482
+ return (` ${index + 1}. ${cause.kind === 'connection' ? 'CONNECTION error, not the app’s — ' : ''}${signature} — ` +
2483
+ `${cause.tests.length} test${cause.tests.length === 1 ? '' : 's'} ` +
2271
2484
  `in ${files} file${files === 1 ? '' : 's'}: ${tests.join(', ')}${more}` +
2272
2485
  (shortened.length ? `\n ${shortened.join(' · ')}` : ''));
2273
2486
  }
@@ -2331,8 +2544,8 @@ function omittedCausesRecap(omitted, run, excluded) {
2331
2544
  * the same app and database, and that is exactly what is refused while the run walks
2332
2545
  * ({@link suiteRunInTheWay}).
2333
2546
  *
2334
- * @param run - The run in flight: the command it was started with, as it was run, and the
2335
- * directory that command ran in.
2547
+ * @param run - The run in flight: the command it was started with, as it was run, the directory
2548
+ * that command ran in, and the config it loaded when the caller knows it.
2336
2549
  * @param locations - The failing `file:line`s to re-run, as the run's log printed them.
2337
2550
  * @param excluded - The test titles the request carves out: the re-run inverts them out
2338
2551
  * (`--grep-invert`) so it cannot report them again.
@@ -2341,7 +2554,7 @@ function omittedCausesRecap(omitted, run, excluded) {
2341
2554
  export function rerunOfRunningSuite(run, locations, excluded = []) {
2342
2555
  if (locations.length === 0)
2343
2556
  return null;
2344
- const scoped = scopedRerunCommand(run.command, locations, excluded);
2557
+ const scoped = scopedRerunCommand(run.command, locations, excluded, run.config);
2345
2558
  if (scoped === run.command)
2346
2559
  return null;
2347
2560
  if (!run.cwd || commandSegments(withoutHeredocs(run.command)).some((s) => s.program === 'cd'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.8.2",
3
+ "version": "1.9.1",
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",