@molecule/api-ai-tools 1.9.9 → 1.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-18T04:56:57.682Z
6
+ Generated: 2026-10-18T06:52:09.924Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-tools
@@ -397,6 +397,44 @@ interface TraceRequest {
397
397
  * `net::ERR_*`, nothing rendered and no selector or app request was measured.
398
398
  */
399
399
  type?: string
400
+ /**
401
+ * When the request started, on the trace's own monotonic clock (ms) — the same clock the
402
+ * {@link TraceAction}s carry, so a request can be attributed to the action that caused it.
403
+ * Absent on traces whose network entries recorded no clock (an older Playwright, a synthetic
404
+ * fixture), in which case only recording order is available.
405
+ */
406
+ startedAt?: number
407
+ }
408
+ ```
409
+
410
+ #### `TraceLastAction`
411
+
412
+ The test's LAST user action before the failing assertion, and the requests it caused.
413
+
414
+ An assertion after a navigation is not explained by the page's last call (`GET /api/tags` on
415
+ whatever page the app redirected to); it is explained by what the action the test took sent and
416
+ what came back (xr-molecule-flash-m148: 13 then 12 failing tests, every trace row leading with
417
+ `GET /api/tags → 200`, while the register `POST /api/users` and its answer — the fact that
418
+ explains the signed-out page — was never shown).
419
+
420
+ ```typescript
421
+ interface TraceLastAction {
422
+ /** The Playwright API the trace names: `Locator.click`, `Locator.fill`, `Frame.goto`. */
423
+ api: string
424
+ /** When the action started, on the trace's monotonic clock (ms). */
425
+ start: number
426
+ /** When it finished, or null when the run ended before it did. */
427
+ end: number | null
428
+ /**
429
+ * The requests the action caused — started after it and before the failing assertion, the app's
430
+ * own API calls first, static assets and the page shell's own files left out. Empty when the
431
+ * action sent none.
432
+ */
433
+ requests: TraceRequest[]
434
+ /** The main frame's URL when the action ran, when the trace recorded frame snapshots. */
435
+ pageBefore?: string
436
+ /** The last main-frame URL the trace recorded — where the page ended up. */
437
+ pageAfter?: string
400
438
  }
