@molecule/api-ai-tools 1.9.9 → 1.9.11

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-18T07:24:21.171Z
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
 
@@ -417,7 +455,7 @@ interface TraceAction {
417
455
 
418
456
  #### `TraceArtifact`
419
457
 
420
- What one trace says: the test it belongs to, the requests it made, and its console errors.
458
+ What one trace says: the test it belongs to, the requests it made, and its console lines.
421
459
 
422
460
  ```typescript
423
461
  interface TraceArtifact {
@@ -428,17 +466,31 @@ 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
- /** The console errors and page errors the page raised. */
480
+ /**
481
+ * The console lines and page errors the page raised: its errors first, then a line that names a
482
+ * session decision or comes from a molecule package, then any other warning, then the browser's
483
+ * own resource error ({@link traceArtifactsScript}).
484
+ */
441
485
  console: string[]
486
+ /**
487
+ * The test's LAST user action before the failing assertion and the requests it caused — what the
488
+ * row leads with, when the trace carries the action times ({@link TraceLastAction}).
489
+ *
490
+ * Absent on traces read before this field existed, and on traces whose network entries recorded
491
+ * no clock; the row then falls back to {@link lead}.
492
+ */
493
+ lastAction?: TraceLastAction
442
494
  /**
443
495
  * The page's own actions in the order the trace recorded them, bounded — what a test that ran
444
496
  * out of a per-test budget spends its time on. A test that repeats one action prints it once per
@@ -1771,7 +1823,7 @@ function buildTools(backend: ExecutionBackend, config?: ToolBuildConfig): AITool
1771
1823
 
1772
1824
  The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
1773
1825
  `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
1774
- them), each with the test it belongs to, the requests its page made and its console errors.
1826
+ them), each with the test it belongs to, the requests its page made and its console lines.
1775
1827
 
1776
1828
  It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
1777
1829
  Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
@@ -1900,8 +1952,8 @@ A 4xx request carries what the page SENT beside its status, so a wrapped body is
1900
1952
  line rather than inferred from the status; when one of those bodies is an object whose only key
1901
1953
  is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
1902
1954
 
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
1955
+ Every row opens with the test's LAST user action before the failing assertion and the requests
1956
+ that action caused ({@link TraceArtifact.lastAction}) — started after it, before the assertion,
1905
1957
 
1906
1958
  _(description truncated — read the full text in `node_modules/@molecule/api-ai-tools/dist/trace-artifacts.d.ts`.)_
1907
1959
 
@@ -3173,7 +3225,8 @@ const MAX_TRACED_REQUESTS: 6
3173
3225
 
3174
3226
  #### `MAX_TRACED_CONSOLE`
3175
3227
 
3176
- How many console errors one test's trace contributes.
3228
+ How many console lines one test's trace contributes: errors ahead of a session or molecule-package
3229
+ line, both ahead of a plain warning and of the browser's own resource error.
3177
3230
 
3178
3231
  ```typescript
3179
3232
  const MAX_TRACED_CONSOLE: 4
@@ -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 {
@@ -70,7 +104,7 @@ export interface TraceAction {
70
104
  /** When it finished, or null when the run ended before it did. */
71
105
  end: number | null;
72
106
  }
73
- /** What one trace says: the test it belongs to, the requests it made, and its console errors. */
107
+ /** What one trace says: the test it belongs to, the requests it made, and its console lines. */
74
108
  export interface TraceArtifact {
75
109
  /** The failure's spec file, relative to the run's directory, when the trace names one. */
76
110
  spec: string | null;
@@ -79,17 +113,31 @@ 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
- /** The console errors and page errors the page raised. */
127
+ /**
128
+ * The console lines and page errors the page raised: its errors first, then a line that names a
129
+ * session decision or comes from a molecule package, then any other warning, then the browser's
130
+ * own resource error ({@link traceArtifactsScript}).
131
+ */
92
132
  console: string[];
133
+ /**
134
+ * The test's LAST user action before the failing assertion and the requests it caused — what the
135
+ * row leads with, when the trace carries the action times ({@link TraceLastAction}).
136
+ *
137
+ * Absent on traces read before this field existed, and on traces whose network entries recorded
138
+ * no clock; the row then falls back to {@link lead}.
139
+ */
140
+ lastAction?: TraceLastAction;
93
141
  /**
94
142
  * The page's own actions in the order the trace recorded them, bounded — what a test that ran
95
143
  * out of a per-test budget spends its time on. A test that repeats one action prints it once per
@@ -101,7 +149,10 @@ export interface TraceArtifact {
101
149
  export declare const MAX_TRACE_ARTIFACTS = 12;
102
150
  /** How many requests one test's trace contributes. */
103
151
  export declare const MAX_TRACED_REQUESTS = 6;
104
- /** How many console errors one test's trace contributes. */
152
+ /**
153
+ * How many console lines one test's trace contributes: errors ahead of a session or molecule-package
154
+ * line, both ahead of a plain warning and of the browser's own resource error.
155
+ */
105
156
  export declare const MAX_TRACED_CONSOLE = 4;
106
157
  /** How many of the page's own actions one test's trace contributes, the LAST ones. */
107
158
  export declare const MAX_TRACED_ACTIONS = 40;
@@ -126,7 +177,7 @@ export declare const TRACE_NOTE_LEAD_GOT_CHARS = 200;
126
177
  /**
127
178
  * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
128
179
  * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
129
- * them), each with the test it belongs to, the requests its page made and its console errors.
180
+ * them), each with the test it belongs to, the requests its page made and its console lines.
130
181
  *
131
182
  * It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
132
183
  * Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
@@ -226,14 +277,19 @@ export declare function stalledDocumentSubresource(request: TraceRequest): boole
226
277
  * line rather than inferred from the status; when one of those bodies is an object whose only key
227
278
  * is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
228
279
  *
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.
280
+ * Every row opens with the test's LAST user action before the failing assertion and the requests
281
+ * that action caused ({@link TraceArtifact.lastAction}) — started after it, before the assertion,
282
+ * the app's own API calls first, each with its status and the first 200 characters of its response,
283
+ * then where the page went next. The trace's action clock and each request's `_monotonicTime` are
284
+ * the same clock, so "caused" is the trace's own fact rather than a guess from recording order.
285
+ * xr-molecule-flash-m148 failed 13 then 12 tests on one unnamed cause while every row led with
286
+ * `GET /api/tags → 200` — the Home page's own fetch after the redirect — and filled its answered
287
+ * slots with the PWA's `/workbox-*.js` and its `/icons/*.svg`; the register `POST /api/users`
288
+ * (answered 201, the page still rendering signed-out) never appeared. With no action clock, the
289
+ * row falls back to {@link TraceArtifact.lead}: the page's last answered fetch/xhr.
234
290
  *
235
291
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
236
- * needs a recorded response, a real failure or a console error ({@link traceRowEvidence}), rows are
292
+ * needs a recorded response, a real failure or a console line ({@link traceRowEvidence}), rows are
237
293
  * deduplicated by the test's location, and they are ordered real failure → repeated from an
238
294
  * earlier walk → the rest ({@link traceRowTier}). Without that the note spent all five rows on four
239
295
  * traces of one `afterEach` line whose every request was `-1`, and left the favorite cause — the
@@ -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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"}
@@ -33,7 +33,10 @@ import { isPlaywrightRunCommand, redactSecrets } from './utilities.js';
33
33
  export const MAX_TRACE_ARTIFACTS = 12;
34
34
  /** How many requests one test's trace contributes. */
