@molecule/api-ai-tools 1.13.0 → 1.14.0

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,278 @@ 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
+ * @param fresh - The exact old file → fresh file mapping the edits make, filled in here.
184
+ * @returns The edits, in statement order.
185
+ */
186
+ function freshRedirectEdits(statement, offset, words, fresh) {
187
+ const edits = [];
188
+ for (const match of statement.matchAll(FILE_REDIRECT)) {
189
+ const before = match[1] ?? '';
190
+ const op = match[2] ?? '';
191
+ const space = match[3] ?? '';
192
+ const single = match[4];
193
+ const double = match[5];
194
+ const target = single ?? double ?? match[6] ?? '';
195
+ // A `>` written inside a quoted value is that value's own text.
196
+ if (insideWord(offset + (match.index ?? 0) + before.length, words))
197
+ continue;
198
+ // `/dev/null` and friends are not a report to preserve; leaving them keeps the re-run silent
199
+ // the way the run was, where rewriting them would create a file nobody asked for.
200
+ if (!target || DEVICE_TARGET.test(target))
201
+ continue;
202
+ // A descriptor (`>&2`) or a process substitution (`> >(tee …)`) is no file either.
203
+ if (target.startsWith('&') || target.startsWith('(') || /^\d+$/.test(target))
204
+ continue;
205
+ const quote = single !== undefined || double !== undefined ? 1 : 0;
206
+ const start = offset + (match.index ?? 0) + before.length + op.length + space.length + quote;
207
+ const rewritten = `${target}.rerun.log`;
208
+ fresh.set(target, rewritten);
209
+ edits.push({ start, end: start + target.length, text: rewritten });
210
+ }
211
+ return edits;
212
+ }
213
+ /**
214
+ * The file arguments of a `tee` stage, as edits that point them at a fresh sibling.
215
+ *
216
+ * A pipeline the suite feeds (`… 2>&1 | tee /tmp/full.log`) writes the run's own stream to the
217
+ * files `tee` names, so a re-run through the same pipeline would overwrite the walk's report
218
+ * exactly the way its own `> <file>` redirect would ({@link freshRedirectEdits}). A stage that
219
+ * names no file, reads a device or carries flags keeps those words as written.
220
+ *
221
+ * @param statement - The stage's text.
222
+ * @param offset - The stage's offset in the command.
223
+ * @param fresh - The exact old file → fresh file mapping the edits make, filled in here.
224
+ * @returns The edits, in stage order.
225
+ */
226
+ function freshTeeEdits(statement, offset, fresh) {
227
+ const words = [...statement.matchAll(ARGUMENT_WORD)].map((match) => ({
228
+ text: match[0],
229
+ start: offset + (match.index ?? 0),
230
+ }));
231
+ const program = words.findIndex((word) => unquoted(word.text).replace(/\/+$/, '').split('/').at(-1) === 'tee');
232
+ if (program < 0)
233
+ return [];
234
+ const edits = [];
235
+ for (const word of words.slice(program + 1)) {
236
+ const bare = unquoted(word.text);
237
+ if (!bare || bare.startsWith('-') || DEVICE_TARGET.test(bare))
238
+ continue;
239
+ const quote = /^['"]/.test(word.text) ? 1 : 0;
240
+ const rewritten = `${bare}.rerun.log`;
241
+ fresh.set(bare, rewritten);
242
+ edits.push({
243
+ start: word.start + quote,
244
+ end: word.start + word.text.length - quote,
245
+ text: rewritten,
246
+ });
247
+ }
248
+ return edits;
249
+ }
250
+ /**
251
+ * The statements that carry the suite run's OWN output: the Playwright statement, and every stage
252
+ * of the pipeline it feeds (`| tee /tmp/full.log | tail -5`).
253
+ *
254
+ * The parse's own spans answer this, so the setup and the tail around the run are never read as
255
+ * its output: `printf … > app/public/_redirects` writes a file BEFORE any suite starts, and
256
+ * `; echo done > /tmp/done` runs after it, whatever their redirects look like. Only a `|` joins a
257
+ * stage to the run's stream; a `;`, `&&` or newline starts a statement of its own whose output is
258
+ * not the run's ({@link redirectRunnerOutputsToFreshFiles}).
259
+ *
260
+ * @param command - The composed command.
261
+ * @returns The statements, or null when the command starts no Playwright run that can be placed.
262
+ */
263
+ function runnerOutputStatements(command) {
264
+ const located = locatedCommandSegments(command);
265
+ const at = located.findIndex((entry) => entry.segment !== null && PLAYWRIGHT_RUN.test(entry.segment.text));
266
+ if (at < 0)
267
+ return null;
268
+ const place = (entry, tee) => {
269
+ if (entry.start === null || entry.end === null)
270
+ return null;
271
+ // A statement can end on an enclosing group's closer (`( … 2>&1)`); the closer is the
272
+ // shell's, not the statement's, so it is not part of what a redirect reader may rewrite.
273
+ const end = statementBoundary(command, entry.start, entry.end)?.textEnd ?? entry.end;
274
+ return { start: entry.start, end, tee };
275
+ };
276
+ const runner = located[at];
277
+ const first = runner === undefined ? null : place(runner, false);
278
+ if (first === null)
279
+ return null;
280
+ const statements = [first];
281
+ for (let i = at + 1; i < located.length; i += 1) {
282
+ const entry = located[i];
283
+ // Only a stage of the SAME pipeline carries the run's output; anything else is the tail.
284
+ if (entry === undefined || entry.separator !== '|')
285
+ break;
286
+ const stage = place(entry, entry.segment?.program === 'tee');
287
+ if (stage !== null)
288
+ statements.push(stage);
289
+ }
290
+ return statements;
291
+ }
292
+ /**
293
+ * Readers whose non-flag operands are ALL files: a display of the run's own report. Every one of
294
+ * them is read-only, so moving its operand with the file it reads cannot change what it does.
295
+ */
296
+ const FILE_READERS = new Set(['cat', 'head', 'tail']);
297
+ /**
298
+ * grep-family readers: their first non-flag operand is the pattern, and the words after it are the
299
+ * files they display — unless a `-e`/`-f` flag named the pattern already.
300
+ */
301
+ const GREP_READERS = new Set(['egrep', 'fgrep', 'grep']);
302
+ /** grep's pattern flags: the word after one is a pattern (or a pattern file), never a file. */
303
+ const GREP_PATTERN_FLAGS = new Set(['-e', '--regexp', '-f', '--file']);
304
+ /**
305
+ * The postlude statements that DISPLAY the run's own report, with the exact file that run wrote
306
+ * pointed at the fresh sibling the re-run writes instead.
307
+ *
308
+ * xr-molecule-flash-o03 (2026-10-22; the stale-READ arm m248 filed at +25:55): the recipe the
309
+ * platform handed the executor ended `> /tmp/e2e-local.log.rerun.log 2>&1; echo EXIT=$?; tail -80
310
+ * /tmp/e2e-local.log`. The writer moved to the fresh sibling and the trailing tail did not, so the
311
+ * narrowed re-run displayed the walk it was narrowed FROM — the builder followed that recipe at
312
+ * +19:16 and read the previous walk's failures as the result of the run it had just done.
313
+ *
314
+ * Only the EXACT file a runner statement (or its `tee`) wrote moves, and only in a statement AFTER
315
+ * the run: the setup in front of it keeps every byte (m429), and a read of any other file is left
316
+ * alone. The value is compared whole — `/tmp/full.log` never matches `/tmp/full.log.bak` — and
317
+ * inside shell quotes the operand's own quoting survives (`"/tmp/my walk.log"` stays quoted).
318
+ *
319
+ * The bound is deliberate: a display this reader set does not recognise (a `wc`, a path held in a
320
+ * variable, a path the command builds) is left exactly as written rather than guessed at. Those
321
+ * shapes are not the ones the platform emits, and rewriting a word by resemblance is how m429's
322
+ * setup redirect was corrupted.
323
+ *
324
+ * @param command - The composed command.
325
+ * @param statements - The statements carrying the run's own output.
326
+ * @param fresh - The exact old file → fresh file mapping the redirect edits made.
327
+ * @returns The edits, in statement order.
328
+ */
329
+ function freshReportReadEdits(command, statements, fresh) {
330
+ if (fresh.size === 0)
331
+ return [];
332
+ const after = Math.max(...statements.map((statement) => statement.end));
333
+ const edits = [];
334
+ for (const entry of locatedCommandSegments(command)) {
335
+ const segment = entry.segment;
336
+ const start = entry.start;
337
+ const end = entry.end;
338
+ const programOffset = entry.programOffset;
339
+ // Only a statement the parse placed and that runs AFTER the walk can display its report.
340
+ if (segment === null || start === null || end === null)
341
+ continue;
342
+ if (start < after || programOffset === null)
343
+ continue;
344
+ const allFiles = FILE_READERS.has(segment.program);
345
+ const grep = GREP_READERS.has(segment.program);
346
+ if (!allFiles && !grep)
347
+ continue;
348
+ const text = command.slice(start, end);
349
+ const words = [...text.matchAll(ARGUMENT_WORD)].map((match) => ({
350
+ text: match[0],
351
+ start: start + (match.index ?? 0),
352
+ }));
353
+ // The program word's own span, so the walk starts exactly where the parse says it does.
354
+ const program = words.findIndex((word) => word.start === programOffset);
355
+ if (program < 0)
356
+ continue;
357
+ /** grep still owes its pattern: the first bare operand is that pattern, not a file. */
358
+ let patternPending = grep;
359
+ for (let i = program + 1; i < words.length; i += 1) {
360
+ const word = words[i];
361
+ if (word === undefined)
362
+ break;
363
+ if (ARGUMENT_REDIRECT_OPERATOR.test(word.text)) {
364
+ // The operator's target is where the display WRITES, not a file it reads.
365
+ i += 1;
366
+ continue;
367
+ }
368
+ if (ARGUMENT_REDIRECT.test(word.text))
369
+ continue;
370
+ const flag = unquoted(word.text);
371
+ if (flag.startsWith('-') && flag !== '-') {
372
+ if (grep) {
373
+ if (GREP_PATTERN_FLAGS.has(flag)) {
374
+ patternPending = false;
375
+ i += 1;
376
+ }
377
+ else if (/^(?:--(?:regexp|file)=|-[ef].)/.test(flag))
378
+ patternPending = false;
379
+ }
380
+ continue;
381
+ }
382
+ if (patternPending) {
383
+ patternPending = false;
384
+ continue;
385
+ }
386
+ const to = fresh.get(unquoted(word.text));
387
+ if (to === undefined)
388
+ continue;
389
+ const quote = /^['"]/.test(word.text) ? 1 : 0;
390
+ edits.push({
391
+ start: word.start + quote,
392
+ end: word.start + word.text.length - quote,
393
+ text: to,
394
+ });
395
+ }
396
+ }
397
+ return edits;
398
+ }
399
+ /**
400
+ * The command with the SUITE RUN's own log files pointed at fresh siblings, and nothing else
401
+ * changed.
132
402
  *
133
403
  * A re-run composed from a run's own command keeps the run's redirect by construction, and the
134
404
  * run wrote the walk's only report to that file: xr-molecule-flash-m173 handed over *"Send this
@@ -137,29 +407,52 @@ const DEVICE_TARGET = /^\/dev\//;
137
407
  * full walk's log, destroying the other 11 failures' error reports. Following the platform's own
138
408
  * advice must never destroy the evidence it was based on.
139
409
  *
140
- * @param command - A shell command.
141
- * @returns The command whose file redirects write to a fresh target.
410
+ * Only the run's OWN stream moves. What surrounds it is SETUP and POSTLUDE, and those writes are
411
+ * the command's meaning, not a report: xr-molecule-flash-m429 (2026-10-22) composed
412
+ * `printf '/index.html 200' > app/public/_redirects && npx playwright test specs/auth.spec.ts
413
+ * > /tmp/full.log 2>&1` with its SPA fallback written to `_redirects.rerun.log`, so the re-run
414
+ * measured an app with no fallback at all. A heredoc config, a `.env` the command writes and a
415
+ * source asset it lays down are the same shape: their targets stay byte-for-byte, and so does
416
+ * every statement chained after the run (`; echo EXIT $? > /tmp/done`).
417
+ *
418
+ * Every statement chained after the run (`; echo EXIT $? > /tmp/done`) is postlude and keeps its
419
+ * own writes, but the DISPLAY of the run's report is part of the same run: a `tail`, `head`, `cat`
420
+ * or `grep` after the run that reads the exact file it wrote is pointed at the fresh sibling too
421
+ * ({@link freshReportReadEdits}), so a re-run shows its own output instead of the walk's
422
+ * (xr-molecule-flash-o03).
423
+ *
424
+ * @param command - The composed command.
425
+ * @returns The command whose suite-run redirects write to a fresh target.
142
426
  */
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
- });
427
+ function redirectRunnerOutputsToFreshFiles(command) {
428
+ const statements = runnerOutputStatements(command);
429
+ if (statements === null)
430
+ return command;
431
+ const words = wordSpans(command);
432
+ const edits = [];
433
+ const fresh = new Map();
434
+ for (const statement of statements) {
435
+ const text = command.slice(statement.start, statement.end);
436
+ edits.push(...freshRedirectEdits(text, statement.start, words, fresh));
437
+ if (statement.tee)
438
+ edits.push(...freshTeeEdits(text, statement.start, fresh));
439
+ }
440
+ edits.push(...freshReportReadEdits(command, statements, fresh));
441
+ return applyTextEdits(command, edits);
153
442
  }