401
439
  ```
402
440
 
@@ -428,17 +466,27 @@ interface TraceArtifact {
428
466
  /** The requests the page made, the interesting ones first ({@link traceArtifactsScript}). */
429
467
  requests: TraceRequest[]
430
468
  /**
431
- * The request the failing step itself sent: the trace's LAST `fetch`/`xhr` in recording order
432
- * (a click's own call), else its document request. It leads the note's row, because a click that
433
- * changed nothing is explained by the request the click sent and what came back — xr-molecule-
434
- * flash-m135's Follow click sent `DELETE /api/profiles/x/follow → 200` (its `following` state was
435
- * stale) and the note listed only the test's earlier, answered requests.
469
+ * The request the failing step itself sent: the first answered request the test's last user
470
+ * action caused ({@link TraceArtifact.lastAction}), else — on a trace with no action clock — the
471
+ * trace's LAST `fetch`/`xhr` in recording order, else its document. It leads the note's row when
472
+ * {@link lastAction} is absent, because a click that changed nothing is explained by the request
473
+ * the click sent and what came back — xr-molecule-flash-m135's Follow click sent
474
+ * `DELETE /api/profiles/x/follow → 200` (its `following` state was stale) and the note listed only
475
+ * the test's earlier, answered requests.
436
476
  *
437
477
  * Absent when the trace recorded neither, and on traces read before this field existed.
438
478
  */
439
479
  lead?: TraceRequest
440
480
  /** The console errors and page errors the page raised. */
441
481
  console: string[]
482
+ /**
483
+ * The test's LAST user action before the failing assertion and the requests it caused — what the
484
+ * row leads with, when the trace carries the action times ({@link TraceLastAction}).
485
+ *
486
+ * Absent on traces read before this field existed, and on traces whose network entries recorded
487
+ * no clock; the row then falls back to {@link lead}.
488
+ */
489
+ lastAction?: TraceLastAction
442
490
  /**
443
491
  * The page's own actions in the order the trace recorded them, bounded — what a test that ran
444
492
  * out of a per-test budget spends its time on. A test that repeats one action prints it once per
@@ -1900,8 +1948,8 @@ A 4xx request carries what the page SENT beside its status, so a wrapped body is
1900
1948
  line rather than inferred from the status; when one of those bodies is an object whose only key
1901
1949
  is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
1902
1950
 
1903
- Every row opens with the request the failing step ITSELF sent ({@link TraceArtifact.lead}) and
1904
- its response — whatever its status — because the question a `waiting for locator` failure asks
1951
+ Every row opens with the test's LAST user action before the failing assertion and the requests
1952
+ that action caused ({@link TraceArtifact.lastAction}) — started after it, before the assertion,
1905
1953
 
1906
1954
  _(description truncated — read the full text in `node_modules/@molecule/api-ai-tools/dist/trace-artifacts.d.ts`.)_
1907
1955
 
@@ -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;AAkJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAkOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAqEnE;;;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;;;;;;;;;;;;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;AA8ED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAk9ExF"}
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;AAkJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAkOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAsEnE;;;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;;;;;;;;;;;;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,CAu/ExF"}
package/dist/tools.js CHANGED
@@ -363,7 +363,7 @@ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
363
363
  }
364
364
  import { suiteCommandTarget } from './suite-overlap.js';
365
365
  import { diagnosisTraceCommand } from './trace-artifacts.js';
366
- 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, suiteRunPipelinePlacement, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, withoutSuiteTimeout, } from './utilities.js';
366
+ 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, suiteRunPipelinePlacement, suiteRunPriorityLaunch, TOOL_RESULT_CHARS, truncateMiddle, universalCause, whitespaceTolerantReplace, withoutSuiteTimeout, } from './utilities.js';
367
367
  /**
368
368
  * How long exec_command waits for a command it backgrounded only because the
369
369
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -610,6 +610,33 @@ function sandboxStillStarting(error) {
610
610
  return true;
611
611
  return /not (?:found|running)|notfound|not attached|still starting/i.test(`${name} ${message}`);
612
612
  }
613
+ /**
614
+ * The most entries a bounded `list_files` result lists. A package catalog is not read entry by
615
+ * entry — the executor wants one package it can name — so the summary keeps enough names to show
616
+ * the naming pattern and no more.
617
+ */
618
+ const LIST_FILES_HEAD_ENTRIES = 20;
619
+ /**
620
+ * The line a package-catalog listing carries instead of a catalog's worth of names: a directory
621
+ * of 1,032 names answers no question, while `find_package` answers the one that was asked.
622
+ */
623
+ const MOLECULE_CATALOG_POINTER = 'use find_package(query) to search the @molecule catalog by what you need, and ' +
624
+ 'read_molecule_doc(name) for one package';
625
+ /**
626
+ * Which package catalog a `list_files` path is, when it is one: the `@molecule` scope (1,032
627
+ * package directories, ~53 KB of names in one plan-phase call — X0 xr-molecule-flash-m151 at
628
+ * +01:54) or the `node_modules` root above it.
629
+ *
630
+ * @param path - A resolved (absolute, normalized) directory path.
631
+ * @returns `'molecule'` for the `@molecule` scope, `'packages'` for a `node_modules` root, or null.
632
+ */
633
+ function packageCatalog(path) {
634
+ if (/(^|\/)node_modules\/@molecule$/.test(path))
635
+ return 'molecule';
636
+ if (/(^|\/)node_modules$/.test(path))
637
+ return 'packages';
638
+ return null;
639
+ }
613
640
  /**
614
641
  * Build a complete set of AI agent tools bound to an execution backend.
615
642
  *
@@ -1161,6 +1188,39 @@ export function buildTools(backend, config) {
1161
1188
  return { error: directoryReadHint(message, path) ?? `Failed to read ${path}: ${message}` };
1162
1189
  }
1163
1190
  }
1191
+ /**
1192
+ * A `list_files` result, bounded when the directory is a package catalog or when the whole
1193
+ * listing would not fit one tool result ({@link TOOL_RESULT_CHARS} — molecule-dev's cap on a
1194
+ * tool result's text, which cuts a longer one through its MIDDLE). A normal app directory whose
1195
+ * listing fits comes back exactly as it always has.
1196
+ *
1197
+ * @param path - The resolved directory path.
1198
+ * @param entries - Everything the backend listed.
1199
+ * @returns The listing, or its count plus the first {@link LIST_FILES_HEAD_ENTRIES} names and a note.
1200
+ */
1201
+ function listFilesResult(path, entries) {
1202
+ const listing = {
1203
+ path,
1204
+ entries: entries.map((entry) => ({ name: entry.name, type: entry.type })),
1205
+ };
1206
+ const catalog = packageCatalog(path);
1207
+ if (!catalog && JSON.stringify(listing).length <= TOOL_RESULT_CHARS)
1208
+ return listing;
1209
+ const head = listing.entries.slice(0, LIST_FILES_HEAD_ENTRIES);
1210
+ const cut = head.length < entries.length;
1211
+ const note = catalog === 'molecule'
1212
+ ? `${entries.length} @molecule packages are installed here` +
1213
+ (cut ? `; this listing is cut to ${head.length} names` : '') +
1214
+ `. ${MOLECULE_CATALOG_POINTER}.`
1215
+ : catalog === 'packages'
1216
+ ? `This node_modules holds ${entries.length} entries` +
1217
+ (cut ? `; this listing is cut to ${head.length} names` : '') +
1218
+ `. ${MOLECULE_CATALOG_POINTER}.`
1219
+ : `This directory holds ${entries.length} entries` +
1220
+ (cut ? `; this listing is cut to ${head.length}` : '') +
1221
+ '. Narrow it with find_files (by path pattern) or search_files (by content), or list a subdirectory.';
1222
+ return { path, count: entries.length, entries: head, note };
1223
+ }
1164
1224
  const toolImpls = {
1165
1225
  async list_files(input) {
1166
1226
  const path = resolve(input.path || '');
@@ -1169,7 +1229,7 @@ export function buildTools(backend, config) {
1169
1229
  return { error: symlinkErr };
1170
1230
  try {
1171
1231
  const entries = await backend.readDir(path);
1172
- return { path, entries: entries.map((e) => ({ name: e.name, type: e.type })) };
1232
+ return listFilesResult(path, entries);
1173
1233
  }
1174
1234
  catch (e) {
1175
1235
  const message = e.message;
@@ -60,6 +60,40 @@ export interface TraceRequest {
60
60
  * `net::ERR_*`, nothing rendered and no selector or app request was measured.
61
61
  */
62
62
  type?: string;
63
+ /**
64
+ * When the request started, on the trace's own monotonic clock (ms) — the same clock the
65
+ * {@link TraceAction}s carry, so a request can be attributed to the action that caused it.
66
+ * Absent on traces whose network entries recorded no clock (an older Playwright, a synthetic
67
+ * fixture), in which case only recording order is available.
68
+ */
69
+ startedAt?: number;
70
+ }
71
+ /**
72
+ * The test's LAST user action before the failing assertion, and the requests it caused.
73
+ *
74
+ * An assertion after a navigation is not explained by the page's last call (`GET /api/tags` on
75
+ * whatever page the app redirected to); it is explained by what the action the test took sent and
76
+ * what came back (xr-molecule-flash-m148: 13 then 12 failing tests, every trace row leading with
77
+ * `GET /api/tags → 200`, while the register `POST /api/users` and its answer — the fact that
78
+ * explains the signed-out page — was never shown).
79
+ */
80
+ export interface TraceLastAction {
81
+ /** The Playwright API the trace names: `Locator.click`, `Locator.fill`, `Frame.goto`. */
82
+ api: string;
83
+ /** When the action started, on the trace's monotonic clock (ms). */
84
+ start: number;
85
+ /** When it finished, or null when the run ended before it did. */
86
+ end: number | null;
87
+ /**
88
+ * The requests the action caused — started after it and before the failing assertion, the app's
89
+ * own API calls first, static assets and the page shell's own files left out. Empty when the
90
+ * action sent none.
91
+ */
92
+ requests: TraceRequest[];
93
+ /** The main frame's URL when the action ran, when the trace recorded frame snapshots. */
94
+ pageBefore?: string;
95
+ /** The last main-frame URL the trace recorded — where the page ended up. */
96
+ pageAfter?: string;
63
97
  }
64
98
  /** One action the traced page performed, with the trace's own timing for it. */
65
99
  export interface TraceAction {
@@ -79,17 +113,27 @@ export interface TraceArtifact {
79
113
  /** The requests the page made, the interesting ones first ({@link traceArtifactsScript}). */
80
114
  requests: TraceRequest[];
81
115
  /**
82
- * The request the failing step itself sent: the trace's LAST `fetch`/`xhr` in recording order
83
- * (a click's own call), else its document request. It leads the note's row, because a click that
84
- * changed nothing is explained by the request the click sent and what came back — xr-molecule-
85
- * flash-m135's Follow click sent `DELETE /api/profiles/x/follow → 200` (its `following` state was
86
- * stale) and the note listed only the test's earlier, answered requests.
116
+ * The request the failing step itself sent: the first answered request the test's last user
117
+ * action caused ({@link TraceArtifact.lastAction}), else — on a trace with no action clock — the
118
+ * trace's LAST `fetch`/`xhr` in recording order, else its document. It leads the note's row when
119
+ * {@link lastAction} is absent, because a click that changed nothing is explained by the request
120
+ * the click sent and what came back — xr-molecule-flash-m135's Follow click sent
121
+ * `DELETE /api/profiles/x/follow → 200` (its `following` state was stale) and the note listed only
122
+ * the test's earlier, answered requests.
87
123
  *
88
124
  * Absent when the trace recorded neither, and on traces read before this field existed.
89
125
  */
90
126
  lead?: TraceRequest;
91
127
  /** The console errors and page errors the page raised. */
92
128
  console: string[];
129
+ /**
130
+ * The test's LAST user action before the failing assertion and the requests it caused — what the
131
+ * row leads with, when the trace carries the action times ({@link TraceLastAction}).
132
+ *
133
+ * Absent on traces read before this field existed, and on traces whose network entries recorded
134
+ * no clock; the row then falls back to {@link lead}.
135
+ */
136
+ lastAction?: TraceLastAction;
93
137
  /**
94
138
  * The page's own actions in the order the trace recorded them, bounded — what a test that ran
95
139
  * out of a per-test budget spends its time on. A test that repeats one action prints it once per
@@ -226,11 +270,16 @@ export declare function stalledDocumentSubresource(request: TraceRequest): boole
226
270
  * line rather than inferred from the status; when one of those bodies is an object whose only key
227
271
  * is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
228
272
  *
229
- * Every row opens with the request the failing step ITSELF sent ({@link TraceArtifact.lead}) and
230
- * its response — whatever its status — because the question a `waiting for locator` failure asks
231
- * is what the click did: xr-molecule-flash-m135's Follow click sent `DELETE …/follow → 200` (an
232
- * unfollow, from a stale `following` state) and the run's result named only the test's earlier
233
- * answered requests, so the executor spent 14 minutes on its own browser probes.
273
+ * Every row opens with the test's LAST user action before the failing assertion and the requests
274
+ * that action caused ({@link TraceArtifact.lastAction}) — started after it, before the assertion,
275
+ * the app's own API calls first, each with its status and the first 200 characters of its response,
276
+ * then where the page went next. The trace's action clock and each request's `_monotonicTime` are
277
+ * the same clock, so "caused" is the trace's own fact rather than a guess from recording order.
278
+ * xr-molecule-flash-m148 failed 13 then 12 tests on one unnamed cause while every row led with
279
+ * `GET /api/tags → 200` — the Home page's own fetch after the redirect — and filled its answered
280
+ * slots with the PWA's `/workbox-*.js` and its `/icons/*.svg`; the register `POST /api/users`
281
+ * (answered 201, the page still rendering signed-out) never appeared. With no action clock, the
282
+ * row falls back to {@link TraceArtifact.lead}: the page's last answered fetch/xhr.
234
283
  *
235
284
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
236
285
  * needs a recorded response, a real failure or a console error ({@link traceRowEvidence}), rows are
@@ -1 +1 @@
1
- {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"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"}
@@ -90,6 +90,7 @@ const zlib = require('node:zlib')
90
90
  const ROOT = process.argv[2] || '/workspace'
91
91
  const MAX_TRACES = ${MAX_TRACE_ARTIFACTS}
92
92
  const MAX_REQUESTS = ${MAX_TRACED_REQUESTS}
93
+ const MAX_NOTE = ${MAX_TRACE_NOTE_REQUESTS}
93
94
  const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
94
95
  const MAX_ACTIONS = ${MAX_TRACED_ACTIONS}
95
96
  const MAX_BODY = ${MAX_TRACED_BODY_CHARS}
@@ -231,6 +232,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
231
232
  status: Number(response.status) || 0,
232
233
  failure: typeof response._failureText === 'string' ? response._failureText : undefined,
233
234
  type: type,
235
+ // The HAR entry carries the monotonic clock the trace's action entries use, so a request
236
+ // can be attributed to the action that caused it (see \`lastAction\` below).
237
+ startedAt: typeof snapshot._monotonicTime === 'number' ? snapshot._monotonicTime : undefined,
234
238
  postData: request.postData,
235
239
  content: response.content,
236
240
  })
@@ -240,16 +244,6 @@ for (const one of found.slice(0, MAX_TRACES)) {
240
244
  origin = first ? originOf(first.path) : ''
241
245
  }
242
246
  for (const r of requests) r.path = pathOf(r.path).slice(0, MAX_URL)
243
- // A 4xx from the app's own API is where a body SHAPE is diagnosed: keep what the page sent and
244
- // what came back (bounded). A relative path here means the same origin as the traced document.
245
- for (const r of requests) {
246
- if (r.status < 400 || r.status >= 500) continue
247
- if (/^https?:/i.test(r.path)) continue
248
- const sent = bodyText(buf, entries, r.postData, MAX_BODY)
249
- const got = bodyText(buf, entries, r.content, MAX_BODY)
250
- if (sent) r.sent = sent
251
- if (got) r.got = got
252
- }
253
247
  // The page's own actions, timed. before/after pair on the call id, and only the LIBRARY
254
248
  // traces carry a class and a method (Frame.goto, Locator.fill); the test runner's own
255
249
  // trace times its hooks and fixtures with class Test, which is not a page action. Read BEFORE
@@ -283,20 +277,36 @@ for (const one of found.slice(0, MAX_TRACES)) {
283
277
  // model to write a caching service worker into the app). A document's OWN stylesheet or
284
278
  // script is not shell noise: the navigation blocks on it.
285
279
  const SHELL_TYPE = { font: 1, image: 1, stylesheet: 1, script: 1, manifest: 1 }
280
+ // The PWA worker's own hashed script (\`/workbox-d8ed3718.js\`), the manifest's touch icons
281
+ // (\`/icons/splash-*.svg\`), the favicons and fonts: the shell's files. They are typed \`other\` or
282
+ // \`fetch\` by the worker and the shell rather than \`script\`/\`image\`, so the type alone never
283
+ // caught them — on xr-molecule-flash-m148 they took three of the note's four slots on every
284
+ // failing test and pushed the register \`POST /api/users\` out.
286
285
  function shellPath(path) {
287
- return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(path) ||
288
- /^\\/(?:assets|@vite|__mol)\\//.test(path)
286
+ var p = path
287
+ try { p = new URL(path, 'http://trace.local').pathname } catch (e) { p = String(path).split(/[?#]/)[0] }
288
+ return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(p) ||
289
+ /^\\/(?:assets|@vite|__mol)\\//.test(p) ||
290
+ /^\\/workbox-[^/]*\\.js(?:[?#]|$)/.test(p) ||
291
+ /^\\/icons\\//.test(p) ||
292
+ /^\\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/.test(p)
289
293
  }
290
294
  function shellRequest(r) {
291
295
  if (r.type === 'document') return false
292
296
  return SHELL_TYPE[r.type] === 1 || shellPath(r.path)
293
297
  }
294
298
  // The shell requests that stay noise even when they never finished: the PWA worker, the
295
- // manifest, a favicon, a font. A pending \`/assets/*.css\` is the document's dependency.
299
+ // manifest, a favicon, a font, the worker's own script and the manifest's icons. A pending
300
+ // \`/assets/*.css\` is NOT one of these — the navigation blocks on it (m135).
296
301
  function incidentalShell(r) {
297
302
  if (r.type === 'document') return false
298
- return r.type === 'font' || r.type === 'manifest' ||
299
- /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(r.path)
303
+ if (r.type === 'font' || r.type === 'manifest') return true
304
+ var p = r.path
305
+ try { p = new URL(r.path, 'http://trace.local').pathname } catch (e) { p = String(r.path).split(/[?#]/)[0] }
306
+ return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(p) ||
307
+ /^\\/workbox-[^/]*\\.js(?:[?#]|$)/.test(p) ||
308
+ /^\\/icons\\//.test(p) ||
309
+ /^\\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/.test(p)
300
310
  }
301
311
  // A failed request: a 4xx/5xx, or no response at all WITH Playwright's error text (a refused
302
312
  // connection). No response and no text is an aborted/pending request, not a failure. A 404 on
@@ -309,15 +319,19 @@ for (const one of found.slice(0, MAX_TRACES)) {
309
319
  function neverFinished(r) {
310
320
  return r.status <= 0 && typeof r.failure !== 'string'
311
321
  }
312
- // The request the failing step itself sent: the page's LAST call to the app's own origin (a
313
- // click's fetch/xhr), else the document load. It leads the test's line, because the request a
314
- // click sent answers "why did nothing change?" — xr-molecule-flash-m135 clicked Follow and sent
315
- // a DELETE (its \`following\` state was stale), and the trace held the whole exchange while the
316
- // result listed only the test's earlier, answered requests.
322
+ // Whether a request can lead a row: it got an answer (or failed for its own recorded reason),
323
+ // and it is the app's own call — a fetch/xhr of the page, or its document — never the page
324
+ // shell's own probe (a refused \`/sw.js\` is not the step's request — m125 +29:33).
325
+ //
326
+ // It ranks the requests the test's last action caused (see \`lastAction\` below), and it is the
327
+ // whole rule on a trace with no action clock (the fallback lead: the page's LAST call to the
328
+ // app's own origin, else the document load). That fallback is right for a click that changed
329
+ // nothing — xr-molecule-flash-m135 clicked Follow and sent a DELETE (its \`following\` state was
330
+ // stale) — and wrong for an assertion after a navigation (m148), which is why the action's
331
+ // clock, when the trace has one, decides.
317
332
  //
318
333
  // Never a request that is still in flight (no response, no error text): it says nothing about
319
- // what came back, and the note has its own honest row for it (m112 +30:45). Never the page
320
- // shell's own probe (a refused \`/sw.js\` is not the step's request — m125 +29:33).
334
+ // what came back, and the note has its own honest row for it (m112 +30:45).
321
335
  function leadCandidate(r) {
322
336
  if (r.status <= 0 && typeof r.failure !== 'string') return false
323
337
  if (r.type === 'document') return true
@@ -329,29 +343,131 @@ for (const one of found.slice(0, MAX_TRACES)) {
329
343
  try { p = new URL(r.path, 'http://trace.local').pathname } catch (e) { p = String(r.path).split(/[?#]/)[0] }
330
344
  return !shellPath(p)
331
345
  }
332
- let leadIndex = -1
333
- for (let i = requests.length - 1; i >= 0; i--) {
334
- const r = requests[i]
335
- if ((r.type === 'fetch' || r.type === 'xhr') && leadCandidate(r)) { leadIndex = i; break }
346
+ // The test's LAST user action before the failing assertion, and the requests it caused.
347
+ //
348
+ // The old lead was "the page's LAST call to the app's own origin", which is right for a click
349
+ // that changed nothing (m135) and wrong for an assertion after a navigation: the last call
350
+ // belongs to the page the app landed on (xr-molecule-flash-m148's \`GET /api/tags\` on Home),
351
+ // not to the action the test took. The trace records both clocks — the action's \`startTime\`
352
+ // and each HAR entry's \`_monotonicTime\` — so the requests that started after the last action
353
+ // are exactly the ones it caused, and the register \`POST /api/users\` (answered 201, the page
354
+ // then rendering signed-out) leads instead of the Home page's own fetch.
355
+ const USER_ACTION = /\\.(?:click|dblclick|tap|fill|press|pressSequentially|selectOption|check|uncheck|setInputFiles|goto|type)$/
356
+ const userActions = actions.filter(function (a) { return USER_ACTION.test(a.api) })
357
+ const lastAction = userActions.length > 0 ? userActions[userActions.length - 1] : null
358
+ // A request the action caused started after it (a millisecond of clock slack) and before the
359
+ // assertion — which is everything later in the trace. A request that started before it is the
360
+ // page's own load, not the action's; the page shell's files are never the step's, except a
361
+ // document subresource the open navigation is waiting on (m135).
362
+ function causedIndexes(action) {
363
+ const out = []
364
+ for (let i = 0; i < requests.length; i++) {
365
+ const r = requests[i]
366
+ if (typeof r.startedAt !== 'number' || r.startedAt < action.start - 1) continue
367
+ if (shellRequest(r) && !(navigationOpen && neverFinished(r) && !incidentalShell(r))) continue
368
+ out.push(i)
369
+ }
370
+ return out
336
371
  }
337
- if (leadIndex === -1) {
338
- for (let i = requests.length - 1; i >= 0; i--) {
339
- if (requests[i].type === 'document' && leadCandidate(requests[i])) { leadIndex = i; break }
372
+ // API requests first, then the navigation the page made; a stalled asset outranks both when the
373
+ // navigation it belongs to never finished (m135's goto timeout was waiting on the CSS).
374
+ function causedOrder(indexes) {
375
+ const stalled = []
376
+ const apiCalls = []
377
+ const documents = []
378
+ const other = []
379
+ for (const i of indexes) {
380
+ const r = requests[i]
381
+ if (r.type === 'document') documents.push(i)
382
+ else if (navigationOpen && neverFinished(r) && !incidentalShell(r)) stalled.push(i)
383
+ else if (r.type === 'fetch' || r.type === 'xhr') apiCalls.push(i)
384
+ else other.push(i)
340
385
  }
386
+ return stalled.concat(apiCalls, documents, other)
341
387
  }
342
- let lead = null
343
- if (leadIndex !== -1) {
344
- const r = requests[leadIndex]
388
+ const actionIndexes = lastAction === null ? [] : causedOrder(causedIndexes(lastAction))
389
+ // Both the action's own requests and a 4xx from the app's own API keep what the page sent and
390
+ // what came back. The action's requests keep the RESPONSE whatever its status — an answered
391
+ // \`POST /api/users → 201\` and its body is the fact that explains a signed-out page.
392
+ for (const i of actionIndexes) {
393
+ const r = requests[i]
394
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
395
+ const got = bodyText(buf, entries, r.content, MAX_BODY)
396
+ if (sent) r.sent = sent
397
+ if (got) r.got = got
398
+ }
399
+ for (const r of requests) {
400
+ if (r.status < 400 || r.status >= 500) continue
401
+ if (/^https?:/i.test(r.path)) continue
345
402
  const sent = bodyText(buf, entries, r.postData, MAX_BODY)
346
403
  const got = bodyText(buf, entries, r.content, MAX_BODY)
347
- lead = {
404
+ if (sent) r.sent = sent
405
+ if (got) r.got = got
406
+ }
407
+ function wire(r) {
408
+ return {
348
409
  method: r.method,
349
410
  path: r.path,
350
411
  status: r.status,
351
412
  failure: r.failure,
413
+ ...(r.sent ? { sent: r.sent } : {}),
414
+ ...(r.got ? { got: r.got } : {}),
352
415
  type: r.type || undefined,
353
- ...(sent ? { sent: sent } : {}),
354
- ...(got ? { got: got } : {}),
416
+ }
417
+ }
418
+ // The main frame's URL, timed: what the page showed when the action ran, and where it ended up.
419
+ // The trace records these around every action, which is how the note can say what the page did
420
+ // after the request — a client-side redirect never appears in the network log as a document.
421
+ const frames = []
422
+ for (const entry of trace) {
423
+ if (!entry || entry.type !== 'frame-snapshot') continue
424
+ const snap = entry.snapshot || {}
425
+ if (snap.isMainFrame === false) continue
426
+ if (typeof snap.frameUrl !== 'string' || typeof snap.timestamp !== 'number') continue
427
+ frames.push({ url: pathOf(snap.frameUrl).slice(0, MAX_URL), at: snap.timestamp })
428
+ }
429
+ let last = null
430
+ if (lastAction !== null) {
431
+ let pageBefore
432
+ for (const frame of frames) { if (frame.at <= lastAction.start) pageBefore = frame.url }
433
+ if (pageBefore === undefined && frames.length > 0) pageBefore = frames[0].url
434
+ const pageAfter = frames.length > 0 ? frames[frames.length - 1].url : undefined
435
+ last = {
436
+ api: lastAction.api,
437
+ start: lastAction.start,
438
+ end: lastAction.end,
439
+ requests: actionIndexes.slice(0, MAX_NOTE).map(function (i) { return wire(requests[i]) }),
440
+ ...(pageBefore === undefined ? {} : { pageBefore: pageBefore }),
441
+ ...(pageAfter === undefined ? {} : { pageAfter: pageAfter }),
442
+ }
443
+ }
444
+ // The lead stays for traces that carry no action clock: the last answered fetch/xhr, else the
445
+ // document. With an action, its first answered request is the lead.
446
+ let lead = null
447
+ for (const i of actionIndexes) {
448
+ if (leadCandidate(requests[i])) { lead = wire(requests[i]); break }
449
+ }
450
+ // The fallback lead stays for traces with no action clock. With an action, only a request it
451
+ // caused is the lead; an action that sent none leaves the page's own load to the row's
452
+ // "also answered" bucket, not to a lead that would hide it.
453
+ if (lead === null && lastAction === null) {
454
+ let leadIndex = -1
455
+ for (let i = requests.length - 1; i >= 0; i--) {
456
+ const r = requests[i]
457
+ if ((r.type === 'fetch' || r.type === 'xhr') && leadCandidate(r)) { leadIndex = i; break }
458
+ }
459
+ if (leadIndex === -1) {
460
+ for (let i = requests.length - 1; i >= 0; i--) {
461
+ if (requests[i].type === 'document' && leadCandidate(requests[i])) { leadIndex = i; break }
462
+ }
463
+ }
464
+ if (leadIndex !== -1) {
465
+ const r = requests[leadIndex]
466
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
467
+ const got = bodyText(buf, entries, r.content, MAX_BODY)
468
+ lead = wire(r)
469
+ if (sent) lead.sent = sent
470
+ if (got) lead.got = got
355
471
  }
356
472
  }
357
473
  const failing = []
@@ -369,28 +485,27 @@ for (const one of found.slice(0, MAX_TRACES)) {
369
485
  else if (navigationOpen && neverFinished(r)) pending.push(i)
370
486
  else rest.push(i)
371
487
  }
372
- const keep = failing.slice(-MAX_REQUESTS).concat(api.slice(-MAX_REQUESTS))
488
+ // The action's own requests are kept whatever the cap: they are the row's first line.
489
+ const actionSet = new Set(actionIndexes)
490
+ const keep = failing.slice(-MAX_REQUESTS).concat(actionIndexes.slice(0, MAX_NOTE))
491
+ .concat(api.slice(-MAX_REQUESTS))
373
492
  .concat(pending.slice(-MAX_REQUESTS)).concat(rest.slice(-2))
374
493
  const chosen = []
375
494
  const seen = new Set()
376
495
  for (const i of keep.sort(function (a, b) {
377
- const wrong = failedRequest(requests[a]) ? 0 : 1
378
- const other = failedRequest(requests[b]) ? 0 : 1
379
- return wrong === other ? a - b : wrong - other
496
+ function rank(index) {
497
+ if (failedRequest(requests[index])) return 0
498
+ return actionSet.has(index) ? 1 : 2
499
+ }
500
+ const left = rank(a)
501
+ const right = rank(b)
502
+ return left === right ? a - b : left - right
380
503
  })) {
381
504
  const r = requests[i]
382
505
  const key = r.method + ' ' + r.path + ' ' + r.status
383
506
  if (seen.has(key)) continue
384
507
  seen.add(key)
385
- chosen.push({
386
- method: r.method,
387
- path: r.path,
388
- status: r.status,
389
- failure: r.failure,
390
- sent: r.sent,
391
- got: r.got,
392
- type: r.type || undefined,
393
- })
508
+ chosen.push(wire(r))
394
509
  }
395
510
  const consoleErrors = []
396
511
  let spec = null
@@ -420,6 +535,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
420
535
  spec: spec,
421
536
  line: line,
422
537
  lead: lead,
538
+ lastAction: last,
423
539
  // Failures first, per the sort above; the keep-window is the HEAD of that order. Taking the
424
540
  // tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
425
541
  // plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
@@ -447,6 +563,31 @@ function parseTraceRequest(value) {
447
563
  ...(typeof r.sent === 'string' && r.sent ? { sent: r.sent } : {}),
448
564
  ...(typeof r.got === 'string' && r.got ? { got: r.got } : {}),
449
565
  ...(typeof r.type === 'string' && r.type ? { type: r.type } : {}),
566
+ ...(Number.isFinite(Number(r.startedAt)) ? { startedAt: Number(r.startedAt) } : {}),
567
+ };
568
+ }
569
+ /**
570
+ * One `lastAction` out of a `TRACE` line's json, or null when the value is not one.
571
+ *
572
+ * @param value - The line's `lastAction`.
573
+ * @returns The action and its requests, or null.
574
+ */
575
+ function parseLastAction(value) {
576
+ if (typeof value !== 'object' || value === null)
577
+ return null;
578
+ const raw = value;
579
+ if (typeof raw.api !== 'string' || !Number.isFinite(Number(raw.start)))
580
+ return null;
581
+ const requests = Array.isArray(raw.requests) ? raw.requests : [];
582
+ return {
583
+ api: raw.api,
584
+ start: Number(raw.start),
585
+ end: raw.end === null || raw.end === undefined ? null : Number(raw.end),
586
+ requests: requests
587
+ .map(parseTraceRequest)
588
+ .filter((request) => request !== null),
589
+ ...(typeof raw.pageBefore === 'string' ? { pageBefore: raw.pageBefore } : {}),
590
+ ...(typeof raw.pageAfter === 'string' ? { pageAfter: raw.pageAfter } : {}),
450
591
  };
451
592
  }
