@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/README.md +413 -31
- package/dist/command-position.d.ts +26 -2
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +221 -24
- package/dist/failure-kind.d.ts +66 -0
- package/dist/failure-kind.d.ts.map +1 -0
- package/dist/failure-kind.js +134 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/sleep-peek.d.ts +51 -0
- package/dist/sleep-peek.d.ts.map +1 -0
- package/dist/sleep-peek.js +76 -0
- package/dist/suite-overlap.d.ts +35 -1
- package/dist/suite-overlap.d.ts.map +1 -1
- package/dist/suite-overlap.js +57 -2
- package/dist/tools.d.ts +56 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +285 -48
- package/dist/trace-artifacts.d.ts +77 -7
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +344 -47
- package/dist/utilities.d.ts +63 -13
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +277 -64
- package/package.json +1 -1
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
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
|
614
|
+
* The shell parse's segments with their offsets in the command.
|
|
608
615
|
*
|
|
609
|
-
* {@link commandSegments} answers which program each stage runs
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
* command
|
|
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
|
|
629
|
+
* @returns The located segments, in order.
|
|
616
630
|
*/
|
|
617
631
|
function locatedSegments(command) {
|
|
618
632
|
const out = [];
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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:
|
|
628
|
-
end:
|
|
629
|
-
between:
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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({
|
|
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({
|
|
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 {
|
|
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.
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
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
|
|
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
|
-
(
|
|
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}. ${
|
|
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,
|
|
2335
|
-
*
|
|
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'))
|