154
443
  /**
155
444
  * 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
445
+ * invocation — same launcher, same settings — with the named `file:line`s in place of every
446
+ * positional selection the run carried (a spec file, a suite directory or a regex filter),
447
+ * `--last-failed` dropped (it re-runs the tests that never ran too, X1 flash-m60) and
158
448
  * `--retries=0` set (a re-run of a known failure does not need to pay for retries).
159
449
  *
160
450
  * 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).
451
+ * the target is rewritten to a fresh sibling ({@link redirectRunnerOutputsToFreshFiles}), so a
452
+ * re-run never overwrites the log the run itself is the evidence for (xr-molecule-flash-m173) —
453
+ * and the setup around the run keeps its own file writes untouched (xr-molecule-flash-m429). A
454
+ * `tail`/`head`/`cat`/`grep` chained after the run that reads that exact log is shown the fresh
455
+ * sibling instead, so the re-run displays the run it performed (xr-molecule-flash-o03).
163
456
  *
164
457
  * `config` is the config the run was measured under ({@link withSuiteConfig}): the narrowed re-run
165
458
  * carries it whenever the command itself does not, so a run that dropped `--config` cannot be
@@ -215,8 +508,10 @@ export function scopedRerunCommand(command, locations, excluded = [], config, cw
215
508
  // nothing — "No tests found" — so every location is rendered in a form Playwright can match
216
509
  // ({@link playwrightLocations}).
217
510
  const narrowed = playwrightLocations(locations, command, cwd);
218
- const dropped = dropSupersededSpecFiles(args, narrowed)
219
- .replace(/\s*--last-failed\b/g, '')
511
+ const dropped = dropSupersededSelections(args, narrowed)
512
+ // `--last-failed` re-runs the tests that never ran too (flash-m60); its `--last-failed-file`
513
+ // sibling only moves the file it reads and is a real option of the re-run.
514
+ .replace(/\s*--last-failed(?!-)/g, '')
220
515
  .replace(/\s*--retries(?:[= ]\d+)?/g, '');
221
516
  const scoped = command.slice(0, run.afterRunner) +
222
517
  ` ${narrowed.join(' ')} --retries=0${carry}${invert}` +
@@ -226,7 +521,7 @@ export function scopedRerunCommand(command, locations, excluded = [], config, cw
226
521
  // X1 xr-molecule-flash-m157 (+14:02) was handed a re-run that still carried the chain's second
227
522
  // whole walk — 4.5 minutes measuring code it had not changed.
228
523
  const rerunTail = suiteRunStatements(tail).length > 0 ? '' : tail;
229
- return withRunEnv(`${redirectToFreshFile(scoped)}${redirectToFreshFile(rerunTail)}`, env);
524
+ return withRunEnv(redirectRunnerOutputsToFreshFiles(`${scoped}${rerunTail}`), env);
230
525
  }
231
526
  /** A `NAME=value` word in command position, optionally behind `export`/`env`. */
