@molecule/api-ai-tools 1.7.0 → 1.8.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/cut-run.js CHANGED
@@ -24,6 +24,12 @@
24
24
  * `backends/sandbox.ts`), which reports 124 whatever the pipeline did, and returns everything
25
25
  * printed until then. The budget is the tool's own, so its number belongs to the tool.
26
26
  *
27
+ * A runner's closing summary is what separates "used the bound and finished" from "stopped at the
28
+ * bound", but it is only half the evidence: Playwright prints one when its process is stopped too,
29
+ * listing just the tests it reached. So a summary is a finish only when it accounts for the total
30
+ * the runner announced before it started ({@link suiteSummary}) — a summary that does not
31
+ * reconcile is a stopped run like any other, and the result says so.
32
+ *
27
33
  * @module
28
34
  */
29
35
  import { commandTimeoutSeconds } from './command-position.js';
@@ -54,7 +60,8 @@ export function commandRunState(result) {
54
60
  * not a tolerance: a command killed at its bound reports AT LEAST the bound (the shell's own
55
61
  * bookkeeping runs after the kill, and this tool's round trip is on top), so any slack would only
56
62
  * make a SHORT bound fire on a run that finished inside it (`timeout 5 curl …` answering in 3 s).
57
- * A run that finished near its bound carried its runner's closing summary — {@link suiteTally}.
63
+ * A run that finished near its bound carried a summary that reconciles with its announced total —
64
+ * {@link suiteSummary}.
58
65
  *
59
66
  * @param boundSeconds - The bound the run was allowed.
60
67
  * @returns The elapsed ms a run stopped at that bound reports at least.
@@ -62,23 +69,126 @@ export function commandRunState(result) {
62
69
  function atBoundMs(boundSeconds) {
63
70
  return boundSeconds * 1000;
64
71
  }
72
+ /** A runner's announcement of the whole walk — Playwright's `Running 139 tests using 1 worker`. */
73
+ const ANNOUNCED_TOTAL = /^\s*Running\s+(\d+)\s+tests?\s+using\b/gm;
74
+ /**
75
+ * One line of Playwright's end-of-run tally — ` 1 failed`, ` 36 passed (2.5m)`,
76
+ * ` 1 did not run`.
77
+ */
78
+ const SUMMARY_STATUS = /^\s*(\d+)\s+(passed|failed|flaky|skipped|interrupted|timed out|did not run)\b/gm;
79
+ /**
80
+ * Vitest's end-of-run tally — `Tests 1 failed | 149 passed (151)` — whose parenthesised number is
81
+ * the total the line accounts for, and so its own announcement ({@link SuiteSummary.announced}).
82
+ */
83
+ const VITEST_TESTS = /^\s*Tests\s+(.+?)\s+\((\d+)\)\s*$/gm;
84
+ /** One status inside Vitest's `Tests` line — `149 passed`. */
85
+ const VITEST_STATUS = /(\d+)\s+(passed|failed|skipped|todo)\b/g;
86
+ /** Hurl's closing block, printed only by a run that walked its whole file list. */
87
+ const HURL_TALLY = /\bExecuted files:\s*\d+/;
65
88
  /**
66
- * A runner's closing summary, printed only by a run that walked its whole suite: Hurl's
67
- * `Executed files:` block and the `N passed` / `N failed` / `N flaky` line Playwright and Vitest
68
- * print when they finish.
89
+ * The marker {@link truncateMiddle} puts before the tally lines it restores — read only when the
90
+ * truncated text carries no tally line of its own.
91
+ */
92
+ const RESTORED_TALLY = /\[run tally\]\s*([^\n]*)/g;
93
+ /** One status inside a restored tally line — `36 passed`. */
94
+ const RESTORED_STATUS = /(\d+)\s+(passed|failed|flaky|skipped|interrupted|timed out|did not run)\b/g;
95
+ /**
96
+ * Add the statuses in `text` to a running tally.
97
+ *
98
+ * Every status counts the same way: a test the runner skipped or left "did not run" was still
99
+ * reached, which is what a stopped run's count measures.
69
100
  *
70
- * This is what separates "stopped at the bound" from "used the bound and finished": a suite that
71
- * printed its summary ran to its end, and is read as the run it was.
101
+ * @param text - A tally line, or a block of them.
102
+ * @param re - The status pattern to read them with.
103
+ * @param counts - The tally to add to.
72
104
  */
73
- const SUITE_TALLY = /\bExecuted files:\s*\d+|\b\d+\s+(?:passed|failed|flaky|skipped)\b/;
105
+ function addStatuses(text, re, counts) {
106
+ for (const m of text.matchAll(re)) {
107
+ counts.saw = true;
108
+ counts.accounted += Number(m[1]);
109
+ }
110
+ }
74
111
  /**
75
- * Whether text carries a runner's closing summary ({@link SUITE_TALLY}).
112
+ * What the runner's closing summary accounts for, or null when it printed none.
113
+ *
114
+ * Three runners' summaries are read, each by its own wording: Playwright's per-status lines
115
+ * (reconciled against its `Running N tests` announcement), Vitest's single `Tests …` line (its own
116
+ * parenthesised total), and Hurl's `Executed files:` block (which it prints only at the end, and
117
+ * which announces no total — so it is a finish by construction).
118
+ *
119
+ * @param text - The run's stdout and stderr, RAW (a truncated copy can lose the announcement).
120
+ * @returns The summary, or null when the text carries none.
121
+ */
122
+ export function suiteSummary(text) {
123
+ if (typeof text !== 'string' || !text)
124
+ return null;
125
+ let announced = null;
126
+ for (const m of text.matchAll(ANNOUNCED_TOTAL))
127
+ announced = Number(m[1]);
128
+ const counts = { accounted: 0, saw: false };
129
+ addStatuses(text, SUMMARY_STATUS, counts);
130
+ if (!counts.saw) {
131
+ // Only when no tally line of its own survived: the restored line is a copy of lines that may
132
+ // also be present, and adding both would over-count a cut run into a finish.
133
+ for (const m of text.matchAll(RESTORED_TALLY))
134
+ addStatuses(m[1] ?? '', RESTORED_STATUS, counts);
135
+ }
136
+ if (counts.saw) {
137
+ return {
138
+ announced,
139
+ accounted: counts.accounted,
140
+ finished: announced === null || counts.accounted >= announced,
141
+ };
142
+ }
143
+ const vitest = [...text.matchAll(VITEST_TESTS)].at(-1);
144
+ if (vitest) {
145
+ const total = Number(vitest[2]);
146
+ const line = { accounted: 0, saw: true };
147
+ addStatuses(vitest[1] ?? '', VITEST_STATUS, line);
148
+ return { announced: total, accounted: line.accounted, finished: line.accounted >= total };
149
+ }
150
+ // Hurl prints `Executed files:` only after walking its whole list.
151
+ if (HURL_TALLY.test(text))
152
+ return { announced: null, accounted: 0, finished: true };
153
+ return null;
154
+ }
155
+ /**
156
+ * Whether text carries a runner's closing summary.
157
+ *
158
+ * A summary is NOT a finish on its own — a stopped Playwright run prints one too. Use
159
+ * {@link suiteSummary} and its `finished` for that.
76
160
  *
77
161
  * @param text - The run's stdout and stderr.
78
162
  * @returns True when the runner's own end-of-run summary is there.
79
163
  */
80
164
  export function suiteTally(text) {
81
- return SUITE_TALLY.test(text);
165
+ return suiteSummary(text) !== null;
166
+ }
167
+ /** A failure count in a runner's own tally — `1 failed`, `Tests 1 failed | 149 passed (151)`. */
168
+ const FAILED_TALLY = /\b[1-9]\d*\s+failed\b/;
169
+ /**
170
+ * The exit code a finished suite run may report.
171
+ *
172
+ * The code in a background result is the code of the command's LAST pipeline stage. When a stage
173
+ * after the suite runner still consumes its output (`| tee run.log`, or a filter the platform could
174
+ * not prove output-only), that stage produced the code, not the run — and `… | grep … | tail -20`
175
+ * exited 0 for a run with a failure in it (xr-molecule-flash-m101 +25:54). No code is reported
176
+ * then: the log's own tally decides.
177
+ *
178
+ * The same reading covers a code of 0 beside a tally that names a failure: whatever ended the
179
+ * pipeline, a run whose log counts a failure did not pass.
180
+ *
181
+ * @param exitCode - The code the pipeline reported (null when it has none).
182
+ * @param output - The run's full log.
183
+ * @param piped - Whether a pipeline stage after the suite runner still consumes its output.
184
+ * @returns The code the run may report, or null when the code is the filter's or contradicts the tally.
185
+ */
186
+ export function suiteRunExitCode(exitCode, output, piped) {
187
+ if (piped)
188
+ return null;
189
+ if (exitCode === 0 && FAILED_TALLY.test(output))
190
+ return null;
191
+ return exitCode;
82
192
  }
83
193
  /** The last thing a run printed: its last non-blank line, as one line and short enough to quote. */
84
194
  function lastPrinted(text) {
@@ -98,19 +208,26 @@ const PROGRESS_LINE = /^\s*\[(\d+)\/(\d+)\]/gm;
98
208
  * What the runner's own output says the run got through.
99
209
  *
100
210
  * Hurl prints a line per finished file and no total unless it finished; Playwright's line reporter
101
- * prints `[n/K]` with both numbers. Neither is inferred from the command — a run that printed
102
- * nothing says nothing, which is what `null` means here.
211
+ * prints `[n/K]` with both numbers, and its end-of-run summary (with the `Running N tests` total it
212
+ * is reconciled against) says how far a stopped walk got. Neither is inferred from the command — a
213
+ * run that printed nothing says nothing, which is what `null` means here.
103
214
  *
104
- * @param text - The run's stdout and stderr.
215
+ * @param text - The run's stdout and stderr, raw.
105
216
  * @param suite - Whether the command runs a suite.
106
- * @returns How many finished, how many the run was walking, and the noun.
217
+ * @param summary - The closing summary the text carries, when it carries one.
218
+ * @returns How far it got, how many it was walking, and the noun.
107
219
  */
108
- function runProgress(text, suite) {
220
+ function runProgress(text, suite, summary) {
109
221
  const files = new Set();
110
222
  for (const m of text.matchAll(HURL_FILE_LINE))
111
223
  files.add(m[1] ?? '');
112
224
  if (files.size > 0)
113
225
  return { ran: files.size, total: null, noun: 'files' };
226
+ // A summary that does not account for its announced total: both numbers come from the runner's
227
+ // own lines, so the sentence can say `Ran 38 of 139` without the caller counting anything.
228
+ if (summary && summary.announced !== null) {
229
+ return { ran: summary.accounted, total: summary.announced, noun: 'tests' };
230
+ }
114
231
  let ran = null;
115
232
  let total = null;
116
233
  for (const m of text.matchAll(PROGRESS_LINE)) {
@@ -127,6 +244,9 @@ function runProgress(text, suite) {
127
244
  *
128
245
  * Evidence, in order — never a guess:
129
246
  *
247
+ * - the runner's own summary, when it printed one: it is a finish only if it accounts for the total
248
+ * the runner announced. A summary that accounts for fewer is a walk a process stopped early, and
249
+ * falls through to the branches below with its own counts ({@link suiteSummary}).
130
250
  * - `timeout`'s own 124 (the code the backend's wrapper returns whenever the TOOL's budget fired,
131
251
  * whatever the pipeline ended with) — and, when the clock says the command's own smaller bound
132
252
  * ran out first and less than the budget, that bound instead.
@@ -135,14 +255,19 @@ function runProgress(text, suite) {
135
255
  * killed at its own bound. Its exit code, when the pipeline handed one back, is the filter's.
136
256
  * - the run used this tool's whole budget behind a filter that withheld its output, and printed no
137
257
  * closing summary: the budget stopped it, and no code in the result is the run's.
258
+ * - a summary that does not account for its announced total, with neither bound fired: something
259
+ * outside this platform stopped the process ({@link CutBy} `process-stop`).
138
260
  *
139
261
  * @param command - The command the executor sent.
140
262
  * @param result - Its result.
141
263
  * @param elapsedMs - How long the command ran, in ms.
142
264
  * @param budgetSeconds - This tool's foreground budget, in seconds (0 when it has none).
265
+ * @param rawOutput - The run's output BEFORE truncation, when the caller has it: the announcement
266
+ * a summary is reconciled against is the first line a long run prints, which is exactly what a
267
+ * size cap drops. Detection reads this; the sentence's last line still comes from `result`.
143
268
  * @returns The stopped run, or null.
144
269
  */
145
- export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0) {
270
+ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0, rawOutput) {
146
271
  if (typeof command !== 'string' || !command.trim())
147
272
  return null;
148
273
  if (result === null || typeof result !== 'object')
@@ -152,12 +277,18 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0) {
152
277
  const r = result;
153
278
  const exitCode = typeof r.exitCode === 'number' ? r.exitCode : null;
154
279
  const text = `${String(r.stdout ?? '')}\n${String(r.stderr ?? '')}`;
155
- // A suite that printed its summary ran to its end, whatever it was wrapped in.
156
- if (suiteTally(text))
280
+ const detected = typeof rawOutput === 'string' ? rawOutput : text;
281
+ // A runner's summary is a finish only when it accounts for the total the runner announced. A run
282
+ // stopped at 36 of 139 prints the same `36 passed` line a finished run does (X1 xr-molecule-flash-
283
+ // m101 +12:13 read `36 passed` as `exitCode: 0` and moved on), so the announcement decides.
284
+ const summary = suiteSummary(detected);
285
+ // Only a summary that ACCOUNTS for the announced total ends the search for a stop. No summary
286
+ // at all is not a finish — it is the case the bounds below exist for.
287
+ if (summary?.finished)
157
288
  return null;
158
289
  const suite = isSuiteRunCommand(command);
159
290
  const withheld = /\|\s*(?:tail|head|grep|egrep|fgrep|sort|uniq|wc|jq)\b/.test(command);
160
- const progress = runProgress(text, suite);
291
+ const progress = runProgress(detected, suite, summary);
161
292
  const doing = lastPrinted(text);
162
293
  const declared = commandTimeoutSeconds(command);
163
294
  // A command whose own bound is inside this tool's budget is stopped by ITS bound: a larger one
@@ -203,6 +334,21 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0) {
203
334
  doing,
204
335
  };
205
336
  }
337
+ // The runner printed a summary that does not account for the total it announced, and neither
338
+ // bound above fired: the process was stopped from outside the run. A run that used the whole
339
+ // budget is the backend's deadline (which sits just past it); anything else is a stop this tool
340
+ // does not own. Either way the summary counts are all that ran, and a pipeline's 0 is not the
341
+ // run's code — the run never reached its own verdict.
342
+ if (summary !== null && summary.announced !== null) {
343
+ const usedBudget = budgetSeconds > 0 && elapsedMs >= atBoundMs(budgetSeconds);
344
+ return {
345
+ seconds: usedBudget ? budgetSeconds : Math.max(1, Math.round(elapsedMs / 1000)),
346
+ stoppedBy: usedBudget ? 'tool-budget' : 'process-stop',
347
+ exitCode: withheld || exitCode === 0 ? null : exitCode,
348
+ ...progress,
349
+ doing,
350
+ };
351
+ }
206
352
  return null;
207
353
  }
208
354
  /**
@@ -239,29 +385,36 @@ export function cutRunFacts(result) {
239
385
  };
240
386
  }
241
387
  /**
242
- * The first line of a stopped run's result: how long it was allowed, what stopped it, how far it
243
- * got, and the last thing it printed.
388
+ * The first line of a stopped run's result: how far it got, what stopped it, the last thing it
389
+ * printed, and what the reader must do next.
244
390
  *
245
391
  * Written to be quotable by the model in one sentence and to leave no room for "nearly passing":
246
392
  * the counts come from the runner's own output ({@link runProgress}), and a `total` the caller
247
- * read elsewhere (the suite's own file count) fills in the "M of K" it can then state.
393
+ * read elsewhere (the suite's own file count) fills in the "of N" it can then state. The sentence
394
+ * says how many tests the stop left unrun — a number a reader cannot round up to "the suite
395
+ * passed" — and hands over the one move that survives a long run: run it in the background.
248
396
  *
249
397
  * @param run - The stopped run.
250
398
  * @returns The sentence.
251
399
  */
252
400
  export function cutRunSentence(run) {
253
- const by = run.stoppedBy === 'tool-budget' ? "this tool's limit" : `its own \`timeout ${run.seconds}\``;
254
- const lead = `This run was stopped after ${run.seconds}s (${by})`;
255
- const count = run.ran !== null && run.total !== null
256
- ? `: ${run.ran} of ${run.total} ${run.noun} ran, the rest did not run`
401
+ const by = run.stoppedBy === 'tool-budget'
402
+ ? "this tool's limit"
403
+ : run.stoppedBy === 'command-timeout'
404
+ ? `its own \`timeout ${run.seconds}\``
405
+ : 'a process kill outside this tool (no `timeout` of its own and no limit of this tool had fired)';
406
+ const background = 'run it in the background (run_in_background + wait_for_task)';
407
+ const missing = run.ran !== null && run.total !== null ? run.total - run.ran : null;
408
+ const lead = missing !== null
409
+ ? `Ran ${run.ran} of ${run.total} ${run.noun} in ${run.seconds} s and was stopped by ${by} — the ${missing} it never reached ${missing === 1 ? 'has' : 'have'} not run; ${background}`
257
410
  : run.ran !== null
258
- ? `: ${run.ran} ${run.noun} ran and the rest did not run`
259
- : ' before it finished';
260
- const doing = run.doing ? `; the last thing it was doing: ${run.doing}` : '';
411
+ ? `Ran ${run.ran} ${run.noun} in ${run.seconds} s and was stopped by ${by} — the rest have not run; ${background}`
412
+ : `This run was stopped after ${run.seconds}s (${by}) before it finished; ${background}`;
413
+ const doing = run.doing ? ` The last thing it was doing: ${run.doing}.` : '';
261
414
  // Why the result carries no code: without this the model reads the missing field as an
262
415
  // omission, and the number it used to read there was the pipeline's own 0.
263
416
  const code = run.exitCode === null
264
417
  ? ' It has no exit code of its own to report — the one this pipeline produced belongs to the filter, not to the run.'
265
418
  : '';
266
- return `${lead}${count}${doing}.${code}`;
419
+ return `${lead}.${doing}${code}`;
267
420
  }
@@ -60,9 +60,13 @@ export declare function suiteCommandTarget(command: string): string;
60
60
  * @param locations - The failing `file:line`s to re-run, as the run's own log printed them — or,
61
61
  * for a run that started outside the suite, the absolute form Playwright matches each file
62
62
  * argument against.
63
+ * @param excluded - The test titles the request carves out. They are inverted out
64
+ * (`--grep-invert`) so a re-run of the failing locations cannot report one of them again —
65
+ * xr-molecule-flash-m101 re-ran the whole 139-test suite and spent ~4 minutes to be told about
66
+ * a test the request had already said does not count.
63
67
  * @returns The scoped command, or the command unchanged when it runs no Playwright suite.
64
68
  */
65
- export declare function scopedRerunCommand(command: string, locations: readonly string[]): string;
69
+ export declare function scopedRerunCommand(command: string, locations: readonly string[], excluded?: readonly string[]): string;
66
70
  /**
67
71
  * One suite run, as the rule that decides whether a second may start beside it reads it.
68
72
  *
@@ -1 +1 @@
1
- {"version":3,"file":"suite-overlap.d.ts","sourceRoot":"","sources":["../src/suite-overlap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAKH;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAoB1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAexF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+FAA+F;IAC/F,SAAS,EAAE,OAAO,CAAA;IAClB,oFAAoF;IACpF,MAAM,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,gBAAgB,EACzD,SAAS,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EAC7B,QAAQ,EAAE,SAAS,CAAC,EAAE,GACrB,CAAC,GAAG,IAAI,CAMV;AAqDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAQvD"}
1
+ {"version":3,"file":"suite-overlap.d.ts","sourceRoot":"","sources":["../src/suite-overlap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAMH;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAoB1D;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,QAAQ,GAAE,SAAS,MAAM,EAAO,GAC/B,MAAM,CA2BR;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+FAA+F;IAC/F,SAAS,EAAE,OAAO,CAAA;IAClB,oFAAoF;IACpF,MAAM,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,gBAAgB,EACzD,SAAS,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EAC7B,QAAQ,EAAE,SAAS,CAAC,EAAE,GACrB,CAAC,GAAG,IAAI,CAMV;AAqDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAQvD"}
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { commandSegments } from './command-position.js';
32
32
  import { commandRunsNoTests } from './no-test-run.js';
33
+ import { shellQuote } from './utilities.js';
33
34
  /**
34
35
  * A command with its heredoc bodies removed: the runs, kills and hosts it names in a file it
35
36
  * WRITES are not what the command itself does.
@@ -83,9 +84,13 @@ export function suiteCommandTarget(command) {
83
84
  * @param locations - The failing `file:line`s to re-run, as the run's own log printed them — or,
84
85
  * for a run that started outside the suite, the absolute form Playwright matches each file
85
86
  * argument against.
87
+ * @param excluded - The test titles the request carves out. They are inverted out
88
+ * (`--grep-invert`) so a re-run of the failing locations cannot report one of them again —
89
+ * xr-molecule-flash-m101 re-ran the whole 139-test suite and spent ~4 minutes to be told about
90
+ * a test the request had already said does not count.
86
91
  * @returns The scoped command, or the command unchanged when it runs no Playwright suite.
87
92
  */
88
- export function scopedRerunCommand(command, locations) {
93
+ export function scopedRerunCommand(command, locations, excluded = []) {
89
94
  const at = command.search(/\bplaywright\s+test\b/);
90
95
  if (at === -1)
91
96
  return command;
@@ -96,10 +101,19 @@ export function scopedRerunCommand(command, locations) {
96
101
  const end = rest.search(/&&|\|\||;|\|/);
97
102
  const statement = end === -1 ? rest : rest.slice(0, end);
98
103
  const tail = end === -1 ? '' : rest.slice(end);
104
+ // One literal alternation, regex-escaped and shell-quoted: a title carries parentheses and
105
+ // quotes (`should only allow comment author to delete`) that are regex and shell syntax alike.
106
+ const invert = excluded.length > 0 && !/--grep-invert\b/.test(command)
107
+ ? ` --grep-invert ${shellQuote(excluded
108
+ .map((t) => t.replace(/\s+/g, ' ').trim())
109
+ .filter(Boolean)
110
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
111
+ .join('|'))}`
112
+ : '';
99
113
  const scoped = statement
100
114
  .replace(/\s*--last-failed\b/g, '')
101
115
  .replace(/\s*--retries(?:[= ]\d+)?/g, '')
102
- .replace(/^(playwright\s+test)\b/, `$1 ${locations.join(' ')} --retries=0`);
116
+ .replace(/^(playwright\s+test)\b/, `$1 ${locations.join(' ')} --retries=0${invert}`);
103
117
  return `${head}${scoped}${tail}`;
104
118
  }
105
119
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AA+F9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAyHD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAiEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBxE;AAgDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAovExF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AA0G9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAyHD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAkEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBxE;AAgDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAyzExF"}
package/dist/tools.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { commandCutRun, CUT_AT_BUDGET, cutRunSentence } from './cut-run.js';
9
+ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence, suiteRunExitCode } from './cut-run.js';
10
10
  import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
11
11
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
12
12
  import { TOOL_SCHEMAS } from './schemas.js';
@@ -36,6 +36,14 @@ const outputFileByTask = new Map();
36
36
  * resolves the failing test's `file:line` against the wrong suite.
37
37
  */
38
38
  const runByTask = new Map();
39
+ /**
40
+ * Why a finished suite run's result carries no exit code: the code a pipeline reports is its last
41
+ * stage's, and a stage after the runner (or a code of 0 beside a failure in the tally) is not the
42
+ * run's verdict. Said in the result so the missing field does not read as an omission.
43
+ */
44
+ const FILTERED_EXIT_NOTE = 'No exit code is reported for this run: a stage after the test runner produced the pipeline’s ' +
45
+ 'code (or the code says 0 beside a failure in the tally), so read the pass/fail tally in the ' +
46
+ 'log instead.';
39
47
  /**
40
48
  * Task ids whose own-output file has already been looked for this process.
41
49
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -222,7 +230,7 @@ function runningNote(clauses) {
222
230
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
223
231
  }
224
232
  import { diagnosisTraceCommand } from './trace-artifacts.js';
225
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, 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, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
233
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, 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, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPipedAfterRunner, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
226
234
  /**
227
235
  * How long exec_command waits for a command it backgrounded only because the
228
236
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -377,7 +385,7 @@ function batchFailureError(path, total, failures) {
377
385
  * @returns Array of AITool objects ready to pass to an AI provider
378
386
  */
379
387
  export function buildTools(backend, config) {
380
- const { include, exclude, pathGuards = true, symlinkGuards = false, redactSecrets: doRedact = true, blockDangerousCommands = false, blockCommand, execTimeoutMs = 120_000, commandBudgetMs, overCeilingGraceMs = OVER_CEILING_GRACE_MS, searchExcludedDirs, outlineLargeHtml, onAfterWrite, onFileDiff, onFileChange, } = config ?? {};
388
+ const { include, exclude, pathGuards = true, symlinkGuards = false, redactSecrets: doRedact = true, blockDangerousCommands = false, blockCommand, execTimeoutMs = 120_000, commandBudgetMs, overCeilingGraceMs = OVER_CEILING_GRACE_MS, searchExcludedDirs, outlineLargeHtml, excludedTestTitles, onAfterWrite, onFileDiff, onFileChange, } = config ?? {};
381
389
  const root = backend.projectRoot;
382
390
  // A host-filesystem backend with escape guards off is the combination the
383
391
  // audit flagged: `pathGuards` off lets `..` paths out of the workspace,
@@ -1879,6 +1887,9 @@ export function buildTools(backend, config) {
1879
1887
  outputFileByTask.set(id, ownFile);
1880
1888
  runByTask.set(id, { command: streaming.command, cwd });
1881
1889
  await backend.writeFile(script, `${streaming.command}\n`);
1890
+ // The clock the grace return below reports a stopped run against: the command is
1891
+ // started here, and how long it ran is what a stop at its own bound is read from.
1892
+ const launchedAt = Date.now();
1882
1893
  await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
1883
1894
  `> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
1884
1895
  // A command backgrounded only for the budget it ASKED for often ends in
@@ -1908,12 +1919,43 @@ export function buildTools(backend, config) {
1908
1919
  // Finished without writing anything: an empty output is still a result.
1909
1920
  output = '';
1910
1921
  }
1922
+ const stdout = sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE));
1923
+ // A grace return is a foreground-shaped result, so it carries the same stop: this
1924
+ // command was started because its output is withheld until it ends (a Playwright
1925
+ // run behind a pipe), and a run that printed a tally accounting for fewer tests
1926
+ // than it announced was stopped — not "Finished … with exit code 0" (the pipeline's
1927
+ // 0 is the filter's). No tool budget applies to a detached run, so the command's
1928
+ // own `timeout` is the only bound here.
1929
+ const cut = commandCutRun(command, { exitCode: code, stdout, stderr: '' }, Date.now() - launchedAt, 0, output);
1930
+ if (cut) {
1931
+ return {
1932
+ stdout,
1933
+ stderr: '',
1934
+ status: CUT_AT_BUDGET,
1935
+ exitCode: cut.exitCode,
1936
+ cutRun: cut,
1937
+ ...(output.length > MAX_OUTPUT_SIZE
1938
+ ? { truncated: true, fullOutput: log }
1939
+ : {}),
1940
+ error: `${cutRunSentence(cut)} ` +
1941
+ (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1942
+ };
1943
+ }
1944
+ // The exit file holds the PIPELINE's code: when a stage this rewrite could not
1945
+ // prove is output-only still follows the suite runner (`| tee run.log`), that code
1946
+ // is the filter's — report none and let the log's tally decide (m101 +25:54).
1947
+ const piped = suiteRunPipedAfterRunner(streaming.command);
1948
+ const reported = suiteRunExitCode(code, output, piped);
1949
+ const notes = [
1950
+ ...streaming.changes,
1951
+ ...(reported === null ? [FILTERED_EXIT_NOTE] : []),
1952
+ ];
1911
1953
  return {
1912
- stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
1954
+ stdout,
1913
1955
  stderr: '',
1914
- exitCode: code,
1956
+ exitCode: reported,
1915
1957
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
1916
- ...(streaming.changes.length ? { note: streaming.changes.join(' ') } : {}),
1958
+ ...(notes.length ? { note: notes.join(' ') } : {}),
1917
1959
  };
1918
1960
  }
1919
1961
  await new Promise((r) => setTimeout(r, 1_500));
@@ -2005,7 +2047,12 @@ export function buildTools(backend, config) {
2005
2047
  // output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
2006
2048
  // the same state (X1 xr-molecule-flash-m93: `timeout 280 hurl … | tail -60` came back
2007
2049
  // `exitCode: 0` after 284 s and four of thirteen files, and the executor re-sent it).
2008
- const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds);
2050
+ const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds,
2051
+ // Detection reads the RAW output: `truncateMiddle` keeps the head and the tail, and the
2052
+ // runner's announcement (`Running 139 tests using 1 worker`) is the first line a long run
2053
+ // prints — the one a size cap drops first. Only the counts and the tally come from here;
2054
+ // what the result quotes is still the sanitized, truncated text.
2055
+ `${result.stdout}\n${result.stderr}`);
2009
2056
  if (cut) {
2010
2057
  const cutLead = cutRunSentence(cut);
2011
2058
  return {
@@ -2400,16 +2447,27 @@ export function buildTools(backend, config) {
2400
2447
  // flash-m83 (2026-10-16) spent three deploys (6.7 minutes) each fixing a single cause, and
2401
2448
  // the second and third were found by runs stopped before they walked the rest. Null for
2402
2449
  // anything that is not a run with failures in it.
2403
- const causes = failureCausesNote(output);
2450
+ // How this run was started, so the note can hand over the narrowed re-run of exactly its
2451
+ // failing tests instead of leaving the executor to re-run the whole suite (xr-molecule-
2452
+ // flash-m101 +12:13: 139 tests, ~4 minutes, to check two fixes).
2453
+ const run = runByTask.get(taskId);
2454
+ // The tests the request excludes are not causes to fix, and the re-run below inverts them
2455
+ // out — a result must never ask the executor to fix a test it also says does not count.
2456
+ // The log's own path rides along so a recap that leaves causes out can hand over the ONE
2457
+ // grep that prints their bodies: the stdout above is cut in the middle, so the log is where
2458
+ // they still are whole.
2459
+ const causes = failureCausesNote(output, excludedTestTitles?.() ?? [], run ? { ...run, log: ownFile ?? log } : undefined);
2404
2460
  // A backgrounded run is under no tool budget, and the exit file hands back the PIPELINE's
2405
2461
  // code — so a suite killed at a `timeout 280` the command carried behind a `| tail` reports
2406
2462
  // 0 here too. The run's start is in its own id ({@link taskStartedAt}), so the bound it used
2407
2463
  // is readable without new state: say stopped, and never hand the pipeline's 0 back as the
2408
2464
  // run's (X1 xr-molecule-flash-m93 re-sent that run three times).
2409
- const run = runByTask.get(taskId);
2410
2465
  const startedAtMs = taskStartedAt(taskId);
2411
2466
  const cut = run && startedAtMs !== null
2412
- ? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0)
2467
+ ? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0,
2468
+ // Same reason as the foreground path: the log is raw here, and the returned stdout
2469
+ // below is the truncated copy — detection must not read that copy.
2470
+ output)
2413
2471
  : null;
2414
2472
  if (cut) {
2415
2473
  return {
@@ -2424,12 +2482,19 @@ export function buildTools(backend, config) {
2424
2482
  };
2425
2483
  }
2426
2484
  // Same shaping as a foreground exec result, so the executor reads both
2427
- // the same way.
2485
+ // the same way. The exit file holds the PIPELINE's code: when a stage this rewrite could not
2486
+ // prove is output-only still follows the suite runner (`| tee run.log`), that code is the
2487
+ // filter's — report none and let the log's tally decide (m101 +25:54).
2488
+ const piped = run ? suiteRunPipedAfterRunner(run.command) : false;
2489
+ const reported = suiteRunExitCode(exitCode, output, piped);
2490
+ const noCodeNote = reported === null ? FILTERED_EXIT_NOTE : null;
2428
2491
  return {
2429
2492
  taskId,
2430
- exitCode,
2493
+ exitCode: reported,
2431
2494
  waitedMs,
2432
- ...(causes ? { note: causes } : {}),
2495
+ ...(causes || noCodeNote
2496
+ ? { note: [causes, noCodeNote].filter((n) => Boolean(n)).join(' ') }
2497
+ : {}),
2433
2498
  stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
2434
2499
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2435
2500
  };
@@ -1 +1 @@
1
- {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAKH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AAuE/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAgBjC"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AAuG/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC"}
@@ -26,6 +26,7 @@
26
26
  *
27
27
  * @module
28
28
  */
29
+ import { commandSegments } from './command-position.js';
29
30
  import { isDiagnosisRun } from './suite-overlap.js';
30
31
  import { isPlaywrightRunCommand } from './utilities.js';
31
32
  /** How many traces one read reports, newest first. */
@@ -374,6 +375,39 @@ export const DIAGNOSIS_TRACE_FLAG = '--trace=retain-on-failure';
374
375
  function namesTraceMode(command) {
375
376
  return /(?:^|[\s;&|(])--trace(?:=|\s)/.test(command);
376
377
  }
378
+ /**
379
+ * Where the `test` word of the command's Playwright run ends, or null when it has none that the
380
+ * shell parse can place.
381
+ *
382
+ * The flag has to land INSIDE the Playwright step, not at the end of the whole command: a run
383
+ * Flash sends almost every time ends `… 2>&1 | tail -60` or `… > log 2>&1; echo "exit=$?";
384
+ * grep -c "" log`, and a flag appended after those is an operand of `tail`/`grep` — GNU `tail`
385
+ * refuses it outright (`tail: option used in invalid context -- 6`) and Playwright never sees it
386
+ * (xr-molecule-flash-m102 +07:40 → +07:53).
387
+ *
388
+ * The step is found by the package's own shell parse ({@link commandSegments}), the one every
389
+ * runner classifier reads: it answers which SEGMENT runs `playwright test` in command position,
390
+ * so a `playwright test` named in an argument (`grep -n "playwright test" e2e/README.md`) is not
391
+ * a run and is left alone. Each segment's text is its program and arguments re-joined with single
392
+ * spaces, so its offset in the command is found by locating that text, walking the segments in
393
+ * order. A segment whose text cannot be located verbatim (irregular whitespace) ends the walk —
394
+ * null, and the caller adds nothing rather than splicing into a guess.
395
+ *
396
+ * @param command - The shell command.
397
+ * @returns The offset just after `test`, or null.
398
+ */
399
+ function playwrightTestWordEnd(command) {
400
+ let cursor = 0;
401
+ for (const segment of commandSegments(command)) {
402
+ const at = command.indexOf(segment.text, cursor);
403
+ if (at < 0)
404
+ return null;
405
+ cursor = at + segment.text.length;
406
+ if (/^playwright\s+test\b/.test(segment.text))
407
+ return at + 'playwright test'.length;
408
+ }
409
+ return null;
410
+ }
377
411
  /** The config paths a candidate directory could hold, in the order Playwright itself looks. */
378
412
  const CONFIG_NAMES = [
379
413
  'playwright.config.ts',
@@ -458,8 +492,18 @@ export async function diagnosisTraceCommand(args) {
458
492
  const config = await readRunConfig(args.command, args.cwd, args.readFile);
459
493
  if (suiteRecordsTraces(config))
460
494
  return unchanged;
495
+ // The flag goes into the Playwright step, right after its `test` word — an option of the run
496
+ // itself, whatever the command opens with and wherever it pipes or redirects its output. Appended
497
+ // at the end of the whole command it belonged to the LAST pipeline stage instead (`tail`, `grep`),
498
+ // which either refused it or silently ran without it (xr-molecule-flash-m102 +07:40 → +07:53). The
499
+ // step's shape is untouched otherwise, so the rewrites that run on this command afterwards still
500
+ // see what they expect: a trailing `| tail -60` is still trailing and is still dropped
501
+ // (`streamingBackgroundCommand`).
502
+ const at = playwrightTestWordEnd(args.command);
503
+ if (at === null)
504
+ return unchanged;
461
505
  return {
462
- command: `${args.command.trimEnd()} ${DIAGNOSIS_TRACE_FLAG}`,
506
+ command: `${args.command.slice(0, at)} ${DIAGNOSIS_TRACE_FLAG}${args.command.slice(at)}`,
463
507
  change: `Added \`${DIAGNOSIS_TRACE_FLAG}\`: the suite's config records no trace for a failure` +
464
508
  `${/trace:/.test(config) ? '' : ' (it sets none)'}, and this diagnosis run's failures are ` +
465
509
  'what the platform reads the network log from. The proof run keeps the suite’s own settings.',