@molecule/api-ai-tools 1.10.1 → 1.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -15
- package/dist/no-test-run.d.ts +3 -1
- package/dist/no-test-run.d.ts.map +1 -1
- package/dist/no-test-run.js +114 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +64 -30
- package/dist/trace-artifacts.d.ts +65 -0
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +370 -29
- package/dist/types.d.ts +27 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/utilities.d.ts +114 -12
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +424 -33
- package/package.json +1 -1
package/dist/utilities.js
CHANGED
|
@@ -343,8 +343,9 @@ const PRINTING_REPORTERS = new Set(['list', 'line', 'dot', 'basic', 'spec']);
|
|
|
343
343
|
* inline the end-of-run repeat is suppressed with it (`epilogue(!printFailuresInline)`), so one
|
|
344
344
|
* reporter still prints each failure exactly once.
|
|
345
345
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
346
|
+
* Every background command is given this (see {@link streamingBackgroundCommand}), so a suite the
|
|
347
|
+
* executor starts through its own wrapper is covered too — the wrapper passes the environment on
|
|
348
|
+
* to the Playwright process it spawns. Without it the log's only failure lines are `✘` rows:
|
|
348
349
|
* X1 xr-molecule-flash-m99 (+19:27 → +21:00, 2026-10-16) was told to `grep -n -A 20 '<test name>'`
|
|
349
350
|
* the log, got nothing back through four greps, inferred causes from the spec prose instead, and
|
|
350
351
|
* edited `app/src` while the run was still walking — which voids the run.
|
|
@@ -398,10 +399,12 @@ function isStatusOnlyStep(step) {
|
|
|
398
399
|
* `junit`) is the
|
|
399
400
|
* executor's own and is left exactly as it is — so is any reporter this build does not know.
|
|
400
401
|
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
402
|
+
* Every background command that is not a no-test invocation is also given
|
|
403
|
+
* {@link PLAYWRIGHT_FAILURES_INLINE_ENV} (inert except for Playwright's `list` reporter), so each
|
|
404
|
+
* failure's own error is in the log as that test fails rather than only at the end: a suite that is
|
|
405
|
+
* still walking is exactly when the executor reads the log, and without it a running log carries
|
|
406
|
+
* `✘` rows and nothing else (xr-molecule-flash-m99 spent ~90 s on greps of one, then fixed what it
|
|
407
|
+
* guessed; m191's wrapper-launched run lost 6.5 minutes to the same shape).
|
|
405
408
|
*
|
|
406
409
|
* Only the step that runs the suite is touched, and only its reporter, and only when that step
|
|
407
410
|
* runs something: `npx playwright test --list` takes two seconds and writes nothing per test, so
|
|
@@ -604,16 +607,26 @@ export function streamingBackgroundCommand(command, context) {
|
|
|
604
607
|
changes.push('Added `--reporter=list` so each test result is written to the log as it finishes ' +
|
|
605
608
|
'(the config reporter may print nothing until the end).');
|
|
606
609
|
}
|
|
607
|
-
// The run's failures, in the log as they fail (see PLAYWRIGHT_FAILURES_INLINE_ENV)
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
610
|
+
// The run's failures, in the log as they fail (see PLAYWRIGHT_FAILURES_INLINE_ENV), on EVERY
|
|
611
|
+
// background command that is not a no-test invocation — not only the ones whose text this
|
|
612
|
+
// reader can place the runner in. The executor starts the suite through its own wrapper often
|
|
613
|
+
// (`node scripts/run-realworld-e2e.mjs`, `npm run e2e`), and the wrapper passes its environment
|
|
614
|
+
// on to the Playwright process it spawns; keying the switch on a recognized `playwright test`
|
|
615
|
+
// step withheld it from exactly those runs, whose logs then carried `✘` rows and nothing else
|
|
616
|
+
// while the executor waited (xr-molecule-flash-m191 +12:20 → +19:40: 65 failures and ONE cause,
|
|
617
|
+
// named at the last failure instead of at the third — 6.5 minutes lost). It is inert for
|
|
618
|
+
// anything but Playwright's `list` reporter, which is the only reader of it.
|
|
619
|
+
//
|
|
620
|
+
// Set as an `export` at the head of the whole command, never mid-command: the last step may be
|
|
621
|
+
// a wrapper (`timeout 280 npx playwright test`) or start a line of its own (a multi-line
|
|
622
|
+
// command's last line), and an assignment written in front of either would be read as the
|
|
623
|
+
// program to run.
|
|
624
|
+
out = `export ${PLAYWRIGHT_FAILURES_INLINE_ENV}=1; ${out}`;
|
|
625
|
+
// The sentence is only true of a run this reader put on `list` — a command it cannot place (a
|
|
626
|
+
// wrapper, `npm run build`) gets the switch silently, and that is all it needs.
|
|
613
627
|
const endsOnListReporter = finalPlaywrightRun &&
|
|
614
628
|
(reporters === null || reporters.values.some((v) => PRINTING_REPORTERS.has(v)));
|
|
615
629
|
if (endsOnListReporter) {
|
|
616
|
-
out = `export ${PLAYWRIGHT_FAILURES_INLINE_ENV}=1; ${out}`;
|
|
617
630
|
changes.push('Each failure’s own error is now written into the log as that test fails, not only in the ' +
|
|
618
631
|
'summary at the end — so a run that is still walking already says what its failures are, ' +
|
|
619
632
|
'and you need not wait for it to end to start on them.');
|
|
@@ -809,6 +822,61 @@ export function suiteRunBudgetCommand(command, flags) {
|
|
|
809
822
|
}
|
|
810
823
|
return { command: out, placed, unplaced };
|
|
811
824
|
}
|
|
825
|
+
/** The config flag and the separator before its value, as a run may write it. */
|
|
826
|
+
const CONFIG_ARGUMENT = /(?:^|[\s;&|(])(?:--config|-c)(?:=|\s+)/g;
|
|
827
|
+
/**
|
|
828
|
+
* A command with the derived config put on the LAST suite runner's own arguments, replacing the
|
|
829
|
+
* config the run names or added when it names none.
|
|
830
|
+
*
|
|
831
|
+
* Some of the suite's settings have no Playwright flag (`expect.timeout`, `use.actionTimeout`,
|
|
832
|
+
* `use.navigationTimeout`), so a config the executor wrote cannot be corrected from the command
|
|
833
|
+
* line. The consumer writes a derived config OUTSIDE the supplied suite that re-imports the one
|
|
834
|
+
* the run selected and pins those settings ({@link SuiteBudgetPin.config}); pointing the runner at
|
|
835
|
+
* it is the one way to carry them, and it belongs on the runner itself for the same reasons
|
|
836
|
+
* {@link suiteRunBudgetCommand}'s flags do (last suite step, before a pipe or group closer).
|
|
837
|
+
*
|
|
838
|
+
* A command whose runner cannot be placed is returned exactly as written with `placed: false`, so
|
|
839
|
+
* the caller never claims a config it did not point the run at.
|
|
840
|
+
*
|
|
841
|
+
* @param command - The shell command about to run.
|
|
842
|
+
* @param config - The config to run, as the runner takes it (`--config <path>`).
|
|
843
|
+
* @returns The command to run and whether the config was placed.
|
|
844
|
+
*/
|
|
845
|
+
export function suiteRunConfigCommand(command, config) {
|
|
846
|
+
const run = suiteRunnerArguments(command);
|
|
847
|
+
if (run === null)
|
|
848
|
+
return { command, placed: false };
|
|
849
|
+
const text = command.slice(run.start, run.at);
|
|
850
|
+
CONFIG_ARGUMENT.lastIndex = 0;
|
|
851
|
+
let last = null;
|
|
852
|
+
for (let m = CONFIG_ARGUMENT.exec(text); m !== null; m = CONFIG_ARGUMENT.exec(text)) {
|
|
853
|
+
const start = m.index + m[0].length;
|
|
854
|
+
const quote = text[start];
|
|
855
|
+
if (quote === '"' || quote === "'") {
|
|
856
|
+
const close = text.indexOf(quote, start + 1);
|
|
857
|
+
if (close === -1)
|
|
858
|
+
continue;
|
|
859
|
+
last = { start: start + 1, end: close };
|
|
860
|
+
// The value may not continue into a second quoted word: resume after the closer.
|
|
861
|
+
CONFIG_ARGUMENT.lastIndex = close + 1;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
const value = /^[^\s;&|)]*/.exec(text.slice(start))?.[0] ?? '';
|
|
865
|
+
if (value === '')
|
|
866
|
+
continue;
|
|
867
|
+
last = { start, end: start + value.length };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (last === null) {
|
|
871
|
+
return {
|
|
872
|
+
command: `${command.slice(0, run.at)} --config=${config}${command.slice(run.at)}`,
|
|
873
|
+
placed: true,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const from = run.start + last.start;
|
|
877
|
+
const to = run.start + last.end;
|
|
878
|
+
return { command: `${command.slice(0, from)}${config}${command.slice(to)}`, placed: true };
|
|
879
|
+
}
|
|
812
880
|
/**
|
|
813
881
|
* The command with the `timeout <N>` wrapper lifted off the statement that runs the suite, and the
|
|
814
882
|
* bound that was lifted.
|
|
@@ -2287,6 +2355,40 @@ export function repeatingFailure(log) {
|
|
|
2287
2355
|
const mean = best.reduce((sum, t) => sum + t.seconds, 0) / best.length;
|
|
2288
2356
|
return { count: best.length, message: null, seconds: Math.round(mean * 10) / 10, tests: best };
|
|
2289
2357
|
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Whether the log read so far carries a failure's own body under a `✘` row — the evidence that a
|
|
2360
|
+
* run prints each failure's error as that test fails ({@link PLAYWRIGHT_FAILURES_INLINE_ENV}).
|
|
2361
|
+
*
|
|
2362
|
+
* `wait_for_task` promises the executor that each failure's report sits right under its `✘` line
|
|
2363
|
+
* only when the log proves it. The command's shape cannot answer: the launch sets the switch on
|
|
2364
|
+
* every background command (the executor's wrapper script inherits it), so a wrapper this reader
|
|
2365
|
+
* cannot place is exactly as covered as `npx playwright test` ({@link streamingBackgroundCommand}).
|
|
2366
|
+
* The log is the evidence — a `1) [project] › spec › title ───` block whose test the log also
|
|
2367
|
+
* printed with `✘` ({@link failureBlocks}).
|
|
2368
|
+
*
|
|
2369
|
+
* A block alone is not enough: a FINISHED run's end-of-run summary carries the same blocks, and a
|
|
2370
|
+
* running wait must not be told "as it fails" about a log whose bodies only appear at the end.
|
|
2371
|
+
* During a wait the run has not ended (the exit file is read first), so a block beside a `✘` row
|
|
2372
|
+
* is one the reporter printed inline.
|
|
2373
|
+
*
|
|
2374
|
+
* @param log - A log, or the slice of one a wait reads.
|
|
2375
|
+
* @returns True when a `✘` row's own test has a failure body under it.
|
|
2376
|
+
*/
|
|
2377
|
+
export function logShowsInlineFailureBodies(log) {
|
|
2378
|
+
const text = log.replace(ANSI_SGR, '');
|
|
2379
|
+
const failed = new Set(distinctTests(failedTests(text)).map(retrylessTestKey));
|
|
2380
|
+
if (failed.size === 0)
|
|
2381
|
+
return false;
|
|
2382
|
+
for (const block of failureBlocks(text)) {
|
|
2383
|
+
const m = FAILURE_BLOCK_HEADER.exec(block.split('\n')[0] ?? '');
|
|
2384
|
+
if (m === null)
|
|
2385
|
+
continue;
|
|
2386
|
+
const { where, title } = splitTitlePath(m[1] ?? '');
|
|
2387
|
+
if (failed.has(retrylessTestKey({ where, title, seconds: null })))
|
|
2388
|
+
return true;
|
|
2389
|
+
}
|
|
2390
|
+
return false;
|
|
2391
|
+
}
|
|
2290
2392
|
/** How many of a repeating cause's failures the note lists before it says "and N more". */
|
|
2291
2393
|
export const REPEATING_FAILURE_LISTED = 5;
|
|
2292
2394
|
/**
|
|
@@ -2392,11 +2494,12 @@ export const UNIVERSAL_CAUSE_FILES = 3;
|
|
|
2392
2494
|
* (m177: 18 tests, ~4.2 minutes, on a page whose thrown error was visible in the first
|
|
2393
2495
|
* `pageerror`). Three is the smallest count that is not one page's own bug: the cause additionally
|
|
2394
2496
|
* needs three DISTINCT spec files (`UNIVERSAL_CAUSE_FILES`, or the files a narrowed run selected),
|
|
2395
|
-
*
|
|
2396
|
-
*
|
|
2397
|
-
*
|
|
2398
|
-
*
|
|
2399
|
-
* about one test's worth
|
|
2497
|
+
* no failure showing a locator RESOLVING ({@link ELEMENT_FOUND} — a blank page cannot resolve
|
|
2498
|
+
* one), and no test passing after the first failure. A page that renders misses elements, but
|
|
2499
|
+
* three unrelated specs in three files failing at three different locators that never resolved,
|
|
2500
|
+
* with nothing passing in between, is the page, not three bugs. At the suite's own 5 s action
|
|
2501
|
+
* timeout that verdict lands ~20 s into the walk, so the early stop costs about one test's worth
|
|
2502
|
+
* of time instead of fifteen.
|
|
2400
2503
|
*/
|
|
2401
2504
|
export const EMPTY_PAGE_CAUSE_TESTS = 3;
|
|
2402
2505
|
/**
|
|
@@ -2406,6 +2509,19 @@ export const EMPTY_PAGE_CAUSE_TESTS = 3;
|
|
|
2406
2509
|
* test's own code — is a different failure and leaves this reading nothing.
|
|
2407
2510
|
*/
|
|
2408
2511
|
const LOCATOR_WAIT = /waiting for locator\(|element\(s\) not found|toBeVisible\(\) failed/;
|
|
2512
|
+
/**
|
|
2513
|
+
* A failure body that shows the page PAINTED: Playwright's call log RESOLVED the locator to an
|
|
2514
|
+
* element (`locator resolved to <i …>`, `resolved to 2 elements`) and only then found it
|
|
2515
|
+
* unusable (`element is not visible` / `not enabled` / `not stable` / `not attached`, `outside
|
|
2516
|
+
* of the viewport`). A blank page cannot resolve a locator, so one of these anywhere in a run
|
|
2517
|
+
* disproves {@link emptyPageCause}.
|
|
2518
|
+
*
|
|
2519
|
+
* xr-molecule-flash-m187 (+15:39 and +18:54) narrowed two re-runs to the tests that had just
|
|
2520
|
+
* failed, and both were stopped as "the app is rendering NOTHING" while every block in the log
|
|
2521
|
+
* carried `locator resolved to <i class="ion-close-circled"></i>` followed by `element is not
|
|
2522
|
+
* visible` — the page was drawn, and only an icon had no box.
|
|
2523
|
+
*/
|
|
2524
|
+
const ELEMENT_FOUND = /locator resolved to (?:<|[1-9]\d* elements?\b)|element is (?:not (?:visible|enabled|stable|attached)\b|outside of the viewport)/;
|
|
2409
2525
|
/** An uncaught page error, as a run that listens for `pageerror` prints it. */
|
|
2410
2526
|
const PAGE_ERROR_LINE = /^\s*(?:\[pageerror\]|pageerror:)\s*(.+)$/m;
|
|
2411
2527
|
/** An uncaught page error, in the browser's own `Uncaught …` wording. */
|
|
@@ -2425,7 +2541,18 @@ function pageErrorInLog(text) {
|
|
|
2425
2541
|
* The "nothing has passed and nothing renders" cause: the run's FIRST consecutive failures — from
|
|
2426
2542
|
* its first failed test, with no pass since — are all locator waits, at least
|
|
2427
2543
|
* {@link EMPTY_PAGE_CAUSE_TESTS} of them across {@link UNIVERSAL_CAUSE_FILES} different spec
|
|
2428
|
-
* files, and
|
|
2544
|
+
* files, and NO failure block shows the page resolving the locator it waited for
|
|
2545
|
+
* ({@link ELEMENT_FOUND}).
|
|
2546
|
+
*
|
|
2547
|
+
* "No locator resolved" is the check that says the page painted nothing; it replaced an
|
|
2548
|
+
* earlier claim that each failure waits for its own spec's FIRST element, which the code never
|
|
2549
|
+
* verified and the log alone cannot verify (Playwright prints only the failing line, not the
|
|
2550
|
+
* spec's first one). A narrowed re-run of known failures meets every other condition by
|
|
2551
|
+
* construction — every test in it failed last time at a locator wait — so the page-painted
|
|
2552
|
+
* evidence is what keeps it from reading as an empty page. When the command names the
|
|
2553
|
+
* individual tests (`file:line`), a re-run of exactly those is also skipped
|
|
2554
|
+
* ({@link selectedTestRefs}): the executor is verifying fixes, and the stop would throw away
|
|
2555
|
+
* the verdict the re-run exists to give.
|
|
2429
2556
|
*
|
|
2430
2557
|
* The file span stays at {@link UNIVERSAL_CAUSE_FILES} whatever the run selected: "the page
|
|
2431
2558
|
* renders nothing" is a claim about the APP, and one file's failures can be that file's own setup
|
|
@@ -2445,9 +2572,11 @@ function pageErrorInLog(text) {
|
|
|
2445
2572
|
*
|
|
2446
2573
|
* @param text - The log (ANSI already stripped).
|
|
2447
2574
|
* @param found - The repeating cause {@link repeatingFailure} found, when it found one.
|
|
2575
|
+
* @param command - How the run was started, when the caller knows it: a command naming the
|
|
2576
|
+
* individual tests it selects marks a re-run of known failures ({@link selectedTestRefs}).
|
|
2448
2577
|
* @returns The cause, or null when the log is not this shape.
|
|
2449
2578
|
*/
|
|
2450
|
-
function emptyPageCause(text, found) {
|
|
2579
|
+
function emptyPageCause(text, found, command) {
|
|
2451
2580
|
const marks = resultMarks(text);
|
|
2452
2581
|
const firstFailure = marks.indexOf('✘');
|
|
2453
2582
|
if (firstFailure === -1 || marks.slice(firstFailure).includes('✓'))
|
|
@@ -2455,12 +2584,26 @@ function emptyPageCause(text, found) {
|
|
|
2455
2584
|
const blocks = failureBlocks(text);
|
|
2456
2585
|
if (blocks.length === 0 || !blocks.every((block) => LOCATOR_WAIT.test(block)))
|
|
2457
2586
|
return null;
|
|
2587
|
+
// A blank page cannot RESOLVE a locator: `locator resolved to …` (or an actionability outcome
|
|
2588
|
+
// like `element is not visible`) means the page painted and only the element's box or
|
|
2589
|
+
// visibility was wrong. xr-molecule-flash-m187 stopped two narrowed re-runs on exactly this.
|
|
2590
|
+
if (blocks.some((block) => ELEMENT_FOUND.test(block)))
|
|
2591
|
+
return null;
|
|
2458
2592
|
// Both readings of the same failures: a `list` line carries the test and its duration, a
|
|
2459
2593
|
// numbered block carries its body. The larger is the count — a failure that has a block but
|
|
2460
2594
|
// whose result line the poll had not reached yet is still a failure.
|
|
2461
2595
|
const byList = distinctTests(failedTests(text));
|
|
2462
2596
|
const byBlock = distinctTests(failureBlockTests(text));
|
|
2463
2597
|
const tests = byBlock.length >= byList.length ? byBlock : byList;
|
|
2598
|
+
// A run whose command names individual tests, and whose every failure is one of them, is a
|
|
2599
|
+
// re-run of known failures, never an empty-page stop: the executor is checking fixes it just
|
|
2600
|
+
// made, and stopping it throws away that verdict (xr-molecule-flash-m187, twice).
|
|
2601
|
+
const selected = command ? selectedTestRefs(command) : null;
|
|
2602
|
+
if (selected !== null &&
|
|
2603
|
+
tests.length > 0 &&
|
|
2604
|
+
tests.every((test) => selected.some((ref) => sameTestRef(ref, test.where)))) {
|
|
2605
|
+
return null;
|
|
2606
|
+
}
|
|
2464
2607
|
// One signature already covers every failure: that is the signature cause, and its wording
|
|
2465
2608
|
// (which names the repeated error) says more than "the page renders nothing" would.
|
|
2466
2609
|
if (found !== null && found.message !== null && found.count >= tests.length)
|
|
@@ -2572,6 +2715,62 @@ export function specFilesSelected(command) {
|
|
|
2572
2715
|
}
|
|
2573
2716
|
return null;
|
|
2574
2717
|
}
|
|
2718
|
+
/**
|
|
2719
|
+
* The individual tests a `playwright test` command names (`file:line` positionals), or null when
|
|
2720
|
+
* it selects whole files, a directory, a glob, a bare filter, or nothing at all.
|
|
2721
|
+
*
|
|
2722
|
+
* A command that names `file:line` tests is a re-run of tests that already failed — the shape
|
|
2723
|
+
* {@link emptyPageCause} must not read as "the page renders nothing", because the executor is
|
|
2724
|
+
* verifying fixes and the stop throws that verdict away (xr-molecule-flash-m187, twice).
|
|
2725
|
+
*
|
|
2726
|
+
* @param command - The run's command.
|
|
2727
|
+
* @returns The selected test references, or null when the selection is not per-test.
|
|
2728
|
+
*/
|
|
2729
|
+
function selectedTestRefs(command) {
|
|
2730
|
+
for (const segment of commandSegments(command)) {
|
|
2731
|
+
if (!/^playwright\s+test\b/.test(segment.text))
|
|
2732
|
+
continue;
|
|
2733
|
+
const refs = [];
|
|
2734
|
+
let flagValue = false;
|
|
2735
|
+
for (const word of segment.args.slice(1)) {
|
|
2736
|
+
const token = word.replace(/^(['"])(.*)\1$/, '$2');
|
|
2737
|
+
if (flagValue) {
|
|
2738
|
+
flagValue = false;
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
if (token.startsWith('-')) {
|
|
2742
|
+
const flag = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
|
|
2743
|
+
flagValue = !token.includes('=') && PLAYWRIGHT_VALUE_FLAGS.has(flag);
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2746
|
+
const m = /^(.*\.(?:spec|test)\.[cm]?[jt]sx?):(\d+)(?::\d+)?$/.exec(token);
|
|
2747
|
+
if (!m)
|
|
2748
|
+
return null;
|
|
2749
|
+
refs.push({ path: m[1], line: Number(m[2]) });
|
|
2750
|
+
}
|
|
2751
|
+
return refs.length > 0 ? refs : null;
|
|
2752
|
+
}
|
|
2753
|
+
return null;
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Whether a failing test (`file:line`, as the log printed it) is the test one of the command's
|
|
2757
|
+
* `file:line` selectors names.
|
|
2758
|
+
*
|
|
2759
|
+
* Paths are compared by suffix: the log prints the path from the suite root
|
|
2760
|
+
* (`fixtures/conduit-spec/specs/e2e/articles.spec.ts:229`) while the command names it from its
|
|
2761
|
+
* own cwd (`articles.spec.ts:229`), so neither is a prefix of the other.
|
|
2762
|
+
*
|
|
2763
|
+
* @param ref - One selected test reference.
|
|
2764
|
+
* @param where - The failed test's `file:line` (see {@link splitTitlePath}).
|
|
2765
|
+
* @returns True when they name the same test.
|
|
2766
|
+
*/
|
|
2767
|
+
function sameTestRef(ref, where) {
|
|
2768
|
+
const m = /^(.*\.(?:spec|test)\.[cm]?[jt]sx?):(\d+)$/.exec(where);
|
|
2769
|
+
if (!m || Number(m[2]) !== ref.line)
|
|
2770
|
+
return false;
|
|
2771
|
+
const path = m[1];
|
|
2772
|
+
return path === ref.path || path.endsWith(`/${ref.path}`) || ref.path.endsWith(`/${path}`);
|
|
2773
|
+
}
|
|
2575
2774
|
/**
|
|
2576
2775
|
* Whether a repeating cause is failing the whole run — the one case where stopping it is the
|
|
2577
2776
|
* right call.
|
|
@@ -2608,9 +2807,9 @@ export function universalCause(log, found, command) {
|
|
|
2608
2807
|
// can never span three of them, however whole its failure is.
|
|
2609
2808
|
const selected = command ? specFilesSelected(command) : null;
|
|
2610
2809
|
const threshold = selected === null ? UNIVERSAL_CAUSE_FILES : Math.min(UNIVERSAL_CAUSE_FILES, selected);
|
|
2611
|
-
// Nothing has passed and nothing renders: EVERY spec so far is waiting for
|
|
2612
|
-
//
|
|
2613
|
-
const emptyPage = emptyPageCause(text, found);
|
|
2810
|
+
// Nothing has passed and nothing renders: EVERY spec so far is waiting for an element the
|
|
2811
|
+
// page never painted, so the signatures differ while the cause does not (xr-molecule-flash-m141).
|
|
2812
|
+
const emptyPage = emptyPageCause(text, found, command);
|
|
2614
2813
|
if (emptyPage)
|
|
2615
2814
|
return emptyPage;
|
|
2616
2815
|
if (!found)
|
|
@@ -2957,6 +3156,178 @@ function noBaseUrlConfigNote(causes, run, excluded) {
|
|
|
2957
3156
|
'with `--config`; this command names none. ') +
|
|
2958
3157
|
(command ? `Send exactly this: \`${command}\`` : ''));
|
|
2959
3158
|
}
|
|
3159
|
+
/**
|
|
3160
|
+
* A selector that names the page's navigation landmark: the `nav` tag, a `navbar`/`nav-*` class,
|
|
3161
|
+
* or `role=navigation`. `a.navbar-brand` names it through its class — the brand link is the
|
|
3162
|
+
* navbar's own child in every theme this reader has seen.
|
|
3163
|
+
*/
|
|
3164
|
+
const NAVIGATION_SELECTOR = /(?:^|[\s>+~,(])nav(?=$|[\s.#:[>+~,)])|\.navbar(?:-[a-z-]+)?(?![\w-])|\[role\s*=\s*['"]?navigation['"]?\]/i;
|
|
3165
|
+
/** A selector that names the page's header landmark: the `header` tag or `role=banner`. */
|
|
3166
|
+
const HEADER_SELECTOR = /(?:^|[\s>+~,(])header(?=$|[\s.#:[>+~,)])|\[role\s*=\s*['"]?banner['"]?\]/i;
|
|
3167
|
+
/**
|
|
3168
|
+
* The quoted argument of each `locator(...)` call in a failure block. Both quote styles are
|
|
3169
|
+
* matched separately: a selector routinely carries the OTHER quote inside it
|
|
3170
|
+
* (`locator('nav a[href="/"]')`), which one shared backreferenced class would cut short.
|
|
3171
|
+
*/
|
|
3172
|
+
const QUOTED_LOCATORS = [/locator\(\s*'([^']*)'/g, /locator\(\s*"([^"]*)"/g];
|
|
3173
|
+
/** The role a `getByRole(...)` call names, when the role is a landmark this reader knows. */
|
|
3174
|
+
const QUOTED_ROLE = /getByRole\(\s*(['"])(navigation|banner)\1/gi;
|
|
3175
|
+
/** The path an `[href="…"]` / `[to="…"]` filter names, when it is a root-relative route. */
|
|
3176
|
+
const HREF_FILTER = /\[(?:href|to)\s*=\s*(['"])([^'"]+)\1\]/g;
|
|
3177
|
+
/**
|
|
3178
|
+
* The landmark a selector requires as an ANCESTOR (or names as its own role), or null when it
|
|
3179
|
+
* names no landmark at all (`h1`, `.article-preview`, `a[href="/profile/x"]`).
|
|
3180
|
+
*
|
|
3181
|
+
* @param selector - One CSS selector or `getByRole` role from a failure's call log.
|
|
3182
|
+
* @returns The landmark, or null.
|
|
3183
|
+
*/
|
|
3184
|
+
export function requiredLandmark(selector) {
|
|
3185
|
+
if (NAVIGATION_SELECTOR.test(selector))
|
|
3186
|
+
return 'navigation';
|
|
3187
|
+
if (HEADER_SELECTOR.test(selector))
|
|
3188
|
+
return 'banner';
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* The route a selector names through an `[href="…"]`/`[to="…"]` filter (`nav a[href="/"]` → `/`),
|
|
3193
|
+
* or null when it names none. The query string and hash are dropped: the route is the path.
|
|
3194
|
+
*
|
|
3195
|
+
* @param selector - One CSS selector from a failure's call log.
|
|
3196
|
+
* @returns The path, or null.
|
|
3197
|
+
*/
|
|
3198
|
+
function routeNamedBySelector(selector) {
|
|
3199
|
+
for (const match of selector.matchAll(HREF_FILTER)) {
|
|
3200
|
+
const value = match[2] ?? '';
|
|
3201
|
+
if (!value.startsWith('/') || value.startsWith('//'))
|
|
3202
|
+
continue;
|
|
3203
|
+
const path = value.split(/[?#]/)[0] ?? value;
|
|
3204
|
+
return path === '' ? '/' : path;
|
|
3205
|
+
}
|
|
3206
|
+
return null;
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* The failures whose own selector requires a landmark — the rows {@link missingHeaderNote}
|
|
3210
|
+
* compares against their page snapshots.
|
|
3211
|
+
*
|
|
3212
|
+
* @param text - The run's output, ANSI already stripped.
|
|
3213
|
+
* @returns The failures, in the order the run printed them.
|
|
3214
|
+
*/
|
|
3215
|
+
function missingLandmarkFailures(text) {
|
|
3216
|
+
const out = [];
|
|
3217
|
+
for (const block of failureBlocks(text)) {
|
|
3218
|
+
const header = FAILURE_BLOCK_HEADER.exec(block.split('\n')[0] ?? '');
|
|
3219
|
+
const { where } = splitTitlePath(header?.[1] ?? '');
|
|
3220
|
+
const selectors = [];
|
|
3221
|
+
// The block's own words, without the code frame: a `12 | await page.locator('header')` line
|
|
3222
|
+
// is the spec's SOURCE beside the failure, not the locator it waited for.
|
|
3223
|
+
const words = failureBody(block).join('\n');
|
|
3224
|
+
for (const pattern of QUOTED_LOCATORS)
|
|
3225
|
+
for (const match of words.matchAll(pattern))
|
|
3226
|
+
selectors.push(match[1]);
|
|
3227
|
+
for (const match of words.matchAll(QUOTED_ROLE))
|
|
3228
|
+
selectors.push(`[role="${match[2]}"]`);
|
|
3229
|
+
const distinct = [...new Set(selectors)];
|
|
3230
|
+
const needing = distinct.filter((selector) => requiredLandmark(selector) !== null);
|
|
3231
|
+
if (needing.length === 0)
|
|
3232
|
+
continue;
|
|
3233
|
+
// Navigation outranks banner for a selector that names both (a `header nav …` chain): the
|
|
3234
|
+
// finding's own sentence is about the missing navigation.
|
|
3235
|
+
const landmark = needing.some((selector) => requiredLandmark(selector) === 'navigation')
|
|
3236
|
+
? 'navigation'
|
|
3237
|
+
: 'banner';
|
|
3238
|
+
const route = needing.map(routeNamedBySelector).find((candidate) => candidate !== null) ?? null;
|
|
3239
|
+
out.push({ where, landmark, selectors: needing, route });
|
|
3240
|
+
}
|
|
3241
|
+
return out;
|
|
3242
|
+
}
|
|
3243
|
+
/**
|
|
3244
|
+
* The snapshot a failure's own `error-context.md` produced, keyed the way the log and the
|
|
3245
|
+
* artifact both spell the test's location. The log writes the path relative to the command's
|
|
3246
|
+
* cwd and the artifact read keys it the same way, but a caller that shortened one side is
|
|
3247
|
+
* matched on its tail rather than losing the reading.
|
|
3248
|
+
*
|
|
3249
|
+
* @param where - The failure's location.
|
|
3250
|
+
* @param pageErrors - The snapshots the caller read.
|
|
3251
|
+
* @returns The entry, or undefined when this test has none.
|
|
3252
|
+
*/
|
|
3253
|
+
function pageSnapshotFor(where, pageErrors) {
|
|
3254
|
+
const exact = pageErrors.find((entry) => entry.where === where);
|
|
3255
|
+
if (exact !== undefined)
|
|
3256
|
+
return exact;
|
|
3257
|
+
return pageErrors.find((entry) => (where !== '' && entry.where.endsWith(`/${where}`)) ||
|
|
3258
|
+
(entry.where !== '' && where.endsWith(`/${entry.where}`)));
|
|
3259
|
+
}
|
|
3260
|
+
/** How the sentence names the landmark: the finding's words, one per role. */
|
|
3261
|
+
const LANDMARK_WORD = {
|
|
3262
|
+
navigation: 'navigation',
|
|
3263
|
+
banner: 'header',
|
|
3264
|
+
};
|
|
3265
|
+
/**
|
|
3266
|
+
* The sentence for a run whose navigational elements are all missing from pages that otherwise
|
|
3267
|
+
* painted — the missing shared header the executor must check BEFORE rewriting auth.
|
|
3268
|
+
*
|
|
3269
|
+
* xr-molecule-flash-m193 (+13:54 → +20:00): four failures on `/` — `a[href="/profile/…"]`,
|
|
3270
|
+
* `a.navbar-brand`, `nav a[href="/"]`, `nav .user-pic` — while every `error-context.md` snapshot
|
|
3271
|
+
* showed the feed, sidebar and footer with no navigation at all. `Home.tsx` imported and
|
|
3272
|
+
* re-exported `Navbar` but never rendered it, and `App.tsx` mounted `<Home />` in a bare route.
|
|
3273
|
+
* The cause list named four unrelated symptoms, so the run rewrote `Navbar`'s auth logic and then
|
|
3274
|
+
* the HTTP bond across two whole walks (~6 minutes) instead of mounting the header.
|
|
3275
|
+
*
|
|
3276
|
+
* The reading is per FAILURE and per SNAPSHOT: each failure's own selector must require the
|
|
3277
|
+
* landmark, its own snapshot must have been read AND show populated page content, and that
|
|
3278
|
+
* snapshot must not contain the landmark. Two or more such failures on ONE route (the route a
|
|
3279
|
+
* selector names, e.g. `nav a[href="/"]` → `/`; failures whose selector names none ride along)
|
|
3280
|
+
* are the group. A page that shows the navigation and merely misses a signed-in link is not this
|
|
3281
|
+
* shape — its snapshots contain the landmark, so nothing is said.
|
|
3282
|
+
*
|
|
3283
|
+
* This never stops the run and never claims the page is blank: it is one sentence prepended to
|
|
3284
|
+
* the causes note ({@link failureCausesNote}).
|
|
3285
|
+
*
|
|
3286
|
+
* @param log - The run's output.
|
|
3287
|
+
* @param pageErrors - The page snapshot facts the caller read per failing test.
|
|
3288
|
+
* @returns The sentence, or null when the failures do not share a missing landmark.
|
|
3289
|
+
*/
|
|
3290
|
+
export function missingHeaderNote(log, pageErrors = []) {
|
|
3291
|
+
if (pageErrors.length === 0)
|
|
3292
|
+
return null;
|
|
3293
|
+
const failures = missingLandmarkFailures(log.replace(ANSI_SGR, ''));
|
|
3294
|
+
for (const landmark of ['navigation', 'banner']) {
|
|
3295
|
+
const needing = failures.filter((failure) => failure.landmark === landmark);
|
|
3296
|
+
if (needing.length < 2)
|
|
3297
|
+
continue;
|
|
3298
|
+
// One route is what the sentence can name; two named routes are two pages, not one group.
|
|
3299
|
+
const routes = [...new Set(needing.map((failure) => failure.route).filter(Boolean))];
|
|
3300
|
+
if (routes.length !== 1)
|
|
3301
|
+
continue;
|
|
3302
|
+
const route = routes[0];
|
|
3303
|
+
const group = needing.filter((failure) => failure.route === null || failure.route === route);
|
|
3304
|
+
if (group.length < 2)
|
|
3305
|
+
continue;
|
|
3306
|
+
// Every snapshot in the group must have been READ, and each must show populated content
|
|
3307
|
+
// without the landmark. A snapshot that is missing, blank, or carries the landmark leaves
|
|
3308
|
+
// the reading unproven.
|
|
3309
|
+
const proven = group.every((failure) => {
|
|
3310
|
+
const snapshot = pageSnapshotFor(failure.where, pageErrors);
|
|
3311
|
+
if (snapshot === undefined || snapshot.landmarks === undefined)
|
|
3312
|
+
return false;
|
|
3313
|
+
if (snapshot.populated !== true)
|
|
3314
|
+
return false;
|
|
3315
|
+
return !snapshot.landmarks.includes(landmark);
|
|
3316
|
+
});
|
|
3317
|
+
if (!proven)
|
|
3318
|
+
continue;
|
|
3319
|
+
const selectors = [...new Set(group.flatMap((failure) => failure.selectors))];
|
|
3320
|
+
const named = selectors.map((selector) => `\`${selector}\``);
|
|
3321
|
+
const list = named.length <= 1
|
|
3322
|
+
? (named[0] ?? '')
|
|
3323
|
+
: `${named.slice(0, -1).join(', ')} and ${named[named.length - 1]}`;
|
|
3324
|
+
return (`The \`${route}\` snapshots contain populated main content but no ` +
|
|
3325
|
+
`${LANDMARK_WORD[landmark]} landmark. These ${list} failures share the missing header; ` +
|
|
3326
|
+
`check whether the route actually renders its shared header before changing authentication ` +
|
|
3327
|
+
`or the header component’s internals.`);
|
|
3328
|
+
}
|
|
3329
|
+
return null;
|
|
3330
|
+
}
|
|
2960
3331
|
/**
|
|
2961
3332
|
* The one-line-per-cause summary of a finished run's failures, or null when the run named fewer
|
|
2962
3333
|
* than two of them.
|
|
@@ -2984,7 +3355,14 @@ function noBaseUrlConfigNote(causes, run, excluded) {
|
|
|
2984
3355
|
* the "left out" sentence greps when more causes exist than the result has room for.
|
|
2985
3356
|
* @returns The note, or null.
|
|
2986
3357
|
*/
|
|
2987
|
-
export function failureCausesNote(log, excluded = [], run, pageErrors = []
|
|
3358
|
+
export function failureCausesNote(log, excluded = [], run, pageErrors = [],
|
|
3359
|
+
/**
|
|
3360
|
+
* Spec files whose selector-contract rows an edit broke since the last whole walk, when the
|
|
3361
|
+
* caller knows them: the narrowed re-run names them beside the failing tests. A green re-run of
|
|
3362
|
+
* the failure alone would certify a tree it never measured, and the specs selecting the
|
|
3363
|
+
* element the edit removed were never walked (xr-molecule-flash-m187 shipped that).
|
|
3364
|
+
*/
|
|
3365
|
+
regressedSpecs = []) {
|
|
2988
3366
|
const { causes, ignored, flaky } = groupFailures(log, excluded, pageErrors);
|
|
2989
3367
|
const total = causes.reduce((n, c) => n + c.tests.length, 0);
|
|
2990
3368
|
if (total < 2)
|
|
@@ -2997,8 +3375,14 @@ export function failureCausesNote(log, excluded = [], run, pageErrors = []) {
|
|
|
2997
3375
|
// (xr-molecule-flash-m101 +12:13 re-ran all 139, ~4 minutes, to check two). The command is the
|
|
2998
3376
|
// run's own invocation narrowed by the ONE composer the mid-run note also uses
|
|
2999
3377
|
// ({@link rerunOfRunningSuite}), with the request's exclusions inverted out so the re-run
|
|
3000
|
-
// cannot report them again.
|
|
3001
|
-
|
|
3378
|
+
// cannot report them again. The regressed specs ride the same set: a re-run of the failures
|
|
3379
|
+
// alone does not measure what an edit removed from the markup those specs select (m187).
|
|
3380
|
+
const locations = [
|
|
3381
|
+
...new Set([
|
|
3382
|
+
...causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean),
|
|
3383
|
+
...regressedSpecs,
|
|
3384
|
+
]),
|
|
3385
|
+
];
|
|
3002
3386
|
const rerun = run ? rerunOfRunningSuite(run, locations, excluded) : null;
|
|
3003
3387
|
// A document the browser could not load explains every failure at once, and it is not in the
|
|
3004
3388
|
// app: m118 (+37:09) read 14 `page.goto` timeouts as app failures while the page load had ended
|
|
@@ -3041,13 +3425,20 @@ export function failureCausesNote(log, excluded = [], run, pageErrors = []) {
|
|
|
3041
3425
|
// (2026-10-17). The root fix is to hand this function the whole log; this says so when a
|
|
3042
3426
|
// caller cannot.
|
|
3043
3427
|
const clipped = /^…\((\d+) earlier characters omitted\)/.exec(log);
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3428
|
+
// The missing-header reading leads the list: four unrelated locators failing while every
|
|
3429
|
+
// snapshot shows the page painted and un-navigated is ONE fact about the route, and the list
|
|
3430
|
+
// below would otherwise read as four separate defects (xr-molecule-flash-m193 rewrote the
|
|
3431
|
+
// navbar's auth logic and then the HTTP bond across two walks, ~6 minutes, for a page that
|
|
3432
|
+
// never rendered `<Navbar />`).
|
|
3433
|
+
const missingHeader = missingHeaderNote(log, pageErrors);
|
|
3434
|
+
const header = (missingHeader === null ? '' : `${missingHeader}\n`) +
|
|
3435
|
+
(clipped !== null
|
|
3436
|
+
? `${total} failure${total === 1 ? '' : 's'} in the LAST part of this run’s log — its ` +
|
|
3437
|
+
`first ${clipped[1]} characters were clipped, so a cause that failed earlier is ` +
|
|
3438
|
+
`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' : ''}, ` +
|
|
3439
|
+
`and read the whole run log before concluding:\n`
|
|
3440
|
+
: `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
|
|
3441
|
+
`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`);
|
|
3051
3442
|
// The flaky sentence sits before the ignored one: the ignored sentence is the LAST thing the
|
|
3052
3443
|
// note says, and callers assert on that ending.
|
|
3053
3444
|
const tail = (rerun ? ` Re-run just these once they are fixed: \`${rerun}\`` : '') +
|