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