@molecule/api-ai-tools 1.10.1 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/utilities.js CHANGED
@@ -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
- * A background suite is put on `list` (see {@link streamingBackgroundCommand}) and this with it, so
347
- * the log names its causes WHILE it walks. Without it the log's only failure lines are `✘` rows:
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
- * A run put on `list` is also given {@link PLAYWRIGHT_FAILURES_INLINE_ENV}, so each failure's own
402
- * error is in the log as that test fails rather than only at the end: a suite that is still walking
403
- * is exactly when the executor reads the log, and without it a running log carries `✘` rows and
404
- * nothing else (xr-molecule-flash-m99 spent ~90 s on greps of one, then fixed what it guessed).
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). Only for a
608
- // run that ends on `list` — a run whose only reporters are machine formats has no failure lines
609
- // to make inline, and this is inert for one — and set as an `export` at the head of the whole
610
- // command, never mid-command: the last step may be a wrapper (`timeout 280 npx playwright test`)
611
- // or start a line of its own (a multi-line command's last line), and an assignment written in
612
- // front of either would be read as the program to run.
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
- * each spec waiting for its OWN first element, and no test passing after the first failure. A page
2396
- * that renders misses elements, but three unrelated specs in three files failing at three
2397
- * different first locators with nothing passing in between is the page, not three bugs. At the
2398
- * suite's own 5 s action timeout that verdict lands ~20 s into the walk, so the early stop costs
2399
- * about one test's worth of time instead of fifteen.
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 each waits for its own spec's first element.
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 its own first
2612
- // element, so the signatures differ while the cause does not (xr-molecule-flash-m141).
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
- const locations = [...new Set(causes.flatMap((c) => c.tests.map((t) => t.where)).filter(Boolean))];
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
- const header = clipped !== null
3045
- ? `${total} failure${total === 1 ? '' : 's'} in the LAST part of this run’s log — its ` +
3046
- `first ${clipped[1]} characters were clipped, so a cause that failed earlier is ` +
3047
- `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' : ''}, ` +
3048
- `and read the whole run log before concluding:\n`
3049
- : `${total} failure${total === 1 ? '' : 's'} in this run, grouped by cause — fix every cause ` +
3050
- `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`;
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}\`` : '') +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",