452
593
  /**
@@ -469,6 +610,7 @@ export function parseTraceArtifacts(stdout) {
469
610
  const consoleMessages = Array.isArray(raw.console) ? raw.console : [];
470
611
  const actions = Array.isArray(raw.actions) ? raw.actions : [];
471
612
  const lead = parseTraceRequest(raw.lead);
613
+ const lastAction = parseLastAction(raw.lastAction);
472
614
  out.push({
473
615
  spec: typeof raw.spec === 'string' ? raw.spec : null,
474
616
  line: typeof raw.line === 'number' ? raw.line : 0,
@@ -476,6 +618,7 @@ export function parseTraceArtifacts(stdout) {
476
618
  .map(parseTraceRequest)
477
619
  .filter((request) => request !== null),
478
620
  ...(lead === null ? {} : { lead }),
621
+ ...(lastAction === null ? {} : { lastAction }),
479
622
  console: consoleMessages.map(String),
480
623
  actions: actions
481
624
  .filter((a) => typeof a === 'object' && a !== null)
@@ -536,6 +679,26 @@ function requestLine(request) {
536
679
  : '';
537
680
  return `${head}${sent}${got}`;
538
681
  }
682
+ /**
683
+ * One request as the note writes it when the body matters as much as the status: the app's own
684
+ * answer, printed to `gotChars`.
685
+ *
686
+ * @param request - The request.
687
+ * @param gotChars - How much of the response body to print.
688
+ * @returns The request's clause.
689
+ */
690
+ function requestWithBody(request, gotChars) {
691
+ const status = request.status > 0
692
+ ? String(request.status)
693
+ : request.failure
694
+ ? `no response (${bodyLine(redactSecrets(request.failure), TRACE_NOTE_FAILURE_CHARS)})`
695
+ : 'no response';
696
+ const sent = request.sent
697
+ ? ` sent ${bodyLine(redactSecrets(request.sent), TRACE_NOTE_SENT_CHARS)}`
698
+ : '';
699
+ const got = request.got ? ` got ${bodyLine(redactSecrets(request.got), gotChars)}` : '';
700
+ return `${requestLabel(request)} → ${status}${sent}${got}`;
701
+ }
539
702
  /**
540
703
  * The failing step's own request as the note writes it first: `the failing step sent DELETE
541
704
  * /api/profiles/x/follow → 200 got {"profile":{…}}`.
@@ -548,18 +711,27 @@ function requestLine(request) {
548
711
  * @returns The clause the test's row opens with.
549
712
  */