35
35
  export const MAX_TRACED_REQUESTS = 6;
36
- /** How many console errors one test's trace contributes. */
36
+ /**
37
+ * How many console lines one test's trace contributes: errors ahead of a session or molecule-package
38
+ * line, both ahead of a plain warning and of the browser's own resource error.
39
+ */
37
40
  export const MAX_TRACED_CONSOLE = 4;
38
41
  /** How many of the page's own actions one test's trace contributes, the LAST ones. */
39
42
  export const MAX_TRACED_ACTIONS = 40;
@@ -60,7 +63,7 @@ const TRACE_NOTE_FAILURE_CHARS = 120;
60
63
  /**
61
64
  * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
62
65
  * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
63
- * them), each with the test it belongs to, the requests its page made and its console errors.
66
+ * them), each with the test it belongs to, the requests its page made and its console lines.
64
67
  *
65
68
  * It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
66
69
  * Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
@@ -90,10 +93,25 @@ const zlib = require('node:zlib')
90
93
  const ROOT = process.argv[2] || '/workspace'
91
94
  const MAX_TRACES = ${MAX_TRACE_ARTIFACTS}
92
95
  const MAX_REQUESTS = ${MAX_TRACED_REQUESTS}
96
+ const MAX_NOTE = ${MAX_TRACE_NOTE_REQUESTS}
93
97
  const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
94
98
  const MAX_ACTIONS = ${MAX_TRACED_ACTIONS}
95
99
  const MAX_BODY = ${MAX_TRACED_BODY_CHARS}
96
100
  const MAX_URL = 200
101
+ // A console line that is not an error but still has to reach the note (m148): one a molecule
102
+ // package wrote — it names the package, the \`[molecule/…]\` prefix, or the \`[scope]\` prefix
103
+ // molecule's own logger writes for a named module ([auth], [http], [i18n]) — or one that names a
104
+ // session decision in the page's own words. \`@molecule/app-auth\` logged "Token refresh failed,
105
+ // logging out" at WARNING level, and this reader kept error entries only, so every trace note
106
+ // carried the browser's generic "Failed to load resource: 401" while the platform's own package was
107
+ // saying, in words, that it had signed the user out.
108
+ const MOLECULE_CONSOLE = /@molecule\\/|\\[molecule[:\\/][^\\]]*\\]|^\\s*\\[[a-z][a-z0-9-]*\\]/
109
+ const SESSION_CONSOLE = /\\b(?:auth(?:entication|enticated|orization|orized)?|session|token|sign(?:ed|ing)?[\\s-]?out|log(?:ged|ging)?[\\s-]?out|refresh(?:ed|ing)?\\s+failed|no\\s+provider)\\b/i
110
+
111
+ // Whether a console line belongs in the note even though it is not at error level.
112
+ function consoleSignal(text) {
113
+ return MOLECULE_CONSOLE.test(text) || SESSION_CONSOLE.test(text)
114
+ }
97
115
 
98
116
  function walk(dir, depth, out) {
99
117
  if (depth < 0) return
@@ -231,6 +249,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
231
249
  status: Number(response.status) || 0,
232
250
  failure: typeof response._failureText === 'string' ? response._failureText : undefined,
233
251
  type: type,
252
+ // The HAR entry carries the monotonic clock the trace's action entries use, so a request
253
+ // can be attributed to the action that caused it (see \`lastAction\` below).
254
+ startedAt: typeof snapshot._monotonicTime === 'number' ? snapshot._monotonicTime : undefined,
234
255
  postData: request.postData,
235
256
  content: response.content,
236
257
  })
@@ -240,16 +261,6 @@ for (const one of found.slice(0, MAX_TRACES)) {
240
261
  origin = first ? originOf(first.path) : ''
241
262
  }
242
263
  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
264
  // The page's own actions, timed. before/after pair on the call id, and only the LIBRARY
254
265
  // traces carry a class and a method (Frame.goto, Locator.fill); the test runner's own
255
266
  // trace times its hooks and fixtures with class Test, which is not a page action. Read BEFORE
@@ -283,20 +294,36 @@ for (const one of found.slice(0, MAX_TRACES)) {
283
294
  // model to write a caching service worker into the app). A document's OWN stylesheet or
284
295
  // script is not shell noise: the navigation blocks on it.
285
296
  const SHELL_TYPE = { font: 1, image: 1, stylesheet: 1, script: 1, manifest: 1 }
297
+ // The PWA worker's own hashed script (\`/workbox-d8ed3718.js\`), the manifest's touch icons
298
+ // (\`/icons/splash-*.svg\`), the favicons and fonts: the shell's files. They are typed \`other\` or
299
+ // \`fetch\` by the worker and the shell rather than \`script\`/\`image\`, so the type alone never
300
+ // caught them — on xr-molecule-flash-m148 they took three of the note's four slots on every
301
+ // failing test and pushed the register \`POST /api/users\` out.
286
302
  function shellPath(path) {
287
- return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(path) ||
288
- /^\\/(?:assets|@vite|__mol)\\//.test(path)
303
+ var p = path
304
+ try { p = new URL(path, 'http://trace.local').pathname } catch (e) { p = String(path).split(/[?#]/)[0] }
305
+ return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(p) ||
306
+ /^\\/(?:assets|@vite|__mol)\\//.test(p) ||
307
+ /^\\/workbox-[^/]*\\.js(?:[?#]|$)/.test(p) ||
308
+ /^\\/icons\\//.test(p) ||
309
+ /^\\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/.test(p)
289
310
  }
290
311
  function shellRequest(r) {
291
312
  if (r.type === 'document') return false
292
313
  return SHELL_TYPE[r.type] === 1 || shellPath(r.path)
293
314
  }
294
315
  // 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.
316
+ // manifest, a favicon, a font, the worker's own script and the manifest's icons. A pending
317
+ // \`/assets/*.css\` is NOT one of these — the navigation blocks on it (m135).
296
318
  function incidentalShell(r) {
297
319
  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)
320
+ if (r.type === 'font' || r.type === 'manifest') return true
321
+ var p = r.path
322
+ try { p = new URL(r.path, 'http://trace.local').pathname } catch (e) { p = String(r.path).split(/[?#]/)[0] }
323
+ return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(p) ||
324
+ /^\\/workbox-[^/]*\\.js(?:[?#]|$)/.test(p) ||
325
+ /^\\/icons\\//.test(p) ||
326
+ /^\\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/.test(p)
300
327
  }
301
328
  // A failed request: a 4xx/5xx, or no response at all WITH Playwright's error text (a refused
302
329
  // connection). No response and no text is an aborted/pending request, not a failure. A 404 on
@@ -309,15 +336,19 @@ for (const one of found.slice(0, MAX_TRACES)) {
309
336
  function neverFinished(r) {
310
337
  return r.status <= 0 && typeof r.failure !== 'string'
311
338
  }
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.
339
+ // Whether a request can lead a row: it got an answer (or failed for its own recorded reason),
340
+ // and it is the app's own call — a fetch/xhr of the page, or its document — never the page
341
+ // shell's own probe (a refused \`/sw.js\` is not the step's request — m125 +29:33).
342
+ //
343
+ // It ranks the requests the test's last action caused (see \`lastAction\` below), and it is the
344
+ // whole rule on a trace with no action clock (the fallback lead: the page's LAST call to the
345
+ // app's own origin, else the document load). That fallback is right for a click that changed
346
+ // nothing — xr-molecule-flash-m135 clicked Follow and sent a DELETE (its \`following\` state was
347
+ // stale) — and wrong for an assertion after a navigation (m148), which is why the action's
348
+ // clock, when the trace has one, decides.
317
349
  //
318
350
  // 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).
351
+ // what came back, and the note has its own honest row for it (m112 +30:45).
321
352
  function leadCandidate(r) {
322
353
  if (r.status <= 0 && typeof r.failure !== 'string') return false
323
354
  if (r.type === 'document') return true
@@ -329,29 +360,131 @@ for (const one of found.slice(0, MAX_TRACES)) {
329
360
  try { p = new URL(r.path, 'http://trace.local').pathname } catch (e) { p = String(r.path).split(/[?#]/)[0] }
330
361
  return !shellPath(p)
331
362
  }
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 }
363
+ // The test's LAST user action before the failing assertion, and the requests it caused.
364
+ //
365
+ // The old lead was "the page's LAST call to the app's own origin", which is right for a click
366
+ // that changed nothing (m135) and wrong for an assertion after a navigation: the last call
367
+ // belongs to the page the app landed on (xr-molecule-flash-m148's \`GET /api/tags\` on Home),
368
+ // not to the action the test took. The trace records both clocks — the action's \`startTime\`
369
+ // and each HAR entry's \`_monotonicTime\` — so the requests that started after the last action
370
+ // are exactly the ones it caused, and the register \`POST /api/users\` (answered 201, the page
371
+ // then rendering signed-out) leads instead of the Home page's own fetch.
372
+ const USER_ACTION = /\\.(?:click|dblclick|tap|fill|press|pressSequentially|selectOption|check|uncheck|setInputFiles|goto|type)$/
373
+ const userActions = actions.filter(function (a) { return USER_ACTION.test(a.api) })
374
+ const lastAction = userActions.length > 0 ? userActions[userActions.length - 1] : null
375
+ // A request the action caused started after it (a millisecond of clock slack) and before the
376
+ // assertion — which is everything later in the trace. A request that started before it is the
377
+ // page's own load, not the action's; the page shell's files are never the step's, except a
378
+ // document subresource the open navigation is waiting on (m135).
379
+ function causedIndexes(action) {
380
+ const out = []
381
+ for (let i = 0; i < requests.length; i++) {
382
+ const r = requests[i]
383
+ if (typeof r.startedAt !== 'number' || r.startedAt < action.start - 1) continue
384
+ if (shellRequest(r) && !(navigationOpen && neverFinished(r) && !incidentalShell(r))) continue
385
+ out.push(i)
386
+ }
387
+ return out
336
388
  }
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 }
389
+ // API requests first, then the navigation the page made; a stalled asset outranks both when the
390
+ // navigation it belongs to never finished (m135's goto timeout was waiting on the CSS).
391
+ function causedOrder(indexes) {
392
+ const stalled = []
393
+ const apiCalls = []
394
+ const documents = []
395
+ const other = []
396
+ for (const i of indexes) {
397
+ const r = requests[i]
398
+ if (r.type === 'document') documents.push(i)
399
+ else if (navigationOpen && neverFinished(r) && !incidentalShell(r)) stalled.push(i)
400
+ else if (r.type === 'fetch' || r.type === 'xhr') apiCalls.push(i)
401
+ else other.push(i)
340
402
  }
403
+ return stalled.concat(apiCalls, documents, other)
341
404
  }
342
- let lead = null
343
- if (leadIndex !== -1) {
344
- const r = requests[leadIndex]
405
+ const actionIndexes = lastAction === null ? [] : causedOrder(causedIndexes(lastAction))
406
+ // Both the action's own requests and a 4xx from the app's own API keep what the page sent and
407
+ // what came back. The action's requests keep the RESPONSE whatever its status — an answered
408
+ // \`POST /api/users → 201\` and its body is the fact that explains a signed-out page.
409
+ for (const i of actionIndexes) {
410
+ const r = requests[i]
411
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
412
+ const got = bodyText(buf, entries, r.content, MAX_BODY)
413
+ if (sent) r.sent = sent
414
+ if (got) r.got = got
415
+ }
416
+ for (const r of requests) {
417
+ if (r.status < 400 || r.status >= 500) continue
418
+ if (/^https?:/i.test(r.path)) continue
345
419
  const sent = bodyText(buf, entries, r.postData, MAX_BODY)
346
420
  const got = bodyText(buf, entries, r.content, MAX_BODY)
347
- lead = {
421
+ if (sent) r.sent = sent
422
+ if (got) r.got = got
423
+ }
424
+ function wire(r) {
425
+ return {
348
426
  method: r.method,
349
427
  path: r.path,
350
428
  status: r.status,
351
429
  failure: r.failure,
430
+ ...(r.sent ? { sent: r.sent } : {}),
431
+ ...(r.got ? { got: r.got } : {}),
352
432
  type: r.type || undefined,
353
- ...(sent ? { sent: sent } : {}),
354
- ...(got ? { got: got } : {}),
433
+ }
434
+ }
435
+ // The main frame's URL, timed: what the page showed when the action ran, and where it ended up.
436
+ // The trace records these around every action, which is how the note can say what the page did
437
+ // after the request — a client-side redirect never appears in the network log as a document.
438
+ const frames = []
439
+ for (const entry of trace) {
440
+ if (!entry || entry.type !== 'frame-snapshot') continue
441
+ const snap = entry.snapshot || {}
442
+ if (snap.isMainFrame === false) continue
443
+ if (typeof snap.frameUrl !== 'string' || typeof snap.timestamp !== 'number') continue
444
+ frames.push({ url: pathOf(snap.frameUrl).slice(0, MAX_URL), at: snap.timestamp })
445
+ }
446
+ let last = null
447
+ if (lastAction !== null) {
448
+ let pageBefore
449
+ for (const frame of frames) { if (frame.at <= lastAction.start) pageBefore = frame.url }
450
+ if (pageBefore === undefined && frames.length > 0) pageBefore = frames[0].url
451
+ const pageAfter = frames.length > 0 ? frames[frames.length - 1].url : undefined
452
+ last = {
453
+ api: lastAction.api,
454
+ start: lastAction.start,
455
+ end: lastAction.end,
456
+ requests: actionIndexes.slice(0, MAX_NOTE).map(function (i) { return wire(requests[i]) }),
457
+ ...(pageBefore === undefined ? {} : { pageBefore: pageBefore }),
458
+ ...(pageAfter === undefined ? {} : { pageAfter: pageAfter }),
459
+ }
460
+ }
461
+ // The lead stays for traces that carry no action clock: the last answered fetch/xhr, else the
462
+ // document. With an action, its first answered request is the lead.
463
+ let lead = null
464
+ for (const i of actionIndexes) {
465
+ if (leadCandidate(requests[i])) { lead = wire(requests[i]); break }
466
+ }
467
+ // The fallback lead stays for traces with no action clock. With an action, only a request it
468
+ // caused is the lead; an action that sent none leaves the page's own load to the row's
469
+ // "also answered" bucket, not to a lead that would hide it.
470
+ if (lead === null && lastAction === null) {
471
+ let leadIndex = -1
472
+ for (let i = requests.length - 1; i >= 0; i--) {
473
+ const r = requests[i]
474
+ if ((r.type === 'fetch' || r.type === 'xhr') && leadCandidate(r)) { leadIndex = i; break }
475
+ }
476
+ if (leadIndex === -1) {
477
+ for (let i = requests.length - 1; i >= 0; i--) {
478
+ if (requests[i].type === 'document' && leadCandidate(requests[i])) { leadIndex = i; break }
479
+ }
480
+ }
481
+ if (leadIndex !== -1) {
482
+ const r = requests[leadIndex]
483
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
484
+ const got = bodyText(buf, entries, r.content, MAX_BODY)
485
+ lead = wire(r)
486
+ if (sent) lead.sent = sent
487
+ if (got) lead.got = got
355
488
  }
356
489
  }
357
490
  const failing = []
@@ -369,37 +502,41 @@ for (const one of found.slice(0, MAX_TRACES)) {
369
502
  else if (navigationOpen && neverFinished(r)) pending.push(i)
370
503
  else rest.push(i)
371
504
  }
372
- const keep = failing.slice(-MAX_REQUESTS).concat(api.slice(-MAX_REQUESTS))
505
+ // The action's own requests are kept whatever the cap: they are the row's first line.
506
+ const actionSet = new Set(actionIndexes)
507
+ const keep = failing.slice(-MAX_REQUESTS).concat(actionIndexes.slice(0, MAX_NOTE))
508
+ .concat(api.slice(-MAX_REQUESTS))
373
509
  .concat(pending.slice(-MAX_REQUESTS)).concat(rest.slice(-2))
374
510
  const chosen = []
375
511
  const seen = new Set()
376
512
  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
513
+ function rank(index) {
514
+ if (failedRequest(requests[index])) return 0
515
+ return actionSet.has(index) ? 1 : 2
516
+ }
517
+ const left = rank(a)
518
+ const right = rank(b)
519
+ return left === right ? a - b : left - right
380
520
  })) {
381
521
  const r = requests[i]
382
522
  const key = r.method + ' ' + r.path + ' ' + r.status
383
523
  if (seen.has(key)) continue
384
524
  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
- })
525
+ chosen.push(wire(r))
394
526
  }
395
527
  const consoleErrors = []
528
+ const consoleSignals = []
529
+ const consoleWarnings = []
396
530
  let spec = null
397
531
  let line = 0
398
532
  for (const entry of trace) {
399
533
  if (!entry) continue
400
- if (entry.type === 'console' && entry.messageType === 'error' && typeof entry.text === 'string')
401
- consoleErrors.push(entry.text)
402
- else if (entry.type === 'event' && entry.method === 'pageError') {
534
+ if (entry.type === 'console' && typeof entry.text === 'string') {
535
+ if (entry.messageType === 'error') consoleErrors.push(entry.text)
536
+ else if (consoleSignal(entry.text)) consoleSignals.push(entry.text)
537
+ else if (entry.messageType === 'warning' || entry.messageType === 'warn')
538
+ consoleWarnings.push(entry.text)
539
+ } else if (entry.type === 'event' && entry.method === 'pageError') {
403
540
  const message = messageOf(entry.params)
404
541
  if (message) consoleErrors.push(message)
405
542
  } else if (entry.type === 'before' && Array.isArray(entry.stack) && entry.stack.length) {
@@ -411,15 +548,20 @@ for (const one of found.slice(0, MAX_TRACES)) {
411
548
  }
412
549
  }
413
550
  // Chromium reports every failed request as a console error too; where the page logged something
414
- // of its own about it, that is the line worth reading.
551
+ // of its own about it, that is the line worth reading. Errors lead, then the lines that name a
552
+ // session decision or come from a molecule package (m148), then any other warning — the browser's
553
+ // resource error is last, because it is evidence of a failed request, not of a cause, and the
554
+ // platform's own package saying "logging out" must not be ranked below it.
415
555
  const specific = consoleErrors.filter(function (text) { return !/^Failed to load resource/.test(text) })
416
- const keptConsole = specific.length > 0 ? specific : consoleErrors
556
+ const keptConsole = specific.concat(consoleSignals, consoleWarnings)
557
+ if (specific.length === 0) keptConsole.push.apply(keptConsole, consoleErrors)
417
558
  if (chosen.length === 0 && keptConsole.length === 0 && actions.length === 0) continue
418
559
  process.stdout.write('TRACE ' + JSON.stringify({
419
560
  dir: path.relative(ROOT, path.dirname(one.file)),
420
561
  spec: spec,
421
562
  line: line,
422
563
  lead: lead,
564
+ lastAction: last,
423
565
  // Failures first, per the sort above; the keep-window is the HEAD of that order. Taking the
424
566
  // tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
425
567
  // plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
@@ -447,6 +589,31 @@ function parseTraceRequest(value) {
447
589
  ...(typeof r.sent === 'string' && r.sent ? { sent: r.sent } : {}),
448
590
  ...(typeof r.got === 'string' && r.got ? { got: r.got } : {}),
449
591
  ...(typeof r.type === 'string' && r.type ? { type: r.type } : {}),
592
+ ...(Number.isFinite(Number(r.startedAt)) ? { startedAt: Number(r.startedAt) } : {}),
593
+ };
594
+ }
595
+ /**
596
+ * One `lastAction` out of a `TRACE` line's json, or null when the value is not one.
597
+ *
598
+ * @param value - The line's `lastAction`.
599
+ * @returns The action and its requests, or null.
600
+ */
601
+ function parseLastAction(value) {
602
+ if (typeof value !== 'object' || value === null)
603
+ return null;
604
+ const raw = value;
605
+ if (typeof raw.api !== 'string' || !Number.isFinite(Number(raw.start)))
606
+ return null;
607
+ const requests = Array.isArray(raw.requests) ? raw.requests : [];
608
+ return {
609
+ api: raw.api,
610
+ start: Number(raw.start),
611
+ end: raw.end === null || raw.end === undefined ? null : Number(raw.end),
612
+ requests: requests
613
+ .map(parseTraceRequest)
614
+ .filter((request) => request !== null),
615
+ ...(typeof raw.pageBefore === 'string' ? { pageBefore: raw.pageBefore } : {}),
616
+ ...(typeof raw.pageAfter === 'string' ? { pageAfter: raw.pageAfter } : {}),
450
617
  };
451
618
  }