232
527
  const ENV_WORD = /(^|[\s;&|(])(?:export[ \t]+|env[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=('[^']*'|"[^"]*"|[^\s;&|]+)/g;
@@ -366,33 +661,145 @@ function playwrightLocations(locations, command, cwd) {
366
661
  return `${resolved}${suffix}`;
367
662
  });
368
663
  }
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
664
  /**
372
- * The runner's own arguments with every bare spec-file argument taken out once the re-run names
665
+ * Every Playwright `test` option that takes its value from the NEXT word, read from the runner's
666
+ * own option table (`playwright test --help`, Playwright 1.63 as the sandbox image ships it).
667
+ *
668
+ * A value here is never a positional selection however much it looks like a path:
669
+ * `--config playwright.config.ts` names the config the run was measured under, and dropping it
670
+ * because it ends in `.ts` would re-create m113 exactly (a re-run with no base URL).
671
+ */
672
+ const SUITE_VALUE_FLAGS = new Set([
673
+ '--add-reporter',
674
+ '--browser',
675
+ '-c',
676
+ '--config',
677
+ '--global-timeout',
678
+ '-g',
679
+ '--grep',
680
+ '-G',
681
+ '--grep-invert',
682
+ '--last-failed-file',
683
+ '--max-failures',
684
+ '--output',
685
+ '--repeat-each',
686
+ '--reporter',
687
+ '--retries',
688
+ '--run-agents',
689
+ '--shard',
690
+ '--test-list',
691
+ '--test-list-invert',
692
+ '--timeout',
693
+ '--trace',
694
+ '--tsconfig',
695
+ '--ui-host',
696
+ '--ui-port',
697
+ '--update-source-method',
698
+ '-j',
699
+ '--workers',
700
+ ]);
701
+ /**
702
+ * Options whose value is optional (`--debug [mode]`, `--only-changed [ref]`): the next word is
703
+ * theirs unless it is another flag — the reading the runner's own command line takes.
704
+ */
705
+ const SUITE_OPTIONAL_VALUE_FLAGS = new Set([
706
+ '--debug',
707
+ '--only-changed',
708
+ '-u',
709
+ '--update-snapshots',
710
+ ]);
711
+ /**
712
+ * `--project <project-name...>` takes every word up to the next flag, as the runner's own command
713
+ * line does; a project name is a selection of projects, never a positional test filter.
714
+ */
715
+ const SUITE_VARIADIC_VALUE_FLAGS = new Set(['--project']);
716
+ /** A redirect written as one word (`2>&1`, `>/tmp/out.log`): not a selection. */
717
+ const ARGUMENT_REDIRECT = /^(?:\d*|&)(?:<<<|<<-?|>>?\|?|<>|>&|<&)(?:&?\d+|-|[^\s;&|()<>]*)?$/;
718
+ /** A redirect operator whose target is the word after it (`> /tmp/out.log`). */
719
+ const ARGUMENT_REDIRECT_OPERATOR = /^(?:\d*|&)(?:<<<|<<-?|>>?\|?|<>|>&|<&)$/;
720
+ /**
721
+ * The runner's own arguments with every positional selection taken out once the re-run names
373
722
  * failure locations.
374
723
  *
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.
724
+ * The re-run is "just these", and Playwright OR-s every positional argument into one selection:
725
+ * ANY leftover positional re-admits tests the re-run is supposed to skip. A bare spec file
726
+ * re-admits all of that file's tests (xr-molecule-flash-m118 (+45:24) ~297 s for one file and
727
+ * fourteen duplicates; xr-molecule-flash-m134 (+20:0x) 32 tests instead of 8). A suite DIRECTORY
728
+ * is the same argument and re-admits the WHOLE walk even beside fourteen exact `file:line`s —
729
+ * xr-molecule-flash-m428's suggested "Re-run just these" kept
730
+ * `/workspace/fixtures/realworld/specs/e2e` after `--config playwright.config.ts`, so the command
731
+ * said 14 locations and selected every spec again. A regex positional filter selects files the
732
+ * same way and goes too.
733
+ *
734
+ * What is NOT a selection survives byte-for-byte: every option and its value — whatever it looks
735
+ * like (`--config playwright.config.ts`, `--output test-results`, `--grep 'a|b'`, a `--project`
736
+ * list) — and every redirect. Quoting and spacing survive because the result is a span splice on
737
+ * the raw text, never a rebuild from words.
381
738
  *
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.
739
+ * An empty `locations` list keeps the original selection untouched:
740
+ * {@link scopedRerunCommand} is not narrowing anything then.
385
741
  *
386
742
  * @param args - The run's own arguments, after `playwright test`.
387
743
  * @param locations - The failing `file:line`s the re-run names.
388
- * @returns The arguments without their bare spec-file arguments.
744
+ * @returns The arguments without their positional selections.
389
745
  */
390
- function dropSupersededSpecFiles(args, locations) {
746
+ function dropSupersededSelections(args, locations) {
391
747
  if (locations.length === 0)
392
748
  return args;
393
- return args.replace(/(^|\s+)([^\s]+)(?=\s|$)/g, (whole, _space, token) => {
394
- return SPEC_FILE_ARGUMENT.test(token) ? '' : whole;
395
- });
749
+ const words = [];
750
+ for (const match of args.matchAll(ARGUMENT_WORD))
751
+ words.push({ text: match[0], start: match.index ?? 0 });
752
+ const dropped = new Array(words.length).fill(false);
753
+ let at = 0;
754
+ while (at < words.length) {
755
+ const word = words[at]?.text ?? '';
756
+ if (ARGUMENT_REDIRECT_OPERATOR.test(word)) {
757
+ // The operator and the target after it are one redirect, not two words to judge.
758
+ at += 2;
759
+ continue;
760
+ }
761
+ if (ARGUMENT_REDIRECT.test(word)) {
762
+ at += 1;
763
+ continue;
764
+ }
765
+ if (word.startsWith('-') && word !== '-') {
766
+ // `--flag=value` carries its value inside the same word; only the flag's own name is read.
767
+ if (word.includes('=')) {
768
+ at += 1;
769
+ continue;
770
+ }
771
+ if (SUITE_OPTIONAL_VALUE_FLAGS.has(word)) {
772
+ const next = words[at + 1]?.text;
773
+ at += next !== undefined && !next.startsWith('-') ? 2 : 1;
774
+ continue;
775
+ }
776
+ if (SUITE_VARIADIC_VALUE_FLAGS.has(word)) {
777
+ at += 1;
778
+ while (at < words.length && !(words[at]?.text ?? '').startsWith('-'))
779
+ at += 1;
780
+ continue;
781
+ }
782
+ at += SUITE_VALUE_FLAGS.has(word) ? 2 : 1;
783
+ continue;
784
+ }
785
+ // A bare word — a spec file, a directory or a regex filter — is a positional selection.
786
+ dropped[at] = true;
787
+ at += 1;
788
+ }
789
+ let out = '';
790
+ let cursor = 0;
791
+ for (let i = 0; i < words.length; i += 1) {
792
+ const word = words[i];
793
+ if (word === undefined || !dropped[i])
794
+ continue;
795
+ // The whitespace in front of the word goes with it, so the result is not left double-spaced.
796
+ let start = word.start;
797
+ while (start > cursor && /\s/.test(args[start - 1] ?? ''))
798
+ start -= 1;
799
+ out += args.slice(cursor, start);
800
+ cursor = word.start + word.text.length;
801
+ }
802
+ return out + args.slice(cursor);
396
803
  }
397
804
  /**
398
805
  * The run in flight that makes a second run a hazard, or null when nothing is in the way.
package/dist/tools.d.ts CHANGED
@@ -59,6 +59,21 @@ export declare const SUITE_OVER_BUDGET_ADVICE: string;
59
59
  * @returns The note, or null when the path is not inside a package's `dist`.
60
60
  */
61
61
  export declare function moleculeDistReadNote(path: string): string | null;
62
+ /**
63
+ * The command that reads a REGISTERED background task's own log as one bounded contiguous window
64
+ * ({@link runningLogReadCommand}), with the file its launch redirects to when the launch recorded
65
+ * one.
66
+ *
67
+ * The task id is the ownership: it is the handle `wait_for_task` returned, `/tmp/${taskId}.log` is
68
+ * the log this platform's launcher writes for it, and the redirect target is the file this
69
+ * process's own launch registered for that id — never a path parsed out of a command by the
70
+ * caller. A diagnosis that reads a running walk must not build a `/tmp` path of its own
71
+ * (xr-molecule-flash-l07: the wait's DISPLAY was diagnosed as if it were the log).
72
+ *
73
+ * @param taskId - The `mol-bg-…` handle the wait returned.
74
+ * @returns The read command, or null when the handle is not a task id this platform minted.
75
+ */
76
+ export declare function registeredTaskLogCommand(taskId: string): string | null;
62
77
  /**
63
78
  * What the filesystem says about a write target under `node_modules` — the half
64
79
  * of the decision a path's spelling cannot make.
@@ -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;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"}
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;AAiInE;;;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;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGtE;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
@@ -703,7 +703,7 @@ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
703
703
  }
704
704
  import { suiteCommandTarget } from './suite-overlap.js';
705
705
  import { diagnosisTraceCommand } from './trace-artifacts.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';
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, runningLogReadCommand, 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';
707
707
  /**
708
708
  * How long exec_command waits for a command it backgrounded only because the
709
709
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -840,6 +840,25 @@ export function moleculeDistReadNote(path) {
840
840
  ? ' The declarations above are generated from it.'
841
841
  : ' For one signature, read the matching .d.ts file.'));
842
842
  }
843
+ /**
844
+ * The command that reads a REGISTERED background task's own log as one bounded contiguous window
845
+ * ({@link runningLogReadCommand}), with the file its launch redirects to when the launch recorded
846
+ * one.
847
+ *
848
+ * The task id is the ownership: it is the handle `wait_for_task` returned, `/tmp/${taskId}.log` is
849
+ * the log this platform's launcher writes for it, and the redirect target is the file this
850
+ * process's own launch registered for that id — never a path parsed out of a command by the
851
+ * caller. A diagnosis that reads a running walk must not build a `/tmp` path of its own
852
+ * (xr-molecule-flash-l07: the wait's DISPLAY was diagnosed as if it were the log).
853
+ *
854
+ * @param taskId - The `mol-bg-…` handle the wait returned.
855
+ * @returns The read command, or null when the handle is not a task id this platform minted.
856
+ */
857
+ export function registeredTaskLogCommand(taskId) {
858
+ if (!TASK_ID.test(taskId))
859
+ return null;
860
+ return runningLogReadCommand(`/tmp/${taskId}.log`, outputFileByTask.get(taskId));
861
+ }
843
862
  /**
844
863
  * The `node_modules` entry a path goes through, and the package name after it
845
864
  * (`null` when the path IS the entry).