550
713
  function leadRequestLine(request) {
551
- const status = request.status > 0
552
- ? String(request.status)
553
- : request.failure
554
- ? `no response (${bodyLine(redactSecrets(request.failure), TRACE_NOTE_FAILURE_CHARS)})`
555
- : 'no response';
556
- const sent = request.sent
557
- ? ` sent ${bodyLine(redactSecrets(request.sent), TRACE_NOTE_SENT_CHARS)}`
558
- : '';
559
- const got = request.got
560
- ? ` got ${bodyLine(redactSecrets(request.got), TRACE_NOTE_LEAD_GOT_CHARS)}`
561
- : '';
562
- return `the failing step sent ${requestLabel(request)} → ${status}${sent}${got}`;
714
+ return `the failing step sent ${requestWithBody(request, TRACE_NOTE_LEAD_GOT_CHARS)}`;
715
+ }
716
+ /** A request's identity in a trace: method, path and status. */
717
+ function requestKey(request) {
718
+ return `${request.method} ${request.path} ${request.status}`;
719
+ }
720
+ /**
721
+ * When an action ran, relative to the first action the trace recorded — `+2.9 s`.
722
+ *
723
+ * The trace's clock starts with the worker process, so the raw value is meaningless to a reader;
724
+ * a relative time keeps the trace's own ordering visible without inventing a wall-clock time.
725
+ *
726
+ * @param item - The trace.
727
+ * @param start - The action's start, on the trace's clock.
728
+ * @returns The time to print.
729
+ */
730
+ function actionClock(item, start) {
731
+ const first = item.actions?.[0]?.start;
732
+ if (first === undefined || !Number.isFinite(first) || start < first)
733
+ return `${Math.round(start)} ms`;
734
+ return `+${((start - first) / 1000).toFixed(1)} s`;
563
735
  }