452
619
  /**
@@ -469,6 +636,7 @@ export function parseTraceArtifacts(stdout) {
469
636
  const consoleMessages = Array.isArray(raw.console) ? raw.console : [];
470
637
  const actions = Array.isArray(raw.actions) ? raw.actions : [];
471
638
  const lead = parseTraceRequest(raw.lead);
639
+ const lastAction = parseLastAction(raw.lastAction);
472
640
  out.push({
473
641
  spec: typeof raw.spec === 'string' ? raw.spec : null,
474
642
  line: typeof raw.line === 'number' ? raw.line : 0,
@@ -476,6 +644,7 @@ export function parseTraceArtifacts(stdout) {
476
644
  .map(parseTraceRequest)
477
645
  .filter((request) => request !== null),
478
646
  ...(lead === null ? {} : { lead }),
647
+ ...(lastAction === null ? {} : { lastAction }),
479
648
  console: consoleMessages.map(String),
480
649
  actions: actions
481
650
  .filter((a) => typeof a === 'object' && a !== null)
@@ -536,6 +705,26 @@ function requestLine(request) {
536
705
  : '';
537
706
  return `${head}${sent}${got}`;
538
707
  }
708
+ /**
709
+ * One request as the note writes it when the body matters as much as the status: the app's own
710
+ * answer, printed to `gotChars`.
711
+ *
712
+ * @param request - The request.
713
+ * @param gotChars - How much of the response body to print.
714
+ * @returns The request's clause.
715
+ */
716
+ function requestWithBody(request, gotChars) {
717
+ const status = request.status > 0
718
+ ? String(request.status)
719
+ : request.failure
720
+ ? `no response (${bodyLine(redactSecrets(request.failure), TRACE_NOTE_FAILURE_CHARS)})`
721
+ : 'no response';
722
+ const sent = request.sent
723
+ ? ` sent ${bodyLine(redactSecrets(request.sent), TRACE_NOTE_SENT_CHARS)}`
724
+ : '';
725
+ const got = request.got ? ` got ${bodyLine(redactSecrets(request.got), gotChars)}` : '';
726
+ return `${requestLabel(request)} → ${status}${sent}${got}`;
727
+ }
539
728
  /**
540
729
  * The failing step's own request as the note writes it first: `the failing step sent DELETE
541
730
  * /api/profiles/x/follow → 200 got {"profile":{…}}`.
@@ -548,18 +737,27 @@ function requestLine(request) {
548
737
  * @returns The clause the test's row opens with.
549
738
  */
