@molecule/api-ai-tools 1.9.6 → 1.9.8

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.
@@ -49,6 +49,12 @@ export const TRACE_NOTE_URL_CHARS = 96;
49
49
  export const TRACE_NOTE_SENT_CHARS = 160;
50
50
  /** How much of the response body the note prints — the API's first error line. */
51
51
  export const TRACE_NOTE_GOT_CHARS = 120;
52
+ /**
53
+ * How much of the FAILING STEP's response body the note prints. Longer than
54
+ * {@link TRACE_NOTE_GOT_CHARS}: it is the one line that answers "what did the click actually get
55
+ * back", and an app's own body (`{"profile":{…}}`) can carry the answer past its first clause.
56
+ */
57
+ export const TRACE_NOTE_LEAD_GOT_CHARS = 200;
52
58
  /** How much of a network error's own text (`net::ERR_CONNECTION_REFUSED`) the note prints. */
53
59
  const TRACE_NOTE_FAILURE_CHARS = 120;
54
60
  /**
@@ -244,10 +250,38 @@ for (const one of found.slice(0, MAX_TRACES)) {
244
250
  if (sent) r.sent = sent
245
251
  if (got) r.got = got
246
252
  }
253
+ // The page's own actions, timed. before/after pair on the call id, and only the LIBRARY
254
+ // traces carry a class and a method (Frame.goto, Locator.fill); the test runner's own
255
+ // trace times its hooks and fixtures with class Test, which is not a page action. Read BEFORE
256
+ // the request buckets: whether the navigation itself finished decides whether a subresource
257
+ // with no response is evidence (see \`navigationOpen\` below).
258
+ const ended = {}
259
+ for (const entry of trace) {
260
+ if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
261
+ ended[entry.callId] = entry.endTime
262
+ }
263
+ const actions = []
264
+ for (const entry of trace) {
265
+ if (!entry || entry.type !== 'before') continue
266
+ if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
267
+ if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
268
+ actions.push({
269
+ api: entry.class + '.' + entry.method,
270
+ start: entry.startTime,
271
+ end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
272
+ })
273
+ }
274
+ // A \`page.goto\` that has no end time: the page never finished loading. When that is the
275
+ // failure, a document subresource with no response is the reason — the navigation waits for
276
+ // its stylesheets and scripts — and it is evidence, not the aborted noise a finished
277
+ // navigation leaves behind (xr-molecule-flash-m135: three goto timeouts on a deployed app
278
+ // where one 88 KB stylesheet fetch stalled 27 s, and the trace of each held the pending CSS).
279
+ const navigationOpen = actions.some(function (a) { return /\\.goto$/.test(a.api) && a.end === null })
247
280
  // The page shell's own requests (a service worker probe, a manifest, a font) are not the
248
281
  // failing step's dependencies: a 404 on one, or no response at all, is not a cause and takes
249
282
  // no row (xr-molecule-flash-m125 +29:33 — "GET /sw.js; GET /fonts/ionicons.woff" sent the
250
- // model to write a caching service worker into the app).
283
+ // model to write a caching service worker into the app). A document's OWN stylesheet or
284
+ // script is not shell noise: the navigation blocks on it.
251
285
  const SHELL_TYPE = { font: 1, image: 1, stylesheet: 1, script: 1, manifest: 1 }
252
286
  function shellPath(path) {
253
287
  return /^\\/(?:sw|service-worker)\\.js(?:[?#]|$)|\\/manifest\\.webmanifest(?:[?#]|$)|\\/favicon[^/]*(?:[?#]|$)|\\/fonts?\\//.test(path) ||
@@ -257,6 +291,13 @@ for (const one of found.slice(0, MAX_TRACES)) {
257
291
  if (r.type === 'document') return false
258
292
  return SHELL_TYPE[r.type] === 1 || shellPath(r.path)
259
293
  }
294
+ // 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.
296
+ function incidentalShell(r) {
297
+ 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)
300
+ }
260
301
  // A failed request: a 4xx/5xx, or no response at all WITH Playwright's error text (a refused
261
302
  // connection). No response and no text is an aborted/pending request, not a failure. A 404 on
262
303
  // the page shell is excepted, for the reason above.
@@ -264,18 +305,72 @@ for (const one of found.slice(0, MAX_TRACES)) {
264
305
  if (shellRequest(r) && r.status === 404) return false
265
306
  return r.status >= 400 || (r.status <= 0 && typeof r.failure === 'string')
266
307
  }
308
+ // No response and no error text at all — still in flight when the test ended.
309
+ function neverFinished(r) {
310
+ return r.status <= 0 && typeof r.failure !== 'string'
311
+ }
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.
317
+ //
318
+ // 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).
321
+ function leadCandidate(r) {
322
+ if (r.status <= 0 && typeof r.failure !== 'string') return false
323
+ if (r.type === 'document') return true
324
+ if (r.type !== 'fetch' && r.type !== 'xhr') return false
325
+ // The request the page makes for its own shell is not the step's (m125 +29:33). The recorded
326
+ // path can be an absolute URL when the probe's origin differs from the document's, so judge
327
+ // its pathname.
328
+ var p = r.path
329
+ try { p = new URL(r.path, 'http://trace.local').pathname } catch (e) { p = String(r.path).split(/[?#]/)[0] }
330
+ return !shellPath(p)
331
+ }
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 }
336
+ }
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 }
340
+ }
341
+ }
342
+ let lead = null
343
+ if (leadIndex !== -1) {
344
+ const r = requests[leadIndex]
345
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
346
+ const got = bodyText(buf, entries, r.content, MAX_BODY)
347
+ lead = {
348
+ method: r.method,
349
+ path: r.path,
350
+ status: r.status,
351
+ failure: r.failure,
352
+ type: r.type || undefined,
353
+ ...(sent ? { sent: sent } : {}),
354
+ ...(got ? { got: got } : {}),
355
+ }
356
+ }
267
357
  const failing = []
268
358
  const api = []
359
+ const pending = []
269
360
  const rest = []
270
361
  for (let i = 0; i < requests.length; i++) {
271
362
  const r = requests[i]
272
363
  // An answered/aborted shell request is no evidence at all — do not spend a kept row on it.
273
- if (shellRequest(r) && !failedRequest(r)) continue
364
+ // The exception is a document subresource that never finished while the navigation was
365
+ // still open: that IS what the timeout waited on.
366
+ if (shellRequest(r) && !failedRequest(r) && !(navigationOpen && neverFinished(r) && !incidentalShell(r))) continue
274
367
  if (failedRequest(r)) failing.push(i)
275
368
  else if (r.type === 'fetch' || r.type === 'xhr' || r.type === 'document') api.push(i)
369
+ else if (navigationOpen && neverFinished(r)) pending.push(i)
276
370
  else rest.push(i)
277
371
  }
278
- const keep = failing.slice(-MAX_REQUESTS).concat(api.slice(-MAX_REQUESTS)).concat(rest.slice(-2))
372
+ const keep = failing.slice(-MAX_REQUESTS).concat(api.slice(-MAX_REQUESTS))
373
+ .concat(pending.slice(-MAX_REQUESTS)).concat(rest.slice(-2))
279
374
  const chosen = []
280
375
  const seen = new Set()
281
376
  for (const i of keep.sort(function (a, b) {
@@ -297,25 +392,6 @@ for (const one of found.slice(0, MAX_TRACES)) {
297
392
  type: r.type || undefined,
298
393
  })
299
394
  }
300
- // The page's own actions, timed. before/after pair on the call id, and only the LIBRARY
301
- // traces carry a class and a method (Frame.goto, Locator.fill); the test runner's own
302
- // trace times its hooks and fixtures with class Test, which is not a page action.
303
- const ended = {}
304
- for (const entry of trace) {
305
- if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
306
- ended[entry.callId] = entry.endTime
307
- }
308
- const actions = []
309
- for (const entry of trace) {
310
- if (!entry || entry.type !== 'before') continue
311
- if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
312
- if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
313
- actions.push({
314
- api: entry.class + '.' + entry.method,
315
- start: entry.startTime,
316
- end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
317
- })
318
- }
319
395
  const consoleErrors = []
320
396
  let spec = null
321
397
  let line = 0
@@ -343,6 +419,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
343
419
  dir: path.relative(ROOT, path.dirname(one.file)),
344
420
  spec: spec,
345
421
  line: line,
422
+ lead: lead,
346
423
  // Failures first, per the sort above; the keep-window is the HEAD of that order. Taking the
347
424
  // tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
348
425
  // plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
@@ -352,6 +429,26 @@ for (const one of found.slice(0, MAX_TRACES)) {
352
429
  }) + '\\n')
353
430
  }
354
431
  `;
432
+ /**
433
+ * One request out of a `TRACE` line's json, or null when the value is not an object.
434
+ *
435
+ * @param value - One entry of the line's `requests` array, or its `lead`.
436
+ * @returns The request, or null.
437
+ */
438
+ function parseTraceRequest(value) {
439
+ if (typeof value !== 'object' || value === null)
440
+ return null;
441
+ const r = value;
442
+ return {
443
+ method: String(r.method ?? ''),
444
+ path: String(r.path ?? ''),
445
+ status: Number(r.status ?? 0) || 0,
446
+ ...(typeof r.failure === 'string' && r.failure ? { failure: r.failure } : {}),
447
+ ...(typeof r.sent === 'string' && r.sent ? { sent: r.sent } : {}),
448
+ ...(typeof r.got === 'string' && r.got ? { got: r.got } : {}),
449
+ ...(typeof r.type === 'string' && r.type ? { type: r.type } : {}),
450
+ };
451
+ }
355
452
  /**
356
453
  * The traces {@link traceArtifactsScript} printed, parsed.
357
454
  *
@@ -371,20 +468,14 @@ export function parseTraceArtifacts(stdout) {
371
468
  const requests = Array.isArray(raw.requests) ? raw.requests : [];
372
469
  const consoleMessages = Array.isArray(raw.console) ? raw.console : [];
373
470
  const actions = Array.isArray(raw.actions) ? raw.actions : [];
471
+ const lead = parseTraceRequest(raw.lead);
374
472
  out.push({
375
473
  spec: typeof raw.spec === 'string' ? raw.spec : null,
376
474
  line: typeof raw.line === 'number' ? raw.line : 0,
377
475
  requests: requests
378
- .filter((r) => typeof r === 'object' && r !== null)
379
- .map((r) => ({
380
- method: String(r.method ?? ''),
381
- path: String(r.path ?? ''),
382
- status: Number(r.status ?? 0) || 0,
383
- ...(typeof r.failure === 'string' && r.failure ? { failure: r.failure } : {}),
384
- ...(typeof r.sent === 'string' && r.sent ? { sent: r.sent } : {}),
385
- ...(typeof r.got === 'string' && r.got ? { got: r.got } : {}),
386
- ...(typeof r.type === 'string' && r.type ? { type: r.type } : {}),
387
- })),
476
+ .map(parseTraceRequest)
477
+ .filter((request) => request !== null),
478
+ ...(lead === null ? {} : { lead }),
388
479
  console: consoleMessages.map(String),
389
480
  actions: actions
390
481
  .filter((a) => typeof a === 'object' && a !== null)
@@ -445,6 +536,31 @@ function requestLine(request) {
445
536
  : '';
446
537
  return `${head}${sent}${got}`;
447
538
  }
539
+ /**
540
+ * The failing step's own request as the note writes it first: `the failing step sent DELETE
541
+ * /api/profiles/x/follow → 200 got {"profile":{…}}`.
542
+ *
543
+ * It prints the response body whatever the status — an ANSWERED request is the point: m135's
544
+ * Follow click sent the DELETE an unfollow sends, got a 200 and a profile with `following: false`,
545
+ * and nothing in the result said which verb the click used.
546
+ *
547
+ * @param request - The trace's `lead` request.
548
+ * @returns The clause the test's row opens with.
549
+ */
550
+ 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}`;
563
+ }
448
564
  /** Whether a sent body is an object whose only top-level key is `data` — the wrapped-body trap. */
449
565
  function wrappedInData(text) {
450
566
  try {
@@ -534,6 +650,33 @@ export function unansweredRequest(request) {
534
650
  return false;
535
651
  return request.status <= 0 && request.failure === undefined;
536
652
  }
653
+ /**
654
+ * Whether a trace recorded a DOCUMENT SUBRESOURCE that never finished: a stylesheet, script or
655
+ * other asset the page itself loads, with no response and no error text — still in flight when
656
+ * the navigation timed out.
657
+ *
658
+ * The page shell's own incidental requests (`/sw.js`, the manifest, a favicon, a font) are
659
+ * excepted for the reason {@link isStaticAssetRequest} documents: m125's run was sent to write a
660
+ * caching service worker by an aborted `/sw.js`. A pending `/assets/*.css` is different — the
661
+ * navigation blocks on it, so it is the reason `page.goto` ran out of time rather than one of the
662
+ * app's API calls (xr-molecule-flash-m135 +34:10: three deployed `page.goto` timeouts on tests that
663
+ * pass locally, one 88 KB stylesheet fetch measured at 27 s, and the failing tests' traces each
664
+ * held the never-answered CSS while the note said "the cause is in the page").
665
+ *
666
+ * @param request - One request a trace recorded.
667
+ * @returns True when the request is a document subresource that never finished.
668
+ */
669
+ export function stalledDocumentSubresource(request) {
670
+ if (request.status > 0 || request.failure !== undefined)
671
+ return false;
672
+ if (request.type === 'document')
673
+ return false;
674
+ if (request.type === 'font' || request.type === 'manifest')
675
+ return false;
676
+ if (STATIC_ASSET_PATH.test(requestPath(request)))
677
+ return false;
678
+ return isStaticAssetRequest(request);
679
+ }
537
680
  /**
538
681
  * How far a trace's last recorded step can sit after an earlier walk's failing test and still be
539
682
  * that test. The trace names the LINE of the last step it recorded (the click, the wait), while the
@@ -591,14 +734,19 @@ function repeatsEarlierFailure(item, earlier) {
591
734
  * hidden in "… and 7 more traced failures".
592
735
  *
593
736
  * A request the page shell made (m125 +29:33: `/sw.js`, a font) is not evidence about the step
594
- * either, answered or not.
737
+ * either, answered or not. The exception is a document subresource that never finished while the
738
+ * page's own navigation was still open: the navigation waits on it, so it is the reason the
739
+ * `page.goto` timed out ({@link stalledDocumentSubresource}).
595
740
  *
596
741
  * @param item - One traced failure.
597
742
  * @returns True when the trace holds a request or console error worth a row.
598
743
  */