564
736
  /** Whether a sent body is an object whose only top-level key is `data` — the wrapped-body trap. */
565
737
  function wrappedInData(text) {
@@ -589,6 +761,16 @@ function wrappedInData(text) {
589
761
  * invisible to the next proof run. The note is about the test's own API calls.
590
762
  */
591
763
  const STATIC_ASSET_PATH = /^\/(?:sw|service-worker)\.js(?:[?#]|$)|\/manifest\.webmanifest(?:[?#]|$)|\/favicon[^/]*(?:[?#]|$)|\/fonts?\//i;
764
+ /**
765
+ * The PWA worker's own hashed script, the manifest's touch icons and the splash images.
766
+ *
767
+ * The worker and the shell type these `other`/`fetch`, not `script`/`image`, so
768
+ * {@link STATIC_ASSET_TYPES} alone never caught them: on xr-molecule-flash-m148 every failing
769
+ * test's row filled three of its four request slots with `GET /workbox-d8ed3718.js → 200` and
770
+ * `GET /icons/splash-1125x2436.svg → 200`, and the register `POST /api/users` that explains the
771
+ * signed-out page never appeared.
772
+ */
773
+ const SHELL_FILE_PATH = /^\/workbox-[^/]*\.js(?:[?#]|$)|^\/icons\/|^\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/i;
592
774
  /** The build/dev output paths the page shell loads (Vite's bundle, the platform's own files). */
593
775
  const STATIC_ASSET_DIR = /^\/(?:assets|@vite|__mol)\//i;
594
776
  /** The resource types a page loads for itself rather than for the step under test. */
@@ -620,7 +802,7 @@ export function isStaticAssetRequest(request) {
620
802
  if (request.type !== undefined && STATIC_ASSET_TYPES.has(request.type))
621
803
  return true;
622
804
  const path = requestPath(request);
623
- return STATIC_ASSET_PATH.test(path) || STATIC_ASSET_DIR.test(path);
805
+ return STATIC_ASSET_PATH.test(path) || STATIC_ASSET_DIR.test(path) || SHELL_FILE_PATH.test(path);
624
806
  }
625
807
  /**
626
808
  * Whether a trace recorded a request the server actually failed: a 4xx/5xx, or a network error.
@@ -918,11 +1100,16 @@ function missingApiPrefixSentence(request) {
918
1100
  * line rather than inferred from the status; when one of those bodies is an object whose only key
919
1101
  * is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
920
1102
  *
921
- * Every row opens with the request the failing step ITSELF sent ({@link TraceArtifact.lead}) and
922
- * its response — whatever its status — because the question a `waiting for locator` failure asks
923
- * is what the click did: xr-molecule-flash-m135's Follow click sent `DELETE …/follow → 200` (an
924
- * unfollow, from a stale `following` state) and the run's result named only the test's earlier
925
- * answered requests, so the executor spent 14 minutes on its own browser probes.
1103
+ * Every row opens with the test's LAST user action before the failing assertion and the requests
1104
+ * that action caused ({@link TraceArtifact.lastAction}) — started after it, before the assertion,
1105
+ * the app's own API calls first, each with its status and the first 200 characters of its response,
1106
+ * then where the page went next. The trace's action clock and each request's `_monotonicTime` are
1107
+ * the same clock, so "caused" is the trace's own fact rather than a guess from recording order.
1108
+ * xr-molecule-flash-m148 failed 13 then 12 tests on one unnamed cause while every row led with
1109
+ * `GET /api/tags → 200` — the Home page's own fetch after the redirect — and filled its answered
1110
+ * slots with the PWA's `/workbox-*.js` and its `/icons/*.svg`; the register `POST /api/users`
1111
+ * (answered 201, the page still rendering signed-out) never appeared. With no action clock, the
1112
+ * row falls back to {@link TraceArtifact.lead}: the page's last answered fetch/xhr.
926
1113
  *
927
1114
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
928
1115
  * needs a recorded response, a real failure or a console error ({@link traceRowEvidence}), rows are
@@ -1009,17 +1196,20 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1009
1196
  ];
1010
1197
  const rows = [];
1011
1198
  for (const [index, item] of traced.entries()) {
1012
- // The request the failing step itself sent LEADS the row (m135): a click that changed nothing
1013
- // is answered by the request it sent and what came back, whether or not that request failed.
1014
- // It is not repeated in the buckets below.
1199
+ // The test's LAST action before the failing assertion leads the row (m148): the requests it
1200
+ // caused and what came back explain a page that never showed the signed-in state, while the
1201
+ // page's own last call (a redirect target's `GET /api/tags`) and the shell's files do not.
1015
1202
  const lead = item.lead;
1016
- const isLead = (request) => lead !== undefined &&
1017
- request.method === lead.method &&
1018
- request.path === lead.path &&
1019
- request.status === lead.status;
1203
+ const step = item.lastAction;
1204
+ // A navigation that never finished has one explanation — the stalled asset it was waiting on
1205
+ // (m135) — and that leads the row instead of the action's own document request.
1206
+ const stalledNav = navigationNeverFinished(item) && item.requests.some(stalledDocumentSubresource);
1207
+ const stepRequests = step !== undefined && !stalledNav ? step.requests : [];
1208
+ const stepKeys = new Set(stepRequests.map(requestKey));
1209
+ const isLead = (request) => lead !== undefined && requestKey(request) === requestKey(lead);
1020
1210
  // The page shell's own requests take no row, in any bucket: an aborted `/sw.js` would
1021
1211
  // otherwise be printed as an "answered" request with no response (m125 +29:33).
1022
- const requests = item.requests.filter((request) => !isStaticAssetRequest(request) && !isLead(request));
1212
+ const requests = item.requests.filter((request) => !isStaticAssetRequest(request) && !isLead(request) && !stepKeys.has(requestKey(request)));
1023
1213
  const failed = requests.filter(failedRequest);
1024
1214
  const unanswered = requests.filter(unansweredRequest);
1025
1215
  const answered = requests.filter((request) => !failedRequest(request) && !unansweredRequest(request));
@@ -1030,7 +1220,27 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1030
1220
  : [];
1031
1221
  let room = MAX_TRACE_NOTE_REQUESTS;
1032
1222
  const parts = [];
1033
- if (lead !== undefined) {
1223
+ if (step !== undefined && !stalledNav) {
1224
+ const head = `the test's last action was ${step.api} at ${actionClock(item, step.start)}`;
1225
+ if (step.requests.length === 0) {
1226
+ parts.push(`${head}, and it sent no request`);
1227
+ }
1228
+ else {
1229
+ const shown = step.requests.slice(0, room);
1230
+ room -= shown.length;
1231
+ // The response body is the point (m148: the register `POST /api/users` answered 201 and
1232
+ // the page rendered signed-out anyway) — printed whatever the status.
1233
+ parts.push(`${head}, and it sent ${shown.map((request) => requestWithBody(request, TRACE_NOTE_LEAD_GOT_CHARS)).join('; ')}`);
1234
+ }
1235
+ // What the page did next: a navigation shows in the main frame's URL even when the app moved
1236
+ // client-side and the network log recorded no document request.
1237
+ if (step.pageAfter !== undefined) {
1238
+ parts.push(step.pageBefore !== undefined && step.pageBefore !== step.pageAfter
1239
+ ? `the page then navigated to ${step.pageAfter}`
1240
+ : `the page stayed at ${step.pageAfter}`);
1241
+ }
1242
+ }
1243
+ else if (lead !== undefined) {
1034
1244
  parts.push(leadRequestLine(lead));
1035
1245
  room -= 1;
1036
1246
  }
@@ -1052,7 +1262,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1052
1262
  shown.map(requestLabel).join('; '));
1053
1263
  }
1054
1264
  if (answered.length > 0 && room > 0) {
1055
- parts.push(`answered: ${answered.slice(0, room).map(requestLine).join('; ')}`);
1265
+ // An action that sent nothing leaves the page's own load as the only requests there are:
1266
+ // name them for what they are, not as the action's answer.
1267
+ const label = step !== undefined && !stalledNav && step.requests.length === 0
1268
+ ? 'also answered (started before the action)'
1269
+ : 'answered';
1270
+ parts.push(`${label}: ${answered.slice(0, room).map(requestLine).join('; ')}`);
1056
1271
  }
1057
1272
  if (item.console.length > 0)
1058
1273
  parts.push(`console: ${item.console
@@ -1104,8 +1319,9 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1104
1319
  return (transportLead +
1105
1320
  stalledLead +
1106
1321
  `The platform read the failing tests' own Playwright traces from the run against ${target} ` +
1107
- `(what each page sent and got — first the request the failing step itself sent, then the ` +
1108
- `requests that failed, the ones that got no response, and its last answered ones):\n` +
1322
+ `(what each page sent and got — first what the test's last action sent and where the page went ` +
1323
+ `next, then the requests that failed, the ones that got no response, and its last answered ` +
1324
+ `ones):\n` +
1109
1325
  `${rows.join('\n')}${more}\n` +
1110
1326
  (anyFailed
1111
1327
  ? everyFailureLacksPrefix
@@ -1119,10 +1335,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1119
1335
  'page-state bug: re-run these tests against the same URL first, and change the app only ' +
1120
1336
  'if the same navigation times out again with no stalled asset in the trace.'
1121
1337
  : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
1122
- 'page, not the API. Start with the request the failing step sent (the first line of each ' +
1123
- 'row): if it is not the request the test meant — an unfollow sent where the test clicked ' +
1124
- 'follow — the state the click read was stale before it. Otherwise read the state the ' +
1125
- 'failing test waited on.') +
1338
+ "page, not the API. Start with what the test's last action sent (the first line of each " +
1339
+ 'row) and what the page did next: a request that answered (2xx included) while the page ' +
1340
+ 'still did not show the state the assertion waited on is the fact to read — its status ' +
1341
+ 'and response body, not the selector. If the request is not the one the test meant — an ' +
1342
+ 'unfollow sent where the test clicked follow — the state the action read was stale ' +
1343
+ 'before it.') +
1126
1344
  prefixAdvice +
1127
1345
  (wrapped
1128
1346
  ? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.9.9",
3
+ "version": "1.9.10",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",