550
739
  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}`;
740
+ return `the failing step sent ${requestWithBody(request, TRACE_NOTE_LEAD_GOT_CHARS)}`;
741
+ }
742
+ /** A request's identity in a trace: method, path and status. */
743
+ function requestKey(request) {
744
+ return `${request.method} ${request.path} ${request.status}`;
745
+ }
746
+ /**
747
+ * When an action ran, relative to the first action the trace recorded — `+2.9 s`.
748
+ *
749
+ * The trace's clock starts with the worker process, so the raw value is meaningless to a reader;
750
+ * a relative time keeps the trace's own ordering visible without inventing a wall-clock time.
751
+ *
752
+ * @param item - The trace.
753
+ * @param start - The action's start, on the trace's clock.
754
+ * @returns The time to print.
755
+ */
756
+ function actionClock(item, start) {
757
+ const first = item.actions?.[0]?.start;
758
+ if (first === undefined || !Number.isFinite(first) || start < first)
759
+ return `${Math.round(start)} ms`;
760
+ return `+${((start - first) / 1000).toFixed(1)} s`;
563
761
  }
564
762
  /** Whether a sent body is an object whose only top-level key is `data` — the wrapped-body trap. */
565
763
  function wrappedInData(text) {
@@ -589,6 +787,16 @@ function wrappedInData(text) {
589
787
  * invisible to the next proof run. The note is about the test's own API calls.
590
788
  */
591
789
  const STATIC_ASSET_PATH = /^\/(?:sw|service-worker)\.js(?:[?#]|$)|\/manifest\.webmanifest(?:[?#]|$)|\/favicon[^/]*(?:[?#]|$)|\/fonts?\//i;
790
+ /**
791
+ * The PWA worker's own hashed script, the manifest's touch icons and the splash images.
792
+ *
793
+ * The worker and the shell type these `other`/`fetch`, not `script`/`image`, so
794
+ * {@link STATIC_ASSET_TYPES} alone never caught them: on xr-molecule-flash-m148 every failing
795
+ * test's row filled three of its four request slots with `GET /workbox-d8ed3718.js → 200` and
796
+ * `GET /icons/splash-1125x2436.svg → 200`, and the register `POST /api/users` that explains the
797
+ * signed-out page never appeared.
798
+ */
799
+ const SHELL_FILE_PATH = /^\/workbox-[^/]*\.js(?:[?#]|$)|^\/icons\/|^\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/i;
592
800
  /** The build/dev output paths the page shell loads (Vite's bundle, the platform's own files). */
593
801
  const STATIC_ASSET_DIR = /^\/(?:assets|@vite|__mol)\//i;
594
802
  /** The resource types a page loads for itself rather than for the step under test. */
@@ -620,7 +828,7 @@ export function isStaticAssetRequest(request) {
620
828
  if (request.type !== undefined && STATIC_ASSET_TYPES.has(request.type))
621
829
  return true;
622
830
  const path = requestPath(request);
623
- return STATIC_ASSET_PATH.test(path) || STATIC_ASSET_DIR.test(path);
831
+ return STATIC_ASSET_PATH.test(path) || STATIC_ASSET_DIR.test(path) || SHELL_FILE_PATH.test(path);
624
832
  }
625
833
  /**
626
834
  * Whether a trace recorded a request the server actually failed: a 4xx/5xx, or a network error.
@@ -728,7 +936,7 @@ function repeatsEarlierFailure(item, earlier) {
728
936
  }
729
937
  /**
730
938
  * Whether a trace holds evidence the note can explain: a request that got a RECORDED response, a
731
- * real failure (4xx/5xx or a network error), or a console error. A request with no response is
939
+ * real failure (4xx/5xx or a network error), or a console line. A request with no response is
732
940
  * not evidence (m112 +53:46): four of the note's five rows were traces of one `afterEach` line
733
941
  * whose every request was `-1`, so the favorite cause — repeated in all three walks — stayed
734
942
  * hidden in "… and 7 more traced failures".
@@ -739,7 +947,7 @@ function repeatsEarlierFailure(item, earlier) {
739
947
  * `page.goto` timed out ({@link stalledDocumentSubresource}).
740
948
  *
741
949
  * @param item - One traced failure.
742
- * @returns True when the trace holds a request or console error worth a row.
950
+ * @returns True when the trace holds a request or console line worth a row.
743
951
  */
744
952
  function traceRowEvidence(item) {
745
953
  return (item.console.length > 0 ||
@@ -918,14 +1126,19 @@ function missingApiPrefixSentence(request) {
918
1126
  * line rather than inferred from the status; when one of those bodies is an object whose only key
919
1127
  * is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
920
1128
  *
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.
1129
+ * Every row opens with the test's LAST user action before the failing assertion and the requests
1130
+ * that action caused ({@link TraceArtifact.lastAction}) — started after it, before the assertion,
1131
+ * the app's own API calls first, each with its status and the first 200 characters of its response,
1132
+ * then where the page went next. The trace's action clock and each request's `_monotonicTime` are
1133
+ * the same clock, so "caused" is the trace's own fact rather than a guess from recording order.
1134
+ * xr-molecule-flash-m148 failed 13 then 12 tests on one unnamed cause while every row led with
1135
+ * `GET /api/tags → 200` — the Home page's own fetch after the redirect — and filled its answered
1136
+ * slots with the PWA's `/workbox-*.js` and its `/icons/*.svg`; the register `POST /api/users`
1137
+ * (answered 201, the page still rendering signed-out) never appeared. With no action clock, the
1138
+ * row falls back to {@link TraceArtifact.lead}: the page's last answered fetch/xhr.
926
1139
  *
927
1140
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
928
- * needs a recorded response, a real failure or a console error ({@link traceRowEvidence}), rows are
1141
+ * needs a recorded response, a real failure or a console line ({@link traceRowEvidence}), rows are
929
1142
  * deduplicated by the test's location, and they are ordered real failure → repeated from an
930
1143
  * earlier walk → the rest ({@link traceRowTier}). Without that the note spent all five rows on four
931
1144
  * traces of one `afterEach` line whose every request was `-1`, and left the favorite cause — the
@@ -1009,17 +1222,20 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1009
1222
  ];
1010
1223
  const rows = [];
1011
1224
  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.
1225
+ // The test's LAST action before the failing assertion leads the row (m148): the requests it
1226
+ // caused and what came back explain a page that never showed the signed-in state, while the
1227
+ // page's own last call (a redirect target's `GET /api/tags`) and the shell's files do not.
1015
1228
  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;
1229
+ const step = item.lastAction;
1230
+ // A navigation that never finished has one explanation — the stalled asset it was waiting on
1231
+ // (m135) — and that leads the row instead of the action's own document request.
1232
+ const stalledNav = navigationNeverFinished(item) && item.requests.some(stalledDocumentSubresource);
1233
+ const stepRequests = step !== undefined && !stalledNav ? step.requests : [];
1234
+ const stepKeys = new Set(stepRequests.map(requestKey));
1235
+ const isLead = (request) => lead !== undefined && requestKey(request) === requestKey(lead);
1020
1236
  // The page shell's own requests take no row, in any bucket: an aborted `/sw.js` would
1021
1237
  // otherwise be printed as an "answered" request with no response (m125 +29:33).
1022
- const requests = item.requests.filter((request) => !isStaticAssetRequest(request) && !isLead(request));
1238
+ const requests = item.requests.filter((request) => !isStaticAssetRequest(request) && !isLead(request) && !stepKeys.has(requestKey(request)));
1023
1239
  const failed = requests.filter(failedRequest);
1024
1240
  const unanswered = requests.filter(unansweredRequest);
1025
1241
  const answered = requests.filter((request) => !failedRequest(request) && !unansweredRequest(request));
@@ -1030,7 +1246,27 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1030
1246
  : [];
1031
1247
  let room = MAX_TRACE_NOTE_REQUESTS;
1032
1248
  const parts = [];
1033
- if (lead !== undefined) {
1249
+ if (step !== undefined && !stalledNav) {
1250
+ const head = `the test's last action was ${step.api} at ${actionClock(item, step.start)}`;
1251
+ if (step.requests.length === 0) {
1252
+ parts.push(`${head}, and it sent no request`);
1253
+ }
1254
+ else {
1255
+ const shown = step.requests.slice(0, room);
1256
+ room -= shown.length;
1257
+ // The response body is the point (m148: the register `POST /api/users` answered 201 and
1258
+ // the page rendered signed-out anyway) — printed whatever the status.
1259
+ parts.push(`${head}, and it sent ${shown.map((request) => requestWithBody(request, TRACE_NOTE_LEAD_GOT_CHARS)).join('; ')}`);
1260
+ }
1261
+ // What the page did next: a navigation shows in the main frame's URL even when the app moved
1262
+ // client-side and the network log recorded no document request.
1263
+ if (step.pageAfter !== undefined) {
1264
+ parts.push(step.pageBefore !== undefined && step.pageBefore !== step.pageAfter
1265
+ ? `the page then navigated to ${step.pageAfter}`
1266
+ : `the page stayed at ${step.pageAfter}`);
1267
+ }
1268
+ }
1269
+ else if (lead !== undefined) {
1034
1270
  parts.push(leadRequestLine(lead));
1035
1271
  room -= 1;
1036
1272
  }
@@ -1052,7 +1288,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1052
1288
  shown.map(requestLabel).join('; '));
1053
1289
  }
1054
1290
  if (answered.length > 0 && room > 0) {
1055
- parts.push(`answered: ${answered.slice(0, room).map(requestLine).join('; ')}`);
1291
+ // An action that sent nothing leaves the page's own load as the only requests there are:
1292
+ // name them for what they are, not as the action's answer.
1293
+ const label = step !== undefined && !stalledNav && step.requests.length === 0
1294
+ ? 'also answered (started before the action)'
1295
+ : 'answered';
1296
+ parts.push(`${label}: ${answered.slice(0, room).map(requestLine).join('; ')}`);
1056
1297
  }
1057
1298
  if (item.console.length > 0)
1058
1299
  parts.push(`console: ${item.console
@@ -1104,8 +1345,9 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1104
1345
  return (transportLead +
1105
1346
  stalledLead +
1106
1347
  `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` +
1348
+ `(what each page sent and got — first what the test's last action sent and where the page went ` +
1349
+ `next, then the requests that failed, the ones that got no response, and its last answered ` +
1350
+ `ones):\n` +
1109
1351
  `${rows.join('\n')}${more}\n` +
1110
1352
  (anyFailed
1111
1353
  ? everyFailureLacksPrefix
@@ -1119,10 +1361,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1119
1361
  'page-state bug: re-run these tests against the same URL first, and change the app only ' +
1120
1362
  'if the same navigation times out again with no stalled asset in the trace.'
1121
1363
  : '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.') +
1364
+ "page, not the API. Start with what the test's last action sent (the first line of each " +
1365
+ 'row) and what the page did next: a request that answered (2xx included) while the page ' +
1366
+ 'still did not show the state the assertion waited on is the fact to read — its status ' +
1367
+ 'and response body, not the selector. If the request is not the one the test meant — an ' +
1368
+ 'unfollow sent where the test clicked follow — the state the action read was stale ' +
1369
+ 'before it.') +
1126
1370
  prefixAdvice +
1127
1371
  (wrapped
1128
1372
  ? ' 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.11",
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",