@molecule/api-ai-tools 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@
29
29
  * @module
30
30
  */
31
31
  import { posix } from 'node:path';
32
- import { commandSegments, locatedCommandSegments } from './command-position.js';
32
+ import { commandSegments, locatedCommandSegments, statementBoundary, } from './command-position.js';
33
33
  import { commandRunsNoTests } from './no-test-run.js';
34
34
  import { matchingExcludedTitles, shellQuote, suiteRunStatements } from './utilities.js';
35
35
  /**
@@ -127,8 +127,165 @@ const FILE_REDIRECT = /(^|\s)(&>>?|1?>>?)(\s*)(?:'([^']+)'|"([^"]+)"|([^\s;&|]+)
127
127
  /** A target that is not a log anyone could lose: rewriting it would CREATE a file. */
128
128
  const DEVICE_TARGET = /^\/dev\//;
129
129
  /**
130
- * The command with every file redirect pointed at a fresh file beside its original target
131
- * (`/tmp/pw-full.log` → `/tmp/pw-full.rerun.log`), and nothing else changed.
130
+ * The command's words, quotes kept whole and with the offsets they were written at — the reading
131
+ * {@link dropSupersededSelections} tokenizes the runner's arguments with AND the one a redirect
132
+ * reader may not look inside: a quoted `--grep 'a > b'` is one word, and its `>` is the filter's
133
+ * text, not a redirect.
134
+ */
135
+ const ARGUMENT_WORD = /"(?:\\.|[^"\\])*"|'[^']*'|\S+/g;
136
+ /**
137
+ * The command's words as spans, in the order they were written.
138
+ *
139
+ * @param command - The shell command.
140
+ * @returns Each word's span in the command.
141
+ */
142
+ function wordSpans(command) {
143
+ return [...command.matchAll(ARGUMENT_WORD)].map((match) => ({
144
+ start: match.index ?? 0,
145
+ end: (match.index ?? 0) + match[0].length,
146
+ }));
147
+ }
148
+ /**
149
+ * Whether an offset sits strictly INSIDE one of the command's words rather than between them —
150
+ * the quoted data a redirect reader must leave alone.
151
+ *
152
+ * @param at - The offset being read.
153
+ * @param words - The command's word spans.
154
+ * @returns True when the offset is inside a word.
155
+ */
156
+ function insideWord(at, words) {
157
+ return words.some((word) => at > word.start && at < word.end);
158
+ }
159
+ /**
160
+ * The command with the edits applied — right to left, so every edit's offsets are still the ones
161
+ * its reader reported.
162
+ *
163
+ * @param command - The command the edits were read from.
164
+ * @param edits - The replacements, as offsets in that command.
165
+ * @returns The command with each span replaced.
166
+ */
167
+ function applyTextEdits(command, edits) {
168
+ let out = command;
169
+ for (const edit of [...edits].sort((a, b) => b.start - a.start))
170
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
171
+ return out;
172
+ }
173
+ /**
174
+ * The output redirects of ONE statement's text that name a file, as edits that point them at a
175
+ * fresh sibling of the file they wrote (`/tmp/pw-full.log` → `/tmp/pw-full.rerun.log`).
176
+ *
177
+ * A redirect whose operator sits inside a quoted word is that word's text, not a redirect of the
178
+ * statement: `npx playwright test --grep 'a > b' > /tmp/full.log` carries exactly one redirect.
179
+ *
180
+ * @param statement - The statement's text.
181
+ * @param offset - The statement's offset in the command.
182
+ * @param words - The command's word spans.
183
+ * @returns The edits, in statement order.
184
+ */
185
+ function freshRedirectEdits(statement, offset, words) {
186
+ const edits = [];
187
+ for (const match of statement.matchAll(FILE_REDIRECT)) {
188
+ const before = match[1] ?? '';
189
+ const op = match[2] ?? '';
190
+ const space = match[3] ?? '';
191
+ const single = match[4];
192
+ const double = match[5];
193
+ const target = single ?? double ?? match[6] ?? '';
194
+ // A `>` written inside a quoted value is that value's own text.
195
+ if (insideWord(offset + (match.index ?? 0) + before.length, words))
196
+ continue;
197
+ // `/dev/null` and friends are not a report to preserve; leaving them keeps the re-run silent
198
+ // the way the run was, where rewriting them would create a file nobody asked for.
199
+ if (!target || DEVICE_TARGET.test(target))
200
+ continue;
201
+ // A descriptor (`>&2`) or a process substitution (`> >(tee …)`) is no file either.
202
+ if (target.startsWith('&') || target.startsWith('(') || /^\d+$/.test(target))
203
+ continue;
204
+ const quote = single !== undefined || double !== undefined ? 1 : 0;
205
+ const start = offset + (match.index ?? 0) + before.length + op.length + space.length + quote;
206
+ edits.push({ start, end: start + target.length, text: `${target}.rerun.log` });
207
+ }
208
+ return edits;
209
+ }
210
+ /**
211
+ * The file arguments of a `tee` stage, as edits that point them at a fresh sibling.
212
+ *
213
+ * A pipeline the suite feeds (`… 2>&1 | tee /tmp/full.log`) writes the run's own stream to the
214
+ * files `tee` names, so a re-run through the same pipeline would overwrite the walk's report
215
+ * exactly the way its own `> <file>` redirect would ({@link freshRedirectEdits}). A stage that
216
+ * names no file, reads a device or carries flags keeps those words as written.
217
+ *
218
+ * @param statement - The stage's text.
219
+ * @param offset - The stage's offset in the command.
220
+ * @returns The edits, in stage order.
221
+ */
222
+ function freshTeeEdits(statement, offset) {
223
+ const words = [...statement.matchAll(ARGUMENT_WORD)].map((match) => ({
224
+ text: match[0],
225
+ start: offset + (match.index ?? 0),
226
+ }));
227
+ const program = words.findIndex((word) => unquoted(word.text).replace(/\/+$/, '').split('/').at(-1) === 'tee');
228
+ if (program < 0)
229
+ return [];
230
+ const edits = [];
231
+ for (const word of words.slice(program + 1)) {
232
+ const bare = unquoted(word.text);
233
+ if (!bare || bare.startsWith('-') || DEVICE_TARGET.test(bare))
234
+ continue;
235
+ const quote = /^['"]/.test(word.text) ? 1 : 0;
236
+ edits.push({
237
+ start: word.start + quote,
238
+ end: word.start + word.text.length - quote,
239
+ text: `${bare}.rerun.log`,
240
+ });
241
+ }
242
+ return edits;
243
+ }
244
+ /**
245
+ * The statements that carry the suite run's OWN output: the Playwright statement, and every stage
246
+ * of the pipeline it feeds (`| tee /tmp/full.log | tail -5`).
247
+ *
248
+ * The parse's own spans answer this, so the setup and the tail around the run are never read as
249
+ * its output: `printf … > app/public/_redirects` writes a file BEFORE any suite starts, and
250
+ * `; echo done > /tmp/done` runs after it, whatever their redirects look like. Only a `|` joins a
251
+ * stage to the run's stream; a `;`, `&&` or newline starts a statement of its own whose output is
252
+ * not the run's ({@link redirectRunnerOutputsToFreshFiles}).
253
+ *
254
+ * @param command - The composed command.
255
+ * @returns The statements, or null when the command starts no Playwright run that can be placed.
256
+ */
257
+ function runnerOutputStatements(command) {
258
+ const located = locatedCommandSegments(command);
259
+ const at = located.findIndex((entry) => entry.segment !== null && PLAYWRIGHT_RUN.test(entry.segment.text));
260
+ if (at < 0)
261
+ return null;
262
+ const place = (entry, tee) => {
263
+ if (entry.start === null || entry.end === null)
264
+ return null;
265
+ // A statement can end on an enclosing group's closer (`( … 2>&1)`); the closer is the
266
+ // shell's, not the statement's, so it is not part of what a redirect reader may rewrite.
267
+ const end = statementBoundary(command, entry.start, entry.end)?.textEnd ?? entry.end;
268
+ return { start: entry.start, end, tee };
269
+ };
270
+ const runner = located[at];
271
+ const first = runner === undefined ? null : place(runner, false);
272
+ if (first === null)
273
+ return null;
274
+ const statements = [first];
275
+ for (let i = at + 1; i < located.length; i += 1) {
276
+ const entry = located[i];
277
+ // Only a stage of the SAME pipeline carries the run's output; anything else is the tail.
278
+ if (entry === undefined || entry.separator !== '|')
279
+ break;
280
+ const stage = place(entry, entry.segment?.program === 'tee');
281
+ if (stage !== null)
282
+ statements.push(stage);
283
+ }
284
+ return statements;
285
+ }
286
+ /**
287
+ * The command with the SUITE RUN's own log files pointed at fresh siblings, and nothing else
288
+ * changed.
132
289
  *
133
290
  * A re-run composed from a run's own command keeps the run's redirect by construction, and the
134
291
  * run wrote the walk's only report to that file: xr-molecule-flash-m173 handed over *"Send this
@@ -137,29 +294,42 @@ const DEVICE_TARGET = /^\/dev\//;
137
294
  * full walk's log, destroying the other 11 failures' error reports. Following the platform's own
138
295
  * advice must never destroy the evidence it was based on.
139
296
  *
140
- * @param command - A shell command.
141
- * @returns The command whose file redirects write to a fresh target.
297
+ * Only the run's OWN stream moves. What surrounds it is SETUP and POSTLUDE, and those writes are
298
+ * the command's meaning, not a report: xr-molecule-flash-m429 (2026-10-22) composed
299
+ * `printf '/index.html 200' > app/public/_redirects && npx playwright test specs/auth.spec.ts
300
+ * > /tmp/full.log 2>&1` with its SPA fallback written to `_redirects.rerun.log`, so the re-run
301
+ * measured an app with no fallback at all. A heredoc config, a `.env` the command writes and a
302
+ * source asset it lays down are the same shape: their targets stay byte-for-byte, and so does
303
+ * every statement chained after the run (`; echo EXIT $? > /tmp/done`).
304
+ *
305
+ * @param command - The composed command.
306
+ * @returns The command whose suite-run redirects write to a fresh target.
142
307
  */
143
- function redirectToFreshFile(command) {
144
- return command.replace(FILE_REDIRECT, (whole, before, op, space, single, double, bare) => {
145
- const target = single ?? double ?? bare ?? '';
146
- // `/dev/null` and friends are not a report to preserve; leaving them keeps the re-run silent
147
- // the way the run was, where rewriting them would create a file nobody asked for.
148
- if (!target || DEVICE_TARGET.test(target))
149
- return whole;
150
- const quote = single !== undefined ? "'" : double !== undefined ? '"' : '';
151
- return `${before}${op}${space}${quote}${target}.rerun.log${quote}`;
152
- });
308
+ function redirectRunnerOutputsToFreshFiles(command) {
309
+ const statements = runnerOutputStatements(command);
310
+ if (statements === null)
311
+ return command;
312
+ const words = wordSpans(command);
313
+ const edits = [];
314
+ for (const statement of statements) {
315
+ const text = command.slice(statement.start, statement.end);
316
+ edits.push(...freshRedirectEdits(text, statement.start, words));
317
+ if (statement.tee)
318
+ edits.push(...freshTeeEdits(text, statement.start));
319
+ }
320
+ return applyTextEdits(command, edits);
153
321
  }
154
322
  /**
155
323
  * The whole-suite command made scoped to the tests it should re-run instead: the same
156
- * invocation — same directory, same settings — with the named `file:line`s in place of the whole
157
- * suite, `--last-failed` dropped (it re-runs the tests that never ran too, X1 flash-m60) and
324
+ * invocation — same launcher, same settings — with the named `file:line`s in place of every
325
+ * positional selection the run carried (a spec file, a suite directory or a regex filter),
326
+ * `--last-failed` dropped (it re-runs the tests that never ran too, X1 flash-m60) and
158
327
  * `--retries=0` set (a re-run of a known failure does not need to pay for retries).
159
328
  *
160
329
  * The re-run keeps the run's `> <file>` redirect only in the sense of keeping its OUTPUT there:
161
- * the target is rewritten to a fresh sibling ({@link redirectToFreshFile}), so a re-run never
162
- * overwrites the log the run itself is the evidence for (xr-molecule-flash-m173).
330
+ * the target is rewritten to a fresh sibling ({@link redirectRunnerOutputsToFreshFiles}), so a
331
+ * re-run never overwrites the log the run itself is the evidence for (xr-molecule-flash-m173) —
332
+ * and the setup around the run keeps its own file writes untouched (xr-molecule-flash-m429).
163
333
  *
164
334
  * `config` is the config the run was measured under ({@link withSuiteConfig}): the narrowed re-run
165
335
  * carries it whenever the command itself does not, so a run that dropped `--config` cannot be
@@ -215,8 +385,10 @@ export function scopedRerunCommand(command, locations, excluded = [], config, cw
215
385
  // nothing — "No tests found" — so every location is rendered in a form Playwright can match
216
386
  // ({@link playwrightLocations}).
217
387
  const narrowed = playwrightLocations(locations, command, cwd);
218
- const dropped = dropSupersededSpecFiles(args, narrowed)
219
- .replace(/\s*--last-failed\b/g, '')
388
+ const dropped = dropSupersededSelections(args, narrowed)
389
+ // `--last-failed` re-runs the tests that never ran too (flash-m60); its `--last-failed-file`
390
+ // sibling only moves the file it reads and is a real option of the re-run.
391
+ .replace(/\s*--last-failed(?!-)/g, '')
220
392
  .replace(/\s*--retries(?:[= ]\d+)?/g, '');
221
393
  const scoped = command.slice(0, run.afterRunner) +
222
394
  ` ${narrowed.join(' ')} --retries=0${carry}${invert}` +
@@ -226,7 +398,7 @@ export function scopedRerunCommand(command, locations, excluded = [], config, cw
226
398
  // X1 xr-molecule-flash-m157 (+14:02) was handed a re-run that still carried the chain's second
227
399
  // whole walk — 4.5 minutes measuring code it had not changed.
228
400
  const rerunTail = suiteRunStatements(tail).length > 0 ? '' : tail;
229
- return withRunEnv(`${redirectToFreshFile(scoped)}${redirectToFreshFile(rerunTail)}`, env);
401
+ return withRunEnv(redirectRunnerOutputsToFreshFiles(`${scoped}${rerunTail}`), env);
230
402
  }
231
403
  /** A `NAME=value` word in command position, optionally behind `export`/`env`. */
232
404
  const ENV_WORD = /(^|[\s;&|(])(?:export[ \t]+|env[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=('[^']*'|"[^"]*"|[^\s;&|]+)/g;
@@ -366,33 +538,145 @@ function playwrightLocations(locations, command, cwd) {
366
538
  return `${resolved}${suffix}`;
367
539
  });
368
540
  }
369
- /** A spec-file argument Playwright accepts as its positional filter — never a flag. */
370
- const SPEC_FILE_ARGUMENT = /^(?!-)[^\s]*\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+(?::\d+)?)?$/;
371
541
  /**
372
- * The runner's own arguments with every bare spec-file argument taken out once the re-run names
542
+ * Every Playwright `test` option that takes its value from the NEXT word, read from the runner's
543
+ * own option table (`playwright test --help`, Playwright 1.63 as the sandbox image ships it).
544
+ *
545
+ * A value here is never a positional selection however much it looks like a path:
546
+ * `--config playwright.config.ts` names the config the run was measured under, and dropping it
547
+ * because it ends in `.ts` would re-create m113 exactly (a re-run with no base URL).
548
+ */
549
+ const SUITE_VALUE_FLAGS = new Set([
550
+ '--add-reporter',
551
+ '--browser',
552
+ '-c',
553
+ '--config',
554
+ '--global-timeout',
555
+ '-g',
556
+ '--grep',
557
+ '-G',
558
+ '--grep-invert',
559
+ '--last-failed-file',
560
+ '--max-failures',
561
+ '--output',
562
+ '--repeat-each',
563
+ '--reporter',
564
+ '--retries',
565
+ '--run-agents',
566
+ '--shard',
567
+ '--test-list',
568
+ '--test-list-invert',
569
+ '--timeout',
570
+ '--trace',
571
+ '--tsconfig',
572
+ '--ui-host',
573
+ '--ui-port',
574
+ '--update-source-method',
575
+ '-j',
576
+ '--workers',
577
+ ]);
578
+ /**
579
+ * Options whose value is optional (`--debug [mode]`, `--only-changed [ref]`): the next word is
580
+ * theirs unless it is another flag — the reading the runner's own command line takes.
581
+ */
582
+ const SUITE_OPTIONAL_VALUE_FLAGS = new Set([
583
+ '--debug',
584
+ '--only-changed',
585
+ '-u',
586
+ '--update-snapshots',
587
+ ]);
588
+ /**
589
+ * `--project <project-name...>` takes every word up to the next flag, as the runner's own command
590
+ * line does; a project name is a selection of projects, never a positional test filter.
591
+ */
592
+ const SUITE_VARIADIC_VALUE_FLAGS = new Set(['--project']);
593
+ /** A redirect written as one word (`2>&1`, `>/tmp/out.log`): not a selection. */
594
+ const ARGUMENT_REDIRECT = /^(?:\d*|&)(?:<<<|<<-?|>>?\|?|<>|>&|<&)(?:&?\d+|-|[^\s;&|()<>]*)?$/;
595
+ /** A redirect operator whose target is the word after it (`> /tmp/out.log`). */
596
+ const ARGUMENT_REDIRECT_OPERATOR = /^(?:\d*|&)(?:<<<|<<-?|>>?\|?|<>|>&|<&)$/;
597
+ /**
598
+ * The runner's own arguments with every positional selection taken out once the re-run names
373
599
  * failure locations.
374
600
  *
375
- * The re-run is "just these": Playwright matches each positional argument against every collected
376
- * file's path, so a bare file left beside the `file:line`s re-admits ALL of that file's tests.
377
- * xr-molecule-flash-m118 (+45:24) kept the one file its 14 failures were in ("~297s" for a file and
378
- * fourteen duplicates); xr-molecule-flash-m134 (+20:0x) kept all four files of a run whose 8
379
- * failures sat in two of them — 32 tests, ~3 min, instead of the 8 failures' ~30 s. A file with no
380
- * failure in it has nothing to re-run, so it goes too.
601
+ * The re-run is "just these", and Playwright OR-s every positional argument into one selection:
602
+ * ANY leftover positional re-admits tests the re-run is supposed to skip. A bare spec file
603
+ * re-admits all of that file's tests (xr-molecule-flash-m118 (+45:24) ~297 s for one file and
604
+ * fourteen duplicates; xr-molecule-flash-m134 (+20:0x) 32 tests instead of 8). A suite DIRECTORY
605
+ * is the same argument and re-admits the WHOLE walk even beside fourteen exact `file:line`s —
606
+ * xr-molecule-flash-m428's suggested "Re-run just these" kept
607
+ * `/workspace/fixtures/realworld/specs/e2e` after `--config playwright.config.ts`, so the command
608
+ * said 14 locations and selected every spec again. A regex positional filter selects files the
609
+ * same way and goes too.
610
+ *
611
+ * What is NOT a selection survives byte-for-byte: every option and its value — whatever it looks
612
+ * like (`--config playwright.config.ts`, `--output test-results`, `--grep 'a|b'`, a `--project`
613
+ * list) — and every redirect. Quoting and spacing survive because the result is a span splice on
614
+ * the raw text, never a rebuild from words.
381
615
  *
382
- * The launcher's words are not arguments and are not here: the caller passes what follows
383
- * `playwright test` ({@link playwrightStatement}'s `afterRunner`), so `node <path>/playwright
384
- * test` and `npx playwright test` lose the same spec files.
616
+ * An empty `locations` list keeps the original selection untouched:
617
+ * {@link scopedRerunCommand} is not narrowing anything then.
385
618
  *
386
619
  * @param args - The run's own arguments, after `playwright test`.
387
620
  * @param locations - The failing `file:line`s the re-run names.
388
- * @returns The arguments without their bare spec-file arguments.
621
+ * @returns The arguments without their positional selections.
389
622
  */
390
- function dropSupersededSpecFiles(args, locations) {
623
+ function dropSupersededSelections(args, locations) {
391
624
  if (locations.length === 0)
392
625
  return args;
393
- return args.replace(/(^|\s+)([^\s]+)(?=\s|$)/g, (whole, _space, token) => {
394
- return SPEC_FILE_ARGUMENT.test(token) ? '' : whole;
395
- });
626
+ const words = [];
627
+ for (const match of args.matchAll(ARGUMENT_WORD))
628
+ words.push({ text: match[0], start: match.index ?? 0 });
629
+ const dropped = new Array(words.length).fill(false);
630
+ let at = 0;
631
+ while (at < words.length) {
632
+ const word = words[at]?.text ?? '';
633
+ if (ARGUMENT_REDIRECT_OPERATOR.test(word)) {
634
+ // The operator and the target after it are one redirect, not two words to judge.
635
+ at += 2;
636
+ continue;
637
+ }
638
+ if (ARGUMENT_REDIRECT.test(word)) {
639
+ at += 1;
640
+ continue;
641
+ }
642
+ if (word.startsWith('-') && word !== '-') {
643
+ // `--flag=value` carries its value inside the same word; only the flag's own name is read.
644
+ if (word.includes('=')) {
645
+ at += 1;
646
+ continue;
647
+ }
648
+ if (SUITE_OPTIONAL_VALUE_FLAGS.has(word)) {
649
+ const next = words[at + 1]?.text;
650
+ at += next !== undefined && !next.startsWith('-') ? 2 : 1;
651
+ continue;
652
+ }
653
+ if (SUITE_VARIADIC_VALUE_FLAGS.has(word)) {
654
+ at += 1;
655
+ while (at < words.length && !(words[at]?.text ?? '').startsWith('-'))
656
+ at += 1;
657
+ continue;
658
+ }
659
+ at += SUITE_VALUE_FLAGS.has(word) ? 2 : 1;
660
+ continue;
661
+ }
662
+ // A bare word — a spec file, a directory or a regex filter — is a positional selection.
663
+ dropped[at] = true;
664
+ at += 1;
665
+ }
666
+ let out = '';
667
+ let cursor = 0;
668
+ for (let i = 0; i < words.length; i += 1) {
669
+ const word = words[i];
670
+ if (word === undefined || !dropped[i])
671
+ continue;
672
+ // The whitespace in front of the word goes with it, so the result is not left double-spaced.
673
+ let start = word.start;
674
+ while (start > cursor && /\s/.test(args[start - 1] ?? ''))
675
+ start -= 1;
676
+ out += args.slice(cursor, start);
677
+ cursor = word.start + word.text.length;
678
+ }
679
+ return out + args.slice(cursor);
396
680
  }
397
681
  /**
398
682
  * The run in flight that makes a second run a hazard, or null when nothing is in the way.
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AA4O9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAudD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA+HnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AA2DlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AA2GD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA27FxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAgS9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAudD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAgInE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AA2DlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AA2GD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAq/FxF"}
package/dist/tools.js CHANGED
@@ -15,6 +15,7 @@ import { INLINE_SCRIPT_BUDGET_MS, inlineLocalScriptCommand, inlineScriptRunawayS
15
15
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
16
16
  import { diffLineCount } from './line-diff.js';
17
17
  import { pipelineExitCodeNote } from './pipeline-exit-note.js';
18
+ import { commandWritesReportTo, PLAYWRIGHT_REPORT_SUFFIX, playwrightReportPathFor, summarizePlaywrightReport, } from './playwright-report.js';
18
19
  import { TOOL_SCHEMAS } from './schemas.js';
19
20
  import { foregroundBrowserProbe, foregroundServerCommand, SERVER_OVER_BUDGET_ADVICE, serverCommandRefusal, startsServerProgram, } from './server-command.js';
20
21
  import { leadingSleepPeek } from './sleep-peek.js';
@@ -34,6 +35,45 @@ const waitedByTask = new Map();
34
35
  const logBytesByTask = new Map();
35
36
  /** The file a background command writes its own output to, when it redirects it. */
36
37
  const outputFileByTask = new Map();
38
+ /**
39
+ * The standard Playwright machine report each background task was launched with, when the launch
40
+ * could carry one ({@link streamingBackgroundCommand}'s `reportPath`).
41
+ *
42
+ * The path is derived from the task id (`playwright-report.ts`), so a process that restarted
43
+ * mid-run still finds it — nothing here is the only record of where the file is.
44
+ */
45
+ const reportByTask = new Map();
46
+ /**
47
+ * One report file's bounded facts, or the honest reason there are none.
48
+ *
49
+ * A report that is not there is NOT "the run failed nothing": `summarizePlaywrightReport` answers
50
+ * `missing`, and every reader treats that as no machine data rather than as a clean run.
51
+ *
52
+ * @param backend - The project's execution backend.
53
+ * @param path - The report file's absolute path (`/tmp/…`).
54
+ * @param sanitize - The consumer's redactor for the error text the record carries.
55
+ * @returns The bounded record; never a verdict.
56
+ */
57
+ async function readPlaywrightRunReport(backend, path, sanitize) {
58
+ let text;
59
+ try {
60
+ text = await backend.readFile(path);
61
+ }
62
+ catch (error) {
63
+ // No file under this path: the run left no report (killed before it ended, or it was launched
64
+ // before this sideband existed). Absence is said, never read as a clean run.
65
+ return {
66
+ status: 'missing',
67
+ path,
68
+ reason: 'the run left no report at this path' +
69
+ (error instanceof Error && error.message ? ` (${error.message})` : ''),
70
+ stats: null,
71
+ failures: [],
72
+ omittedFailures: 0,
73
+ };
74
+ }
75
+ return summarizePlaywrightReport(text, { path, sanitize });
76
+ }
37
77
  /**
38
78
  * Recovered legacy task ids whose command ENDS with a backgrounded statement (`… &`).
39
79
  *
@@ -663,7 +703,7 @@ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
663
703
  }
664
704
  import { suiteCommandTarget } from './suite-overlap.js';
665
705
  import { diagnosisTraceCommand } from './trace-artifacts.js';
666
- import { addedRedactionMask, BRACE_GLOB_HINT, checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, expandBraceGlob, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isReadOnlyInspection, isSuiteRunCommand, linesWithinSerialized, literalDirPrefix, logShowsInlineFailureBodies, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatedSuiteRunError, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunBudgetCommand, suiteRunConfigCommand, suiteRunPipelinePlacement, suiteRunPriorityLaunch, TOOL_RESULT_CHARS, truncateMiddle, universalCause, whitespaceTolerantReplace, withoutSuiteTimeout, } from './utilities.js';
706
+ import { addedRedactionMask, BRACE_GLOB_HINT, checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, expandBraceGlob, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isReadOnlyInspection, isSuiteRunCommand, linesWithinSerialized, literalDirPrefix, logShowsInlineFailureBodies, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatedSuiteRunError, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunBudgetCommand, suiteRunConfigCommand, suiteRunPipelinePlacement, suiteRunPriorityLaunch, TOOL_RESULT_CHARS, truncateMiddle, universalCause, whitespaceTolerantReplace, withoutSuiteTimeout, withPlaywrightRunReport, } from './utilities.js';
667
707
  /**
668
708
  * How long exec_command waits for a command it backgrounded only because the
669
709
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -2845,14 +2885,19 @@ export function buildTools(backend, config) {
2845
2885
  // The request's own exclusions ride along: a `--grep-invert` that names one of them is
2846
2886
  // the proof scope written as a flag, not a diagnosis, so it keeps the suite's retries
2847
2887
  // (xr-molecule-flash-m170 +33:16). The getter is the host's, read here at launch time.
2848
- const streaming = streamingBackgroundCommand(command, {
2849
- approvedExclusions: excludedTestTitles?.() ?? [],
2850
- });
2888
+ // The standard Playwright machine report this run may carry, at a path derived from the
2889
+ // task's own id: unique per launch, and still findable from the id after a process
2890
+ // restart (see reportByTask). A run whose reporter set is not this tool's to place — or
2891
+ // one that already writes a report of its own — comes back with no report path and is
2892
+ // launched exactly as before.
2893
+ const streaming = streamingBackgroundCommand(command, { approvedExclusions: excludedTestTitles?.() ?? [] }, playwrightReportPathFor(id));
2851
2894
  // A command that starts the suite more than once is refused here, before anything is
2852
2895
  // written or launched: each walk measures the same code, and only the last is rewritten
2853
2896
  // (X1 xr-molecule-flash-m157 +14:02 — two whole walks chained with `;`).
2854
2897
  if (streaming.error)
2855
2898
  return { error: streaming.error };
2899
+ if (streaming.reportPath !== undefined)
2900
+ reportByTask.set(id, streaming.reportPath);
2856
2901
  // This tool already backgrounds the script. If its last statement also uses `&`,
2857
2902
  // let the same shell wait for that child and preserve its exit code. Inferring its
2858
2903
  // completion from output cannot work for silent commands such as `true &`.
@@ -2934,6 +2979,11 @@ export function buildTools(backend, config) {
2934
2979
  // 0 is the filter's). No tool budget applies to a detached run, so the command's
2935
2980
  // own `timeout` is the only bound here.
2936
2981
  const cut = commandCutRun(command, { exitCode: code, stdout, stderr: '' }, Date.now() - launchedAt, 0, output);
2982
+ // The machine report is read here exactly as a wait reads it: a grace return is the
2983
+ // same finished run, handed back without a task id.
2984
+ const graceReport = streaming.reportPath === undefined
2985
+ ? null
2986
+ : await readPlaywrightRunReport(backend, streaming.reportPath, sanitizeOutput);
2937
2987
  if (cut) {
2938
2988
  return {
2939
2989
  stdout,
@@ -2955,6 +3005,7 @@ export function buildTools(backend, config) {
2955
3005
  : ` ${isSuiteRunCommand(command)
2956
3006
  ? SUITE_OVER_BUDGET_ADVICE
2957
3007
  : SPLIT_WORK_ADVICE}`),
3008
+ ...(graceReport === null ? {} : { runReport: graceReport }),
2958
3009
  };
2959
3010
  }
2960
3011
  // The exit file holds the PIPELINE's code: when a stage this rewrite could not
@@ -2984,6 +3035,7 @@ export function buildTools(backend, config) {
2984
3035
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2985
3036
  ...(notes.length ? { note: notes.join(' ') } : {}),
2986
3037
  ...suiteBudgetField(budget),
3038
+ ...(graceReport === null ? {} : { runReport: graceReport }),
2987
3039
  };
2988
3040
  }
2989
3041
  await new Promise((r) => setTimeout(r, 1_500));
@@ -3056,12 +3108,22 @@ export function buildTools(backend, config) {
3056
3108
  ? Math.min(requestedMs, toolCeilingMs)
3057
3109
  : toolCeilingMs;
3058
3110
  const startedAt = Date.now();
3059
- const result = await backend.run(command, {
3111
+ // The standard Playwright machine report, when this run names its own reporters
3112
+ // (`withPlaywrightRunReport`): `json` joins that set and its output goes to a file of this
3113
+ // call's own. A run that names no reporter is left exactly as written — its reporters are
3114
+ // the suite config's, and replacing them would stop writing what the executor configured.
3115
+ const reported = withPlaywrightRunReport(command, `/tmp/mol-pw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${PLAYWRIGHT_REPORT_SUFFIX}`);
3116
+ const result = await backend.run(reported.command, {
3060
3117
  cwd,
3061
3118
  timeout: execTimeoutMs,
3062
3119
  ...(effectiveBudgetMs ? { budgetMs: effectiveBudgetMs } : {}),
3063
3120
  });
3064
3121
  const elapsedMs = Date.now() - startedAt;
3122
+ // Read once, after the run: the runner's own per-test identities and attachment paths, or
3123
+ // the honest absence of a report. Never a verdict — pass/fail stays the caller's reading.
3124
+ const runReport = reported.reportPath === null
3125
+ ? null
3126
+ : await readPlaywrightRunReport(backend, reported.reportPath, sanitizeOutput);
3065
3127
  // The clamped ask is only worth a line when the clamp was felt: a command
3066
3128
  // that asked for 600 s and finished in 25 s says nothing about splitting
3067
3129
  // work, and the note read as a warning about a run that had already
@@ -3090,6 +3152,12 @@ export function buildTools(backend, config) {
3090
3152
  : sanitizeOutput;
3091
3153
  const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
3092
3154
  const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
3155
+ // Whether the stdout above is a middle cut of what the command printed. Said on the
3156
+ // result because a reader that records what this call DELIVERED cannot otherwise tell
3157
+ // a whole file from a holed one, and must not point at a holed output as "already
3158
+ // above" (xr-molecule-flash-m420: a clipped multi-file `cat` armed both a shell and a
3159
+ // read_file re-read hold on a helper whose middle never arrived).
3160
+ const stdoutTruncated = result.stdout.length > MAX_OUTPUT_SIZE;
3093
3161
  // A run a budget stopped is NOT a run that ended: it reports `status: 'cut_at_budget'`,
3094
3162
  // never a 0 the pipeline produced, and names how far it got. Read from the command, the
3095
3163
  // output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
@@ -3140,6 +3208,8 @@ export function buildTools(backend, config) {
3140
3208
  : isSuiteRunCommand(command)
3141
3209
  ? SUITE_OVER_BUDGET_ADVICE
3142
3210
  : SPLIT_WORK_ADVICE),
3211
+ ...(runReport === null ? {} : { runReport }),
3212
+ ...(stdoutTruncated ? { stdoutTruncated: true } : {}),
3143
3213
  };
3144
3214
  }
3145
3215
  // A pipeline reports its LAST stage's code: `tsc | head` says 0 beside a printed type
@@ -3159,6 +3229,8 @@ export function buildTools(backend, config) {
3159
3229
  note: [sleepNote, ceilingNote, traced.change, budgetNote].filter(Boolean).join(' '),
3160
3230
  }
3161
3231
  : {}),
3232
+ ...(runReport === null ? {} : { runReport }),
3233
+ ...(stdoutTruncated ? { stdoutTruncated: true } : {}),
3162
3234
  };
3163
3235
  }
3164
3236
  catch (e) {
@@ -3268,6 +3340,12 @@ export function buildTools(backend, config) {
3268
3340
  // a command that ended in `&` is still a launch acknowledgement, not a finished run.
3269
3341
  if (endsInBackground(script))
3270
3342
  detachedByTask.add(taskId);
3343
+ // …and so does the machine report the launch wired (`playwright-report.ts`): its path is
3344
+ // the task's own id derived, and only a script that sets it to exactly that path is this
3345
+ // platform's own — a `PLAYWRIGHT_JSON_OUTPUT_FILE` the executor chose is theirs.
3346
+ const expectedReport = playwrightReportPathFor(taskId);
3347
+ if (!reportByTask.has(taskId) && commandWritesReportTo(script, expectedReport))
3348
+ reportByTask.set(taskId, expectedReport);
3271
3349
  }
3272
3350
  catch (_error) {
3273
3351
  // No script under that id: this process did not start the task, so
@@ -3651,6 +3729,15 @@ export function buildTools(backend, config) {
3651
3729
  // failing tests instead of leaving the executor to re-run the whole suite (xr-molecule-
3652
3730
  // flash-m101 +12:13: 139 tests, ~4 minutes, to check two fixes).
3653
3731
  const run = runByTask.get(taskId);
3732
+ // The standard machine report this launch wired, when it did (`reportByTask`). Read once per
3733
+ // finished wait: it is the runner's own record of each failing test's identity and of the
3734
+ // artifacts saved for that test, so a diagnosis can stop reconstructing them from the log.
3735
+ // A launch that carried no report path attaches nothing here — there is nothing honest to
3736
+ // say about a run that was never asked to write one.
3737
+ const reportPath = reportByTask.get(taskId);
3738
+ const runReport = reportPath === undefined
3739
+ ? null
3740
+ : await readPlaywrightRunReport(backend, reportPath, sanitizeOutput);
3654
3741
  // What the REQUEST requires of this suite and the run left out: the narrowed re-run below
3655
3742
  // is composed from the run's own command, so without these it would measure the same wrong
3656
3743
  // configuration again (m182: the request names `TEST_MODE=fullstack`, the run and its
@@ -3687,6 +3774,7 @@ export function buildTools(backend, config) {
3687
3774
  note: [cutRunSentence(cut), causes].filter((n) => Boolean(n)).join(' '),
3688
3775
  stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
3689
3776
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
3777
+ ...(runReport === null ? {} : { runReport }),
3690
3778
  };
3691
3779
  }
3692
3780
  // Same shaping as a foreground exec result, so the executor reads both
@@ -3726,6 +3814,7 @@ export function buildTools(backend, config) {
3726
3814
  : {}),
3727
3815
  stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
3728
3816
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
3817
+ ...(runReport === null ? {} : { runReport }),
3729
3818
  };
3730
3819
  },
3731
3820
  async save_plan(input) {