599
744
  function traceRowEvidence(item) {
600
745
  return (item.console.length > 0 ||
601
- item.requests.some((request) => !isStaticAssetRequest(request) && !unansweredRequest(request)));
746
+ item.requests.some((request) => !isStaticAssetRequest(request) && !unansweredRequest(request)) ||
747
+ // The one shape in which a subresource with no response IS the cause: the navigation it was
748
+ // part of never finished (see {@link stalledDocumentSubresource}).
749
+ (navigationNeverFinished(item) && item.requests.some(stalledDocumentSubresource)));
602
750
  }
603
751
  /**
604
752
  * The note's row order: a row with a REAL failure first, then a row whose failure an earlier walk
@@ -609,6 +757,10 @@ function traceRowEvidence(item) {
609
757
  function traceRowTier(item, earlier) {
610
758
  if (item.requests.some(failedRequest))
611
759
  return 0;
760
+ // A navigation that never finished because its own subresource never got a response is as
761
+ // strong a cause as a failed request — it is the one fact the timeout was waiting on (m135).
762
+ if (navigationNeverFinished(item) && item.requests.some(stalledDocumentSubresource))
763
+ return 0;
612
764
  if (repeatsEarlierFailure(item, earlier))
613
765
  return 1;
614
766
  return 2;
@@ -658,6 +810,94 @@ function transportFailure(items) {
658
810
  }
659
811
  return null;
660
812
  }
813
+ /**
814
+ * A request's path when the trace recorded a same-origin path — `/users` — or null for an
815
+ * absolute URL (another origin's path is no evidence about this app's API).
816
+ *
817
+ * @param request - One request a trace recorded.
818
+ * @returns The path without a query or trailing slash, or null.
819
+ */
820
+ function sameOriginPath(request) {
821
+ const raw = String(request.path ?? '').split(/[?#]/)[0] ?? '';
822
+ if (!raw.startsWith('/') || raw === '/')
823
+ return null;
824
+ return raw.replace(/\/+$/, '');
825
+ }
826
+ /**
827
+ * The API path prefixes the traced requests themselves show.
828
+ *
829
+ * A request whose path ends with another traced request's path carries that other path's prefix
830
+ * (`/api/users` beside `/users` → `/api`) — the trace's own evidence, with no literal in the code.
831
+ *
832
+ * @param items - The traces the note is about.
833
+ * @returns The one-segment prefixes (`/api`), sorted; empty when the traces show none.
834
+ */
835
+ function tracedApiPrefixes(items) {
836
+ const paths = items.flatMap((item) => item.requests.map(sameOriginPath).filter((path) => path !== null));
837
+ const prefixes = new Set();
838
+ for (const longer of paths) {
839
+ for (const shorter of paths) {
840
+ if (longer === shorter || shorter.length < 2 || !longer.endsWith(shorter))
841
+ continue;
842
+ const prefix = longer.slice(0, longer.length - shorter.length);
843
+ if (/^\/[^/]+$/.test(prefix))
844
+ prefixes.add(prefix);
845
+ }
846
+ }
847
+ return [...prefixes].sort();
848
+ }
849
+ /**
850
+ * The failed 404s whose path carries none of the known API prefixes.
851
+ *
852
+ * Only a run where the prefix is KNOWABLE diagnoses: the caller reads it from the app's client
853
+ * config, and the traces' own requests add what they show ({@link tracedApiPrefixes}). With no
854
+ * known prefix, an unprefixed path is just a path — assuming `/api` is how a correct request gets
855
+ * a wrong note.
856
+ *
857
+ * @param items - The traces the note is about.
858
+ * @param apiPrefixes - Prefixes the app's client config names (`['/api']`), never a literal.
859
+ * @returns One entry per distinct failed 404, in trace order.
860
+ */
861
+ function missingApiPrefixRequests(items, apiPrefixes) {
862
+ const prefixes = new Set([...apiPrefixes, ...tracedApiPrefixes(items)]
863
+ .map((prefix) => `/${String(prefix).replace(/^\/+|\/+$/g, '')}`)
864
+ .filter((prefix) => prefix !== '/'));
865
+ if (prefixes.size === 0)
866
+ return [];
867
+ const known = [...prefixes];
868
+ const out = [];
869
+ const seen = new Set();
870
+ for (const item of items) {
871
+ for (const request of item.requests) {
872
+ // A document navigation is a page route, never an API call — a 404 on one says nothing
873
+ // about the client's base URL.
874
+ if (request.status !== 404 || request.type === 'document' || !failedRequest(request))
875
+ continue;
876
+ const path = sameOriginPath(request);
877
+ if (path === null)
878
+ continue;
879
+ if (known.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
880
+ continue;
881
+ const key = `${request.method} ${path}`;
882
+ if (seen.has(key))
883
+ continue;
884
+ seen.add(key);
885
+ out.push({ method: request.method, path, prefix: known[0] ?? '' });
886
+ }
887
+ }
888
+ return out;
889
+ }
890
+ /**
891
+ * The sentence naming a request that reached the page origin without the app's API prefix.
892
+ *
893
+ * @param request - The failed request and the prefix its sibling calls carry.
894
+ * @returns The sentence the note appends.
895
+ */
896
+ function missingApiPrefixSentence(request) {
897
+ return (`\`${request.method} ${request.path}\` → 404: this request went to \`${request.path}\` ` +
898
+ `without the \`${request.prefix}\` prefix — the HTTP client's base URL is not set: is ` +
899
+ '`setupProviders()` (which runs the HTTP bond setup) called in `main.tsx`?');
900
+ }
661
901
  /**
662
902
  * The note a failed suite run's result carries: what each failing test's page actually sent, and
663
903
  * what came back.
@@ -678,6 +918,12 @@ function transportFailure(items) {
678
918
  * line rather than inferred from the status; when one of those bodies is an object whose only key
679
919
  * is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
680
920
  *
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.
926
+ *
681
927
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
682
928
  * needs a recorded response, a real failure or a console error ({@link traceRowEvidence}), rows are
683
929
  * deduplicated by the test's location, and they are ordered real failure → repeated from an
@@ -689,14 +935,31 @@ function transportFailure(items) {
689
935
  * transport error (`net::ERR_CONTENT_DECODING_FAILED` and friends) and the document it hit: nothing
690
936
  * rendered, so no row below is app evidence (xr-molecule-flash-m118 +37:09).
691
937
  *
938
+ * When a navigation never finished and one of the document's own subresources (a stylesheet, a
939
+ * script, an asset under `/assets/`) never got a response either, that request takes a row and the
940
+ * note says the page was waiting on it. It is not an API call, but on a `page.goto` timeout it is
941
+ * the only request that can explain the timeout — the deployed app was serving the page while one
942
+ * fetch stalled for 27 s (xr-molecule-flash-m135 +34:10). The page shell's incidental requests
943
+ * (`/sw.js`, the manifest, a favicon, a font) stay out, for m125's reason.
944
+ *
945
+ * A 404 whose path lacks the API prefix the app's own calls carry is called out with that prefix
946
+ * ({@link missingApiPrefixRequests}): xr-molecule-flash-m145 posted `/users` to `vite preview`,
947
+ * whose 404 was answered by the static server because the HTTP client's `baseURL` was never set —
948
+ * and the note printed "read the handler this route reaches in your own source", which presumes a
949
+ * request that never reached the API. The prefix is READ (the caller passes what the app's client
950
+ * config names; the traces add what their own requests show) and never assumed to be `/api`.
951
+ *
692
952
  * @param items - The traces {@link parseTraceArtifacts} read.
693
953
  * @param target - Where the run went — the deployed host, as the note names it.
694
954
  * @param earlierFailures - The failing `file:line`s earlier walks of this suite left, so a repeat
695
955
  * is ranked above a one-off. Empty when the caller knows of no earlier walk.
956
+ * @param apiPrefixes - The API path prefixes the app's client config names (`['/api']`), read from
957
+ * the app by the caller. Empty when the caller could not read one; the traces' own requests may
958
+ * still supply it. Never a hard-coded literal.
696
959
  * @returns The note, or null when no trace held a recorded response, a real failure or a console
697
960
  * error.
698
961
  */
699
- export function failureTraceNote(items, target, earlierFailures = []) {
962
+ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixes = []) {
700
963
  const candidates = items.filter(traceRowEvidence);
701
964
  if (candidates.length === 0)
702
965
  return null;
@@ -720,21 +983,68 @@ export function failureTraceNote(items, target, earlierFailures = []) {
720
983
  const wrapped = traced.some((item) => item.requests.some((request) => request.sent !== undefined && wrappedInData(request.sent)));
721
984
  // The "one of these requests failing" answer is only honest when a listed request really failed.
722
985
  const anyFailed = traced.some((item) => item.requests.some(failedRequest));
986
+ // m145's shape: the app's HTTP client lost its base URL, so every API call reached the page
987
+ // origin without the prefix the app mounts its routes under. The prefix is READ — the caller's
988
+ // client config plus what the traces show — never assumed to be `/api`.
989
+ const missingPrefix = missingApiPrefixRequests(traced, apiPrefixes);
990
+ const failedRequests = traced.flatMap((item) => item.requests.filter(failedRequest));
991
+ const missingKeys = new Set(missingPrefix.map((request) => `${request.method} ${request.path}`));
992
+ const everyFailureLacksPrefix = failedRequests.length > 0 &&
993
+ failedRequests.every((request) => {
994
+ const path = sameOriginPath(request);
995
+ return path !== null && missingKeys.has(`${request.method} ${path}`);
996
+ });
997
+ // The route advice goes when every failure is one of these: a request that never reached the
998
+ // API has no handler to read (m145's note sent the run to the PWA and then to the handler).
999
+ const prefixAdvice = missingPrefix.length > 0
1000
+ ? ` ${missingPrefix.slice(0, 2).map(missingApiPrefixSentence).join(' ')}`
1001
+ : '';
1002
+ // The document subresources that never finished anywhere in this read — the assets a timed-out
1003
+ // navigation was waiting on, when there is no failed request to explain the timeout.
1004
+ const stalledAssets = [
1005
+ ...new Set(traced
1006
+ .filter(navigationNeverFinished)
1007
+ .flatMap((item) => item.requests.filter(stalledDocumentSubresource))
1008
+ .map(requestLabel)),
1009
+ ];
723
1010
  const rows = [];
724
1011
  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.
1015
+ 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;
725
1020
  // The page shell's own requests take no row, in any bucket: an aborted `/sw.js` would
726
1021
  // otherwise be printed as an "answered" request with no response (m125 +29:33).
727
- const requests = item.requests.filter((request) => !isStaticAssetRequest(request));
1022
+ const requests = item.requests.filter((request) => !isStaticAssetRequest(request) && !isLead(request));
728
1023
  const failed = requests.filter(failedRequest);
729
1024
  const unanswered = requests.filter(unansweredRequest);
730
1025
  const answered = requests.filter((request) => !failedRequest(request) && !unansweredRequest(request));
1026
+ // A document subresource that never finished, on a trace whose navigation never finished:
1027
+ // the page was waiting on it when the test's budget ran out (m135).
1028
+ const stalled = navigationNeverFinished(item)
1029
+ ? item.requests.filter(stalledDocumentSubresource)
1030
+ : [];
731
1031
  let room = MAX_TRACE_NOTE_REQUESTS;
732
1032
  const parts = [];
1033
+ if (lead !== undefined) {
1034
+ parts.push(leadRequestLine(lead));
1035
+ room -= 1;
1036
+ }
733
1037
  if (failed.length > 0) {
734
1038
  const shown = failed.slice(0, room);
735
1039
  room -= shown.length;
736
1040
  parts.push(`failed: ${shown.map(requestLine).join('; ')}`);
737
1041
  }
1042
+ if (stalled.length > 0 && room > 0) {
1043
+ const shown = stalled.slice(0, room);
1044
+ room -= shown.length;
1045
+ parts.push(`never finished: ${shown.map(requestLabel).join('; ')} — a document subresource with no ` +
1046
+ 'response, still in flight when the navigation timed out');
1047
+ }
738
1048
  if (unanswered.length > 0 && room > 0) {
739
1049
  const shown = unanswered.slice(0, room);
740
1050
  room -= shown.length;
@@ -775,17 +1085,45 @@ export function failureTraceNote(items, target, earlierFailures = []) {
775
1085
  '`MOL_E2E_PREVIEW_PLUGIN=0 npx vite preview --host 127.0.0.1 --port 4173 --strictPort`. '
776
1086
  : '') +
777
1087
  'Fix the server before the app.\n\n';
1088
+ // A navigation that never finished WITH a subresource that never got a response, and no
1089
+ // transport error: the page was waiting on the asset when the budget ran out. That is the host
1090
+ // or the network serving that file, not the app's state — say it before any app-side reading
1091
+ // (xr-molecule-flash-m135 +34:10: three deployed goto timeouts on tests passing locally in
1092
+ // 1.2-5.6 s, one 88 KB stylesheet fetch measured at 27 s, and the note said "the cause is in
1093
+ // the page").
1094
+ const stalledLead = transport !== null || stalledAssets.length === 0
1095
+ ? ''
1096
+ : `Every failure this run traced is a \`page.goto\` that never finished, and the document's ` +
1097
+ `own ${stalledAssets.length === 1 ? 'subresource' : 'subresources'} ${stalledAssets.join(', ')} ` +
1098
+ `never got a response — the navigation was waiting on ${stalledAssets.length === 1 ? 'it' : 'them'} ` +
1099
+ `when the test's budget ran out. That is the host or the network serving the asset, not the ` +
1100
+ `app's state or a selector: re-run the same tests once, unchanged, against ${target} before ` +
1101
+ `editing anything, and if the stall repeats measure that exact fetch ` +
1102
+ `(\`curl -o /dev/null -w '%{time_starttransfer} %{time_total}' <url>\`) before treating it as ` +
1103
+ `the app's.\n\n`;
778
1104
  return (transportLead +
1105
+ stalledLead +
779
1106
  `The platform read the failing tests' own Playwright traces from the run against ${target} ` +
780
- `(what each page sent and got: the requests that failed, the ones that got no response, and ` +
781
- `its last answered ones):\n` +
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` +
782
1109
  `${rows.join('\n')}${more}\n` +
783
1110
  (anyFailed
784
- ? 'A click that changed nothing is usually one of these requests failing, not the selector: ' +
785
- 'read the handler this route reaches in your own source, and stop `catch`-ing the request of a ' +
786
- 'user action silently — log it.'
787
- : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
788
- 'page, not the API: read the state the failing test waited on.') +
1111
+ ? everyFailureLacksPrefix
1112
+ ? ''
1113
+ : 'A click that changed nothing is usually one of these requests failing, not the selector: ' +
1114
+ 'read the handler this route reaches in your own source, and stop `catch`-ing the request of a ' +
1115
+ 'user action silently — log it.'
1116
+ : stalledAssets.length > 0
1117
+ ? 'No listed request failed — none got a 4xx/5xx or a network error — but the document did ' +
1118
+ 'not finish loading because its own subresource never answered. Do not read this as a ' +
1119
+ 'page-state bug: re-run these tests against the same URL first, and change the app only ' +
1120
+ 'if the same navigation times out again with no stalled asset in the trace.'
1121
+ : '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.') +
1126
+ prefixAdvice +
789
1127
  (wrapped
790
1128
  ? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +
791
1129
  '`put(url, data, config)` / `patch(url, data, config)` take the body as their SECOND ' +