@molecule/api-ai-tools 1.9.15 → 1.9.18

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.
@@ -27,7 +27,6 @@
27
27
  * @module
28
28
  */
29
29
  import { locatedCommandSegments, statementArgumentEnd } from './command-position.js';
30
- import { isDiagnosisRun } from './suite-overlap.js';
31
30
  import { isPlaywrightRunCommand, redactSecrets } from './utilities.js';
32
31
  /** How many traces one read reports, newest first. */
33
32
  export const MAX_TRACE_ARTIFACTS = 12;
@@ -84,6 +83,13 @@ const TRACE_NOTE_FAILURE_CHARS = 120;
84
83
  * test's trace holds the whole page load. A request with no response and NO error text was aborted
85
84
  * or still in flight when the test ended; it is kept too, but as what it is, not as a failure.
86
85
  *
86
+ * A trace.zip holds ONE trace member per browser context the test used (beside the runner's own
87
+ * `test.trace`), and the archive's member order is not the test's chronology. Each member is read
88
+ * as its own context: actions and requests keep their context id, requests and frame snapshots
89
+ * keep the pageId Playwright records, and the failing assertion's page — the page its own step id
90
+ * ran on — decides which actions, snapshots and requests become the test's row. What the test's
91
+ * other browser contexts recorded is reported apart, as `otherContexts` (xr-molecule-flash-m160).
92
+ *
87
93
  * A request answered 4xx by the app's OWN API also keeps what the page SENT and the first of what
88
94
  * came back: Playwright stores either inline (`text`) or in a resource entry (`_file`), and both
89
95
  * are read. That is where a wrapped body shows: xr-molecule-flash-m111's helper passed
@@ -191,6 +197,27 @@ function messageOf(value) {
191
197
  return null
192
198
  }
193
199
 
200
+ // The text a failing assertion waited for, named by its own error: a \`has-text(…)\` CSS
201
+ // pseudo-class, a \`getByText(…)\` locator, or a \`toContainText(…)\` expectation. A strict-mode
202
+ // violation is not this shape — two elements DID match the text — and neither is a wait on a
203
+ // selector with no text argument. The ANSI colouring Playwright leaves in the message is dropped
204
+ // before the read, or the quotes it wraps around a locator would never parse.
205
+ function waitedTextOf(message) {
206
+ if (!message) return null
207
+ const text = String(message).replace(/\\u001b\\[[0-9;]*m/g, '')
208
+ if (text.indexOf('strict mode violation') !== -1) return null
209
+ const quoted = function (pattern) {
210
+ const m = pattern.exec(text)
211
+ return m === null ? null : m[2]
212
+ }
213
+ const hasText = quoted(/has-text\\(\\s*(['"])((?:(?!\\1).)*)\\1/)
214
+ if (hasText !== null) return hasText
215
+ const byText = quoted(/getByText\\(\\s*(['"])((?:(?!\\1).)*)\\1/)
216
+ if (byText !== null) return byText
217
+ if (text.indexOf('toContainText') === -1) return null
218
+ return quoted(/Expected(?: string)?:\\s*(["'])((?:(?!\\1).)*)\\1/)
219
+ }
220
+
194
221
  // A request/response body exactly as Playwright stored it: inline on the value, or in a resource
195
222
  // entry the value names. Bounded, and binary is dropped — a JSON body is what this is for, and a
196
223
  // multipart upload would print bytes.
@@ -276,26 +303,69 @@ const found = []
276
303
  walk(ROOT, 5, found)
277
304
  found.sort(function (a, b) { return b.mtime - a.mtime })
278
305
 
306
+ // A trace.zip holds ONE trace member per browser context the test used (1-trace.trace,
307
+ // 3-trace.trace, …) beside the test runner's own test.trace. The archive ORDER of those members
308
+ // is not the test's chronology: xr-molecule-flash-m160's helper context was written after the
309
+ // primary page's, so the concatenation's last user action was the helper's editor click and the
310
+ // last frame snapshot was /editor, while the assertion had failed on the primary page at
311
+ // /profile/:username. Each member is read as its own context, and everything below attributes a
312
+ // request, an action and a frame snapshot to the page and context that recorded it.
313
+ function traceContextOf(name) {
314
+ if (name === 'test.trace') return 'test'
315
+ if (name === 'trace.trace') return '0'
316
+ const m = /^(.+)-trace\\.trace$/.exec(name)
317
+ return m === null ? null : m[1]
318
+ }
319
+ function networkContextOf(name) {
320
+ if (name === 'trace.network') return '0'
321
+ const m = /^(.+)-trace\\.network$/.exec(name)
322
+ return m === null ? null : m[1]
323
+ }
324
+
279
325
  for (const one of found.slice(0, MAX_TRACES)) {
280
326
  let buf
281
327
  try { buf = fs.readFileSync(one.file) } catch (e) { continue }
282
328
  let entries
283
329
  try { entries = zipEntries(buf) } catch (e) { continue }
284
- const network = []
285
- const trace = []
330
+ const runner = []
331
+ const contexts = []
332
+ const contextById = {}
333
+ function contextFor(id) {
334
+ let ctx = contextById[id]
335
+ if (!ctx) {
336
+ ctx = { id: id, trace: [], network: [], requests: [], actions: [], frames: [] }
337
+ contextById[id] = ctx
338
+ contexts.push(ctx)
339
+ }
340
+ return ctx
341
+ }
286
342
  for (const entry of entries) {
287
- if (/\\.network$/.test(entry.name)) network.push.apply(network, ndjson(zipData(buf, entry)))
288
- else if (/\\.trace$/.test(entry.name)) trace.push.apply(trace, ndjson(zipData(buf, entry)))
343
+ const traceContext = traceContextOf(entry.name)
344
+ if (traceContext === 'test') {
345
+ runner.push.apply(runner, ndjson(zipData(buf, entry)))
346
+ continue
347
+ }
348
+ if (traceContext !== null) {
349
+ const ctx = contextFor(traceContext)
350
+ ctx.trace.push.apply(ctx.trace, ndjson(zipData(buf, entry)))
351
+ continue
352
+ }
353
+ const networkContext = networkContextOf(entry.name)
354
+ if (networkContext !== null) {
355
+ const ctx = contextFor(networkContext)
356
+ ctx.network.push.apply(ctx.network, ndjson(zipData(buf, entry)))
357
+ }
289
358
  }
290
- const requests = []
291
- for (const line of network) {
292
- if (!line || line.type !== 'resource-snapshot') continue
293
- const snapshot = line.snapshot || {}
294
- const request = snapshot.request || {}
295
- if (!request.url || !request.method) continue
296
- const response = snapshot.response || {}
297
- const type = String(snapshot._resourceType || '')
298
- if (!origin) origin = type === 'document' ? originOf(String(request.url)) : ''
359
+ const requestsAll = []
360
+ for (const ctx of contexts) {
361
+ for (const line of ctx.network) {
362
+ if (!line || line.type !== 'resource-snapshot') continue
363
+ const snapshot = line.snapshot || {}
364
+ const request = snapshot.request || {}
365
+ if (!request.url || !request.method) continue
366
+ const response = snapshot.response || {}
367
+ const type = String(snapshot._resourceType || '')
368
+ if (!origin) origin = type === 'document' ? originOf(String(request.url)) : ''
299
369
  // What the request carried for CREDENTIALS — never the secret itself. A 401 on the app's own
300
370
  // API cannot be read without this: m148's favorite POST answered 401 with no Authorization
301
371
  // header at all, so the client never attached the token, and the note sent the run to the
@@ -312,51 +382,215 @@ for (const one of found.slice(0, MAX_TRACES)) {
312
382
  const authSpace = authValue.search(/\\s/)
313
383
  const authWord = authSpace > 0 ? authValue.slice(0, authSpace) : ''
314
384
  const authScheme = /^[A-Za-z][A-Za-z0-9._~+/-]{0,23}$/.test(authWord) ? authWord : undefined
315
- const sentCookie = headerList.some(function (h) {
316
- return h && typeof h.name === 'string' && /^cookie$/i.test(h.name)
317
- })
318
- requests.push({
319
- method: String(request.method).toUpperCase(),
320
- path: String(request.url).slice(0, MAX_URL),
321
- status: Number(response.status) || 0,
322
- failure: typeof response._failureText === 'string' ? response._failureText : undefined,
323
- type: type,
324
- sentAuthorization: authHeader !== undefined,
325
- authorization: authScheme,
326
- sentCookie: sentCookie,
327
- // The HAR entry carries the monotonic clock the trace's action entries use, so a request
328
- // can be attributed to the action that caused it (see \`lastAction\` below).
329
- startedAt: typeof snapshot._monotonicTime === 'number' ? snapshot._monotonicTime : undefined,
330
- postData: request.postData,
331
- content: response.content,
332
- })
385
+ const sentCookie = headerList.some(function (h) {
386
+ return h && typeof h.name === 'string' && /^cookie$/i.test(h.name)
387
+ })
388
+ const parsed = {
389
+ method: String(request.method).toUpperCase(),
390
+ path: String(request.url).slice(0, MAX_URL),
391
+ status: Number(response.status) || 0,
392
+ failure: typeof response._failureText === 'string' ? response._failureText : undefined,
393
+ type: type,
394
+ sentAuthorization: authHeader !== undefined,
395
+ authorization: authScheme,
396
+ sentCookie: sentCookie,
397
+ // The HAR entry carries the monotonic clock the trace's action entries use, so a request
398
+ // can be attributed to the action that caused it (see \`lastAction\` below).
399
+ startedAt: typeof snapshot._monotonicTime === 'number' ? snapshot._monotonicTime : undefined,
400
+ // The context member and the page ref the HAR entry records: a request made by the test's
401
+ // helper context is not the failing page's, however late the archive wrote it (m160).
402
+ context: ctx.id,
403
+ pageId: typeof snapshot.pageref === 'string' ? snapshot.pageref : undefined,
404
+ postData: request.postData,
405
+ content: response.content,
406
+ }
407
+ ctx.requests.push(parsed)
408
+ requestsAll.push(parsed)
409
+ }
333
410
  }
334
411
  if (!origin) {
335
- const first = requests.find(function (r) { return r.type === 'document' }) || requests[0]
412
+ const first = requestsAll.find(function (r) { return r.type === 'document' }) || requestsAll[0]
336
413
  origin = first ? originOf(first.path) : ''
337
414
  }
338
- for (const r of requests) r.path = pathOf(r.path).slice(0, MAX_URL)
415
+ for (const r of requestsAll) r.path = pathOf(r.path).slice(0, MAX_URL)
339
416
  // The page's own actions, timed. before/after pair on the call id, and only the LIBRARY
340
417
  // traces carry a class and a method (Frame.goto, Locator.fill); the test runner's own
341
- // trace times its hooks and fixtures with class Test, which is not a page action. Read BEFORE
342
- // the request buckets: whether the navigation itself finished decides whether a subresource
343
- // with no response is evidence (see \`navigationOpen\` below).
344
- const ended = {}
345
- for (const entry of trace) {
346
- if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
347
- ended[entry.callId] = entry.endTime
418
+ // trace times its hooks and fixtures with class Test, which is not a page action. Each action
419
+ // keeps the context it came from and the page/frame its frame snapshot names, so the failing
420
+ // assertion's own page can be selected below (m160).
421
+ const actionsAll = []
422
+ const framesAll = []
423
+ for (const ctx of contexts) {
424
+ const ended = {}
425
+ for (const entry of ctx.trace) {
426
+ if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
427
+ ended[entry.callId] = entry.endTime
428
+ }
429
+ const pageByCall = {}
430
+ for (const entry of ctx.trace) {
431
+ if (!entry || entry.type !== 'frame-snapshot') continue
432
+ const snap = entry.snapshot || {}
433
+ if (typeof snap.callId === 'string' && typeof snap.pageId === 'string' && pageByCall[snap.callId] === undefined)
434
+ pageByCall[snap.callId] = snap.pageId
435
+ if (snap.isMainFrame === false) continue
436
+ if (typeof snap.frameUrl !== 'string' || typeof snap.timestamp !== 'number') continue
437
+ ctx.frames.push({
438
+ url: pathOf(snap.frameUrl).slice(0, MAX_URL),
439
+ at: snap.timestamp,
440
+ context: ctx.id,
441
+ pageId: typeof snap.pageId === 'string' ? snap.pageId : undefined,
442
+ frameId: typeof snap.frameId === 'string' ? snap.frameId : undefined,
443
+ })
444
+ }
445
+ for (const entry of ctx.trace) {
446
+ if (!entry || entry.type !== 'before') continue
447
+ if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
448
+ if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
449
+ const action = {
450
+ api: entry.class + '.' + entry.method,
451
+ start: entry.startTime,
452
+ end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
453
+ context: ctx.id,
454
+ pageId: typeof entry.callId === 'string' ? pageByCall[entry.callId] : undefined,
455
+ frameId: undefined,
456
+ stepId: typeof entry.stepId === 'string' ? entry.stepId : undefined,
457
+ }
458
+ if (action.pageId !== undefined) {
459
+ for (const frame of ctx.frames) {
460
+ if (frame.pageId !== action.pageId) continue
461
+ action.frameId = frame.frameId
462
+ break
463
+ }
464
+ }
465
+ ctx.actions.push(action)
466
+ }
467
+ // Monotonic time, not archive order: two contexts interleave in the zip, and one context's
468
+ // own actions still have to be read in the order the page performed them.
469
+ ctx.actions.sort(function (a, b) { return a.start - b.start })
470
+ ctx.frames.sort(function (a, b) { return a.at - b.at })
471
+ for (const action of ctx.actions) actionsAll.push(action)
472
+ for (const frame of ctx.frames) framesAll.push(frame)
348
473
  }
349
- const actions = []
350
- for (const entry of trace) {
351
- if (!entry || entry.type !== 'before') continue
352
- if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
353
- if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
354
- actions.push({
355
- api: entry.class + '.' + entry.method,
356
- start: entry.startTime,
357
- end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
358
- })
474
+ // The step the runner itself reports as failed, and the location it names. The runner writes
475
+ // the error on the failed step's \`after\` entry; taking the LAST step with a spec stack instead
476
+ // named the afterEach's \`context.close()\` (m160's social.spec.ts:13) rather than the strict-mode
477
+ // assertion at :90. The first error is the test's own failure — an afterEach that also fails
478
+ // runs after it.
479
+ let failedStepId = null
480
+ let failedStep = null
481
+ let failedEnd = null
482
+ let failedError = ''
483
+ let failureSpec = null
484
+ let failureLine = 0
485
+ let lastSpec = null
486
+ let lastSpecLine = 0
487
+ for (const entry of runner) {
488
+ if (!entry) continue
489
+ if (entry.type === 'after' && entry.error && typeof entry.callId === 'string' && failedStepId === null) {
490
+ failedStepId = entry.callId
491
+ failedEnd = typeof entry.endTime === 'number' ? entry.endTime : null
492
+ failedError = messageOf(entry.error) || ''
493
+ }
359
494
  }
495
+ // The fallback location — the last step that names a spec — is read from every member, runner
496
+ // and library alike: a trace written without a test.trace (a synthetic fixture, an older
497
+ // Playwright) still names its spec on the library entries, and dropping that would leave the
498
+ // row placeless.
499
+ for (const traceEntries of [runner].concat(contexts.map(function (ctx) { return ctx.trace }))) {
500
+ for (const entry of traceEntries) {
501
+ if (!entry || entry.type !== 'before' || !Array.isArray(entry.stack)) continue
502
+ for (const frame of entry.stack) {
503
+ if (typeof frame.file !== 'string' || !/\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) continue
504
+ lastSpec = specPathOf(frame.file)
505
+ lastSpecLine = Number(frame.line) || 0
506
+ break
507
+ }
508
+ }
509
+ }
510
+ if (failedStepId !== null) {
511
+ for (const entry of runner) {
512
+ if (!entry || entry.type !== 'before' || entry.callId !== failedStepId) continue
513
+ failedStep = entry
514
+ if (!Array.isArray(entry.stack)) break
515
+ for (const frame of entry.stack) {
516
+ if (typeof frame.file !== 'string' || !/\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) continue
517
+ failureSpec = specPathOf(frame.file)
518
+ failureLine = Number(frame.line) || 0
519
+ break
520
+ }
521
+ break
522
+ }
523
+ }
524
+ const spec = failureSpec !== null ? failureSpec : lastSpec
525
+ const line = failureSpec !== null ? failureLine : lastSpecLine
526
+ // The page the failing step ran on. The library trace records the runner's step id on every
527
+ // action it performed for that step, and each action's frame snapshot carries its pageId — so
528
+ // the failing assertion's page is the step's own page, never the last page any context left
529
+ // behind (m160: the helper context's /editor won because its member came later in the zip).
530
+ let failedPageIds = []
531
+ if (failedStepId !== null) {
532
+ for (const action of actionsAll) {
533
+ if (action.stepId !== failedStepId || action.pageId === undefined) continue
534
+ if (failedPageIds.indexOf(action.pageId) === -1) failedPageIds.push(action.pageId)
535
+ }
536
+ // A step with no library action of its own (a plain \`page.waitForTimeout\`, a runner-only
537
+ // step) falls back to the actions that ran inside its own interval.
538
+ if (failedPageIds.length === 0 && failedStep !== null && typeof failedStep.startTime === 'number') {
539
+ for (const action of actionsAll) {
540
+ if (action.pageId === undefined) continue
541
+ if (action.start < failedStep.startTime - 1) continue
542
+ if (failedEnd !== null && action.start > failedEnd + 1) continue
543
+ if (failedPageIds.indexOf(action.pageId) === -1) failedPageIds.push(action.pageId)
544
+ }
545
+ }
546
+ }
547
+ let failedPage = failedPageIds.length === 1 ? failedPageIds[0] : null
548
+ // A member with no action, request or frame is not a context the test used: Playwright writes
549
+ // a trace entry for a context it opened and closed without a page, and counting that as a
550
+ // second context would make every ordinary trace unattributable.
551
+ const usedContexts = contexts.filter(function (ctx) {
552
+ return ctx.actions.length > 0 || ctx.requests.length > 0 || ctx.frames.length > 0
553
+ })
554
+ // One context, one page: nothing to attribute, and no second context to merge.
555
+ if (failedPage === null && usedContexts.length === 1) {
556
+ const pages = []
557
+ for (const frame of framesAll) {
558
+ if (frame.pageId !== undefined && pages.indexOf(frame.pageId) === -1) pages.push(frame.pageId)
559
+ }
560
+ if (pages.length === 1) failedPage = pages[0]
561
+ }
562
+ let failedContext = null
563
+ if (failedPage !== null) {
564
+ for (const frame of framesAll) {
565
+ if (frame.pageId !== failedPage) continue
566
+ failedContext = frame.context
567
+ break
568
+ }
569
+ if (failedContext === null) {
570
+ for (const action of actionsAll) {
571
+ if (action.pageId !== failedPage) continue
572
+ failedContext = action.context
573
+ break
574
+ }
575
+ }
576
+ if (failedContext === null) {
577
+ for (const r of requestsAll) {
578
+ if (r.pageId !== failedPage) continue
579
+ failedContext = r.context
580
+ break
581
+ }
582
+ }
583
+ }
584
+ // Two used contexts and no identified page: the note makes no causal claim rather than
585
+ // merging them (m160's rule). With one used context everything in it belongs to the test's page.
586
+ const unattributed = failedPage === null && usedContexts.length > 1
587
+ const actions = failedPage === null ? actionsAll : actionsAll.filter(function (a) { return a.pageId === failedPage })
588
+ const frames = failedPage === null ? (usedContexts.length === 1 ? framesAll : []) : framesAll.filter(function (f) { return f.pageId === failedPage })
589
+ let requests = failedPage === null ? requestsAll : requestsAll.filter(function (r) { return r.pageId === failedPage })
590
+ // A trace whose HAR entries carry no page ref: the failing context's own requests are still
591
+ // its page's, because a context in a test with one page has nowhere else to send them.
592
+ if (failedPage !== null && requests.length === 0 && failedContext !== null)
593
+ requests = requestsAll.filter(function (r) { return r.context === failedContext })
360
594
  // A \`page.goto\` that has no end time: the page never finished loading. When that is the
361
595
  // failure, a document subresource with no response is the reason — the navigation waits for
362
596
  // its stylesheets and scripts — and it is evidence, not the aborted noise a finished
@@ -446,7 +680,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
446
680
  // then rendering signed-out) leads instead of the Home page's own fetch.
447
681
  const USER_ACTION = /\\.(?:click|dblclick|tap|fill|press|pressSequentially|selectOption|check|uncheck|setInputFiles|goto|type)$/
448
682
  const userActions = actions.filter(function (a) { return USER_ACTION.test(a.api) })
449
- const lastAction = userActions.length > 0 ? userActions[userActions.length - 1] : null
683
+ // With two contexts and no identified page there is no causal claim to make: the last action
684
+ // of the concatenation could belong to the helper's page (m160).
685
+ const lastAction = unattributed || userActions.length === 0 ? null : userActions[userActions.length - 1]
450
686
  // A request the action caused started after it (a millisecond of clock slack) and before the
451
687
  // assertion — which is everything later in the trace. A request that started before it is the
452
688
  // page's own load, not the action's; the page shell's files are never the step's, except a
@@ -496,6 +732,26 @@ for (const one of found.slice(0, MAX_TRACES)) {
496
732
  if (sent) r.sent = sent
497
733
  if (got) r.got = got
498
734
  }
735
+ // The text the failing assertion waited for, and a response the SAME page received that carried
736
+ // it (m169). The assertion's own error names the text; the network log says whether it ever
737
+ // reached the browser. Only an answered fetch/xhr can say the API returned it — a 4xx body is an
738
+ // error message, not data. The most focused response wins (smallest body): an articles list also
739
+ // carries tag names in its \`tagList\`, while the tags response is the collection whose membership
740
+ // the assertion tests.
741
+ const waitedText = waitedTextOf(failedError)
742
+ let missingText = null
743
+ if (waitedText !== null && !unattributed) {
744
+ let best = null
745
+ for (const r of requests) {
746
+ if (r.type !== 'fetch' && r.type !== 'xhr') continue
747
+ if (r.status < 200 || r.status >= 400) continue
748
+ const got = gotText(buf, entries, r)
749
+ if (!got || got.indexOf(waitedText) === -1) continue
750
+ if (best === null || got.length < best.got.length) best = { request: r, got: got }
751
+ }
752
+ if (best !== null)
753
+ missingText = { text: waitedText, method: best.request.method, path: best.request.path }
754
+ }
499
755
  function wire(r) {
500
756
  return {
501
757
  method: r.method,
@@ -511,19 +767,13 @@ for (const one of found.slice(0, MAX_TRACES)) {
511
767
  ...(r.sentAuthorization ? { sentAuthorization: true } : {}),
512
768
  ...(r.authorization ? { authorization: r.authorization } : {}),
513
769
  ...(r.sentCookie ? { sentCookie: true } : {}),
770
+ ...(r.context ? { context: r.context } : {}),
771
+ ...(r.pageId ? { pageId: r.pageId } : {}),
514
772
  }
515
773
  }
516
- // The main frame's URL, timed: what the page showed when the action ran, and where it ended up.
517
- // The trace records these around every action, which is how the note can say what the page did
518
- // after the request — a client-side redirect never appears in the network log as a document.
519
- const frames = []
520
- for (const entry of trace) {
521
- if (!entry || entry.type !== 'frame-snapshot') continue
522
- const snap = entry.snapshot || {}
523
- if (snap.isMainFrame === false) continue
524
- if (typeof snap.frameUrl !== 'string' || typeof snap.timestamp !== 'number') continue
525
- frames.push({ url: pathOf(snap.frameUrl).slice(0, MAX_URL), at: snap.timestamp })
526
- }
774
+ // The failing page's own frame snapshots, timed: what the page showed when the action ran, and
775
+ // where it ended up. They are the snapshots of the assertion's page only — another context's
776
+ // editor page is not where this page went (m160) — and they are ordered by the trace's clock.
527
777
  let last = null
528
778
  if (lastAction !== null) {
529
779
  let pageBefore
@@ -547,8 +797,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
547
797
  }
548
798
  // The fallback lead stays for traces with no action clock. With an action, only a request it
549
799
  // caused is the lead; an action that sent none leaves the page's own load to the row's
550
- // "also answered" bucket, not to a lead that would hide it.
551
- if (lead === null && lastAction === null) {
800
+ // "also answered" bucket, not to a lead that would hide it. With two contexts and no identified
801
+ // page there is no causal lead at all (m160).
802
+ if (!unattributed && lead === null && lastAction === null) {
552
803
  let leadIndex = -1
553
804
  for (let i = requests.length - 1; i >= 0; i--) {
554
805
  const r = requests[i]
@@ -608,26 +859,51 @@ for (const one of found.slice(0, MAX_TRACES)) {
608
859
  const consoleErrors = []
609
860
  const consoleSignals = []
610
861
  const consoleWarnings = []
611
- let spec = null
612
- let line = 0
613
- for (const entry of trace) {
614
- if (!entry) continue
615
- if (entry.type === 'console' && typeof entry.text === 'string') {
616
- if (entry.messageType === 'error') consoleErrors.push(entry.text)
617
- else if (consoleSignal(entry.text)) consoleSignals.push(entry.text)
618
- else if (entry.messageType === 'warning' || entry.messageType === 'warn')
619
- consoleWarnings.push(entry.text)
620
- } else if (entry.type === 'event' && entry.method === 'pageError') {
621
- const message = messageOf(entry.params)
622
- if (message) consoleErrors.push(message)
623
- } else if (entry.type === 'before' && Array.isArray(entry.stack) && entry.stack.length) {
624
- const frame = entry.stack[0] || {}
625
- if (typeof frame.file === 'string' && /\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) {
626
- spec = specPathOf(frame.file)
627
- line = Number(frame.line) || 0
862
+ // The console lines of the page the assertion ran on; with no identified page, of every context.
863
+ const consoleTraces =
864
+ failedContext === null
865
+ ? contexts.map(function (ctx) { return ctx.trace })
866
+ : contexts.filter(function (ctx) { return ctx.id === failedContext }).map(function (ctx) { return ctx.trace })
867
+ for (const traceEntries of consoleTraces) {
868
+ for (const entry of traceEntries) {
869
+ if (!entry) continue
870
+ if (entry.type === 'console' && typeof entry.text === 'string') {
871
+ if (entry.messageType === 'error') consoleErrors.push(entry.text)
872
+ else if (consoleSignal(entry.text)) consoleSignals.push(entry.text)
873
+ else if (entry.messageType === 'warning' || entry.messageType === 'warn')
874
+ consoleWarnings.push(entry.text)
875
+ } else if (entry.type === 'event' && entry.method === 'pageError') {
876
+ const message = messageOf(entry.params)
877
+ if (message) consoleErrors.push(message)
628
878
  }
629
879
  }
630
880
  }
881
+ // Evidence from the OTHER browser contexts the test opened — kept apart from the failing page's
882
+ // requests and labelled as set-up work, never as the failing action's effect (m160).
883
+ const otherContexts = []
884
+ if (failedContext !== null) {
885
+ for (const ctx of usedContexts) {
886
+ if (ctx.id === failedContext) continue
887
+ const picked = []
888
+ for (const r of ctx.requests) {
889
+ if (shellRequest(r)) continue
890
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
891
+ const got = gotText(buf, entries, r)
892
+ if (sent) r.sent = sent
893
+ if (got) r.got = got
894
+ picked.push(r)
895
+ }
896
+ if (picked.length === 0 && ctx.actions.length === 0) continue
897
+ // Failures first, then by the trace's clock: a 401 from the helper context is worth naming
898
+ // even when its later answered calls would fill the bound.
899
+ picked.sort(function (a, b) {
900
+ const left = failedRequest(a) ? 0 : 1
901
+ const right = failedRequest(b) ? 0 : 1
902
+ return left === right ? (a.startedAt || 0) - (b.startedAt || 0) : left - right
903
+ })
904
+ otherContexts.push({ context: ctx.id, requests: picked.slice(0, MAX_NOTE).map(wire) })
905
+ }
906
+ }
631
907
  // Chromium reports every failed request as a console error too; where the page logged something
632
908
  // of its own about it, that is the line worth reading. Errors lead, then the lines that name a
633
909
  // session decision or come from a molecule package (m148), then any other warning — the browser's
@@ -647,8 +923,11 @@ for (const one of found.slice(0, MAX_TRACES)) {
647
923
  // tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
648
924
  // plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
649
925
  requests: chosen.slice(0, MAX_REQUESTS),
926
+ ...(missingText === null ? {} : { missingText: missingText }),
650
927
  console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
651
928
  actions: actions.slice(-MAX_ACTIONS),
929
+ ...(unattributed ? { unattributed: true } : {}),
930
+ ...(otherContexts.length > 0 ? { otherContexts: otherContexts } : {}),
652
931
  }) + '\\n')
653
932
  }
654
933
  `;
@@ -677,8 +956,28 @@ function parseTraceRequest(value) {
677
956
  ? { authorization: r.authorization }
678
957
  : {}),
679
958
  ...(r.sentCookie === true ? { sentCookie: true } : {}),
959
+ ...(typeof r.context === 'string' && r.context ? { context: r.context } : {}),
960
+ ...(typeof r.pageId === 'string' && r.pageId ? { pageId: r.pageId } : {}),
680
961
  };
681
962
  }
963
+ /**
964
+ * One `missingText` out of a `TRACE` line's json, or null when the value is not one.
965
+ *
966
+ * @param value - The line's `missingText`.
967
+ * @returns The awaited text and the response that carried it, or null.
968
+ */
969
+ function parseMissingText(value) {
970
+ if (typeof value !== 'object' || value === null)
971
+ return null;
972
+ const raw = value;
973
+ if (typeof raw.text !== 'string' || raw.text === '')
974
+ return null;
975
+ if (typeof raw.method !== 'string' || raw.method === '')
976
+ return null;
977
+ if (typeof raw.path !== 'string' || raw.path === '')
978
+ return null;
979
+ return { text: raw.text, method: raw.method, path: raw.path };
980
+ }
682
981
  /**
683
982
  * One `lastAction` out of a `TRACE` line's json, or null when the value is not one.
684
983
  *
@@ -724,6 +1023,18 @@ export function parseTraceArtifacts(stdout) {
724
1023
  const actions = Array.isArray(raw.actions) ? raw.actions : [];
725
1024
  const lead = parseTraceRequest(raw.lead);
726
1025
  const lastAction = parseLastAction(raw.lastAction);
1026
+ const missingText = parseMissingText(raw.missingText);
1027
+ const otherContexts = Array.isArray(raw.otherContexts)
1028
+ ? raw.otherContexts
1029
+ .filter((c) => typeof c === 'object' && c !== null)
1030
+ .filter((c) => typeof c.context === 'string')
1031
+ .map((c) => ({
1032
+ context: String(c.context),
1033
+ requests: (Array.isArray(c.requests) ? c.requests : [])
1034
+ .map(parseTraceRequest)
1035
+ .filter((request) => request !== null),
1036
+ }))
1037
+ : [];
727
1038
  out.push({
728
1039
  spec: typeof raw.spec === 'string' ? raw.spec : null,
729
1040
  line: typeof raw.line === 'number' ? raw.line : 0,
@@ -740,7 +1051,13 @@ export function parseTraceArtifacts(stdout) {
740
1051
  api: String(a.api),
741
1052
  start: Number(a.start),
742
1053
  end: a.end === null || a.end === undefined ? null : Number(a.end),
1054
+ ...(typeof a.context === 'string' ? { context: a.context } : {}),
1055
+ ...(typeof a.pageId === 'string' ? { pageId: a.pageId } : {}),
1056
+ ...(typeof a.frameId === 'string' ? { frameId: a.frameId } : {}),
743
1057
  })),
1058
+ ...(otherContexts.length === 0 ? {} : { otherContexts }),
1059
+ ...(raw.unattributed === true ? { unattributed: true } : {}),
1060
+ ...(missingText === null ? {} : { missingText }),
744
1061
  });
745
1062
  }
746
1063
  catch (_error) {
@@ -1062,6 +1379,36 @@ function runFailureOf(item, failures) {
1062
1379
  }
1063
1380
  return undefined;
1064
1381
  }
1382
+ /**
1383
+ * Where the row names this failing test.
1384
+ *
1385
+ * The trace names the line of the step it recorded — the failing assertion when the runner wrote
1386
+ * its error on that step, and the suite's `afterEach` (`context.close()`) when it did not. The
1387
+ * runner's own summary names the test's DECLARATION line, and that is the location the result
1388
+ * quotes and the line a re-run command takes, so it wins whenever the run reported one for this
1389
+ * file: matched by the trace's own line inside the test body ({@link runFailureOf}), and otherwise
1390
+ * by the file alone when that file has exactly ONE reported failure — an `afterEach` line sits
1391
+ * BEFORE the declaration, so the line window cannot match it (xr-molecule-flash-m163's row said
1392
+ * `navigation.spec.ts:12`, the suite's `afterEach`, for the test declared at `:200`).
1393
+ *
1394
+ * @param item - One traced failure.
1395
+ * @param failures - The runner's report, one entry per failing test.
1396
+ * @returns The `file:line` the row names.
1397
+ */
1398
+ function failureWhereOf(item, failures) {
1399
+ const spec = item.spec;
1400
+ if (spec === null)
1401
+ return whereOf(item);
1402
+ let reported = runFailureOf(item, failures);
1403
+ if (reported === undefined) {
1404
+ const sameFile = failures.filter((failure) => sameLocationFile(spec, failure.spec));
1405
+ if (sameFile.length === 1)
1406
+ reported = sameFile[0];
1407
+ }
1408
+ if (reported === undefined)
1409
+ return whereOf(item);
1410
+ return reported.line > 0 ? `${reported.spec}:${reported.line}` : reported.spec;
1411
+ }
1065
1412
  /**
1066
1413
  * Whether a pending request's interval covers a navigation's wait: it started while the
1067
1414
  * navigation was loading (at or after the wait began) and it has no recorded response, so it was
@@ -1216,6 +1563,68 @@ function sameOriginPath(request) {
1216
1563
  return null;
1217
1564
  return raw.replace(/\/+$/, '');
1218
1565
  }
1566
+ /**
1567
+ * The path segments a request ASKS FOR a credential through — a login, a registration, a token
1568
+ * endpoint — as opposed to a protected resource that presents one.
1569
+ */
1570
+ const CREDENTIAL_EXCHANGE_PATH = /(?:^|\/)(?:login|log-in|signin|sign-in|register|signup|sign-up|token)(?:\/|$)/i;
1571
+ /** The field a credential payload carries: a password, a grant, or a refresh token. */
1572
+ const CREDENTIAL_FIELD = /"(?:password|credentials|grant_type|refresh_token)"\s*:/i;
1573
+ /** The field that pairs with a password in a login/registration body. */
1574
+ const CREDENTIAL_IDENTITY = /"(?:email|username)"\s*:/i;
1575
+ /**
1576
+ * Whether a request's recorded body is a credential payload — the shape a login, a registration
1577
+ * or a token exchange sends, never a protected write (an article, a comment, a profile).
1578
+ *
1579
+ * @param sent - What the request sent, as the trace recorded it (bounded).
1580
+ * @returns True for a body carrying credentials.
1581
+ */
1582
+ function credentialExchangeBody(sent) {
1583
+ if (!sent)
1584
+ return false;
1585
+ const text = sent.slice(0, MAX_TRACED_BODY_CHARS);
1586
+ // A password alone is a profile write as often as a login; the exchange is a password beside
1587
+ // an identity, or a token grant.
1588
+ if (!CREDENTIAL_FIELD.test(text))
1589
+ return false;
1590
+ return (CREDENTIAL_IDENTITY.test(text) || /"(?:credentials|grant_type|refresh_token)"\s*:/i.test(text));
1591
+ }
1592
+ /**
1593
+ * Whether a refused request is a CREDENTIAL EXCHANGE: the app was asked to mint a token (a login,
1594
+ * a registration, a token endpoint), so it never needed one attached.
1595
+ *
1596
+ * Three readings, weakest last: the app's own auth config names the endpoint (a project may mount
1597
+ * `/session` or `/auth/signin`), the path's own last segments name it (`/users/login`), or the
1598
+ * body is a credential payload (`{"user":{"email":…,"password":…}}` sends a registration).
1599
+ *
1600
+ * xr-molecule-flash-m165 (+10:22): `POST /api/users/login → 401
1601
+ * {"errors":{"credentials":["invalid"]}}` was answered with the missing-token sentence — "the
1602
+ * client did not attach the token … The API handler is not the problem" — for a request whose
1603
+ * whole purpose is to obtain a token. The model read helpers and configs for 40 minutes instead of
1604
+ * the real cause: the suite's setup had seeded `https://api.realworld.show`, not this app.
1605
+ *
1606
+ * @param request - The refused request (401/403).
1607
+ * @param authEndpoints - The endpoints the app's auth config names (`/users/login`).
1608
+ * @returns True when the request is a credential exchange.
1609
+ */
1610
+ function isCredentialExchange(request, authEndpoints) {
1611
+ if (request.method !== 'POST')
1612
+ return false;
1613
+ const path = sameOriginPath(request);
1614
+ if (path === null)
1615
+ return false;
1616
+ // The app's own config names it: matched as a path suffix, because an auth config writes the
1617
+ // endpoint relative to its API base (`/users/login`) while the trace records the mounted path
1618
+ // (`/api/users/login`).
1619
+ if (authEndpoints.some((endpoint) => {
1620
+ const named = `/${String(endpoint).replace(/^\/+|\/+$/g, '')}`;
1621
+ return named.length > 1 && (path === named || path.endsWith(named));
1622
+ }))
1623
+ return true;
1624
+ if (CREDENTIAL_EXCHANGE_PATH.test(path))
1625
+ return true;
1626
+ return credentialExchangeBody(request.sent);
1627
+ }
1219
1628
  /**
1220
1629
  * The sentence a 401/403 on the app's own API gets: which half of the wire refused the request.
1221
1630
  *
@@ -1237,6 +1646,15 @@ function sameOriginPath(request) {
1237
1646
  * sent through. When the trace carries that line, the no-header sentence names the ordering fix
1238
1647
  * instead of leaving the run to re-derive it.
1239
1648
  *
1649
+ * xr-molecule-flash-m165 (+10:22): a `POST /api/users/login → 401
1650
+ * {"errors":{"credentials":["invalid"]}}` is a CREDENTIAL EXCHANGE — the request is how a token is
1651
+ * obtained, so an absent `Authorization` header is the request being what it is, not a client bug.
1652
+ * The note said "the client did not attach the token … The API handler is not the problem" and the
1653
+ * run went looking through the app instead of at the credentials or the setup's API origin. The
1654
+ * exchange branch names the credentials, the account's origin, and the response body; the
1655
+ * categorical handler sentence is gone from the no-header branch too, because a route that
1656
+ * requires a token where the app allows anonymous access is the handler's own mistake.
1657
+ *
1240
1658
  * The trace cannot name the file that made the call: a real HAR entry carries the request, response,
1241
1659
  * `_resourceType`, `_monotonicTime` and a frame ref, but no initiator stack. So the sentence names
1242
1660
  * the shared client and the auth bond instead of guessing a file.
@@ -1244,14 +1662,30 @@ function sameOriginPath(request) {
1244
1662
  * @param request - The refused request, a 401 or 403 on the app's own origin.
1245
1663
  * @param unbondedClient - Whether the traced page logged `no client is bonded`, which explains a
1246
1664
  * request that carried no `Authorization` header.
1665
+ * @param authEndpoints - The endpoints the app's auth config names, so a credential exchange is
1666
+ * recognised even under a path the segment list does not.
1247
1667
  * @returns The sentence the note appends.
1248
1668
  */
1249
- function authRefusalSentence(request, unbondedClient = false) {
1669
+ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []) {
1250
1670
  const label = requestLabel(request);
1251
1671
  // The header's scheme, when it parsed; presence alone is still the server's half of the wire.
1252
1672
  const carried = request.authorization !== undefined
1253
1673
  ? `with \`Authorization: ${request.authorization} …\``
1254
1674
  : 'with an `Authorization` header';
1675
+ // A credential exchange asks the app to MINT a token: no token is expected to be attached, so a
1676
+ // refusal here is about the credentials themselves — never the client's omission (m165).
1677
+ if (isCredentialExchange(request, authEndpoints)) {
1678
+ const got = request.got
1679
+ ? ` got ${bodyLine(redactSecrets(request.got), TRACE_NOTE_GOT_CHARS)}`
1680
+ : '';
1681
+ return (`The browser sent \`${label}\`${got} and the API answered ${request.status} — this is a ` +
1682
+ 'credential exchange (a login/registration/token endpoint), so the client is not expected ' +
1683
+ 'to attach a token: the credentials were rejected. Check that the account the test logs in ' +
1684
+ 'with was created on THIS app — the same database and origin this run drives; a user seeded ' +
1685
+ 'somewhere else is unknown here — and read the response body’s `errors` (a login answers 401 ' +
1686
+ '`{"errors":{"credentials":["invalid"]}}` for a wrong email/password or a user that does not ' +
1687
+ 'exist here).');
1688
+ }
1255
1689
  // The console line names the mechanism the no-header branch otherwise has to guess at:
1256
1690
  // `getClient()` ran before `setupHttpDefault()` and the token was mirrored into the
1257
1691
  // client that later setup replaced (xr-molecule-flash-m156).
@@ -1289,21 +1723,23 @@ function authRefusalSentence(request, unbondedClient = false) {
1289
1723
  ? 'The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
1290
1724
  'that is the credential the server did not accept. '
1291
1725
  : '') +
1292
- 'The API handler is not the problem.' +
1293
1726
  unbonded);
1294
1727
  }
1295
1728
  /**
1296
1729
  * The note's credential-refusal advice, one sentence per distinct shape — at most two.
1297
1730
  *
1298
- * The shapes are `(status, Authorization scheme, cookie present)`: m148's ten walks repeated one
1299
- * shape (`401`, no header) on every failing test, and repeating its sentence per row would bury
1300
- * the fact rather than state it.
1731
+ * The shapes are `(status, Authorization scheme, cookie present, credential exchange)`: m148's ten
1732
+ * walks repeated one shape (`401`, no header) on every failing test, and repeating its sentence per
1733
+ * row would bury the fact rather than state it. A login and a protected request that both carry no
1734
+ * header are DIFFERENT shapes — one is the credentials being checked, the other is a token that was
1735
+ * never attached (m165).
1301
1736
  *
1302
1737
  * @param items - The traces the note is about.
1738
+ * @param authEndpoints - The endpoints the app's auth config names.
1303
1739
  * @returns The sentences to append (each led by a space), or '' when no same-origin 401/403 was
1304
1740
  * traced.
1305
1741
  */
1306
- function authRefusalAdvice(items) {
1742
+ function authRefusalAdvice(items, authEndpoints = []) {
1307
1743
  const sentences = [];
1308
1744
  const seen = new Set();
1309
1745
  // A page that logged `no client is bonded` explains every request it sent without a
@@ -1320,11 +1756,12 @@ function authRefusalAdvice(items) {
1320
1756
  // ignores it and clears the session, and the page owes the navigation (m156).
1321
1757
  if (isLogoutRequest(request))
1322
1758
  continue;
1323
- const key = `${request.status} ${request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
1759
+ const exchange = isCredentialExchange(request, authEndpoints);
1760
+ const key = `${request.status} ${exchange ? 'exchange' : request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
1324
1761
  if (seen.has(key))
1325
1762
  continue;
1326
1763
  seen.add(key);
1327
- sentences.push(authRefusalSentence(request, unbondedClient));
1764
+ sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints));
1328
1765
  if (sentences.length === 2)
1329
1766
  return ` ${sentences.join(' ')}`;
1330
1767
  }
@@ -1390,6 +1827,117 @@ function tracedApiPrefixes(items) {
1390
1827
  }
1391
1828
  return [...prefixes].sort();
1392
1829
  }
1830
+ /**
1831
+ * Every API path prefix the traces support: the one the app's client config names and the ones the
1832
+ * traces' own same-origin requests show ({@link tracedApiPrefixes}).
1833
+ *
1834
+ * @param items - The traces the note is about.
1835
+ * @param apiPrefixes - Prefixes the app's client config names (`['/api']`), never a literal.
1836
+ * @returns The one-segment prefixes, without duplicates.
1837
+ */
1838
+ function knownApiPrefixes(items, apiPrefixes) {
1839
+ const prefixes = new Set([...apiPrefixes, ...tracedApiPrefixes(items)]
1840
+ .map((prefix) => `/${String(prefix).replace(/^\/+|\/+$/g, '')}`)
1841
+ .filter((prefix) => prefix !== '/'));
1842
+ return [...prefixes];
1843
+ }
1844
+ /** A URL written in full (`https://api.realworld.show/api/articles`). */
1845
+ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
1846
+ /**
1847
+ * The API requests a trace made to an origin other than the page's own — set-up traffic that ran
1848
+ * against someone else's server.
1849
+ *
1850
+ * The trace reader relativises every request whose origin is the page's (`pathOf`), so a request
1851
+ * recorded with an absolute URL went to ANOTHER origin by construction. It counts as set-up/API
1852
+ * traffic when its path carries a prefix the app's own API is known to use ({@link
1853
+ * knownApiPrefixes}) — a CDN font or a third-party script has no `/api` under it, and with no
1854
+ * known prefix nothing is claimed.
1855
+ *
1856
+ * xr-molecule-flash-m165 (+10:22): the suite's helper defaulted `API_BASE` to
1857
+ * `https://api.realworld.show/api` and its set-up seeded rows there, while the browser drove this
1858
+ * app — whose own `/api` answers a login for none of those users. The trace's absolute-URL rows
1859
+ * were the evidence, and the note said nothing about them.
1860
+ *
1861
+ * @param items - The traces the note is about.
1862
+ * @param apiPrefixes - Prefixes the app's client config names (`['/api']`).
1863
+ * @returns One entry per distinct (origin, prefix), in trace order.
1864
+ */
1865
+ function foreignOriginApiRequests(items, apiPrefixes) {
1866
+ const prefixes = knownApiPrefixes(items, apiPrefixes);
1867
+ if (prefixes.length === 0)
1868
+ return [];
1869
+ const out = [];
1870
+ const seen = new Set();
1871
+ for (const item of items) {
1872
+ for (const request of item.requests) {
1873
+ const raw = String(request.path ?? '');
1874
+ if (!ABSOLUTE_URL.test(raw))
1875
+ continue;
1876
+ let parsed;
1877
+ try {
1878
+ parsed = new URL(raw);
1879
+ }
1880
+ catch (_error) {
1881
+ // Not a URL this can parse: it is not evidence of another origin.
1882
+ continue;
1883
+ }
1884
+ const prefix = prefixes.find((one) => parsed.pathname === one || parsed.pathname.startsWith(`${one}/`));
1885
+ if (prefix === undefined)
1886
+ continue;
1887
+ const key = `${parsed.origin}${prefix}`;
1888
+ if (seen.has(key))
1889
+ continue;
1890
+ seen.add(key);
1891
+ out.push({ origin: parsed.origin, prefix, request });
1892
+ }
1893
+ }
1894
+ return out;
1895
+ }
1896
+ /**
1897
+ * The origin a run's target names, when it names one: a URL (`http://localhost:4173`), or the
1898
+ * deployed host the note prints (`loopcheck-….apps.mlcl.dev`). Null for the note's descriptive
1899
+ * targets ("the deployed app", "the local app").
1900
+ *
1901
+ * @param target - Where the run went, as {@link failureTraceNote} names it.
1902
+ * @returns The origin, or null.
1903
+ */
1904
+ function appOriginOfTarget(target) {
1905
+ const text = target.trim();
1906
+ try {
1907
+ if (ABSOLUTE_URL.test(text))
1908
+ return new URL(text).origin;
1909
+ if (/^[a-z0-9.-]+(?::\d+)?$/i.test(text))
1910
+ return new URL(`https://${text}`).origin;
1911
+ }
1912
+ catch (_error) {
1913
+ // A target that is not a URL names no origin.
1914
+ }
1915
+ return null;
1916
+ }
1917
+ /**
1918
+ * The sentence naming the foreign origin a run's set-up traffic went to, and the setting that
1919
+ * points it at this app instead.
1920
+ *
1921
+ * It leads the note because it is the cheapest possible wrong result: every row the suite creates
1922
+ * lands in a database this app never reads, so the failure says nothing about the app's code.
1923
+ * xr-molecule-flash-m165 spent the walk after its failed login reading helpers and configs for a
1924
+ * missing token, when the command's `BASE_URL`/`API_BASE` disagreement was the cause.
1925
+ *
1926
+ * @param found - The foreign-origin request the trace holds.
1927
+ * @param target - Where the run went, for the app's own origin when the note knows it.
1928
+ * @returns The sentence, led by nothing and closed by a blank line.
1929
+ */
1930
+ function foreignOriginSentence(found, target) {
1931
+ const appOrigin = appOriginOfTarget(target);
1932
+ const where = appOrigin ?? 'this app’s origin';
1933
+ const apiBase = `${appOrigin ?? '<this app’s origin>'}${found.prefix}`;
1934
+ return (`The suite’s setup ran against ${found.origin}, not this app ` +
1935
+ `(\`${requestLabel(found.request)}\`) — its set-up/API traffic went to another origin while ` +
1936
+ `this app serves its own API at \`${found.prefix}\` on ${where}. Set ` +
1937
+ `\`API_BASE=${apiBase}\` on the run — the supplied suite reads it (verify the variable name ` +
1938
+ `in the suite’s own helpers before writing it) — so the rows it creates are in the database ` +
1939
+ `this app reads.\n\n`);
1940
+ }
1393
1941
  /**
1394
1942
  * The failed 404s whose path carries none of the known API prefixes.
1395
1943
  *
@@ -1403,12 +1951,9 @@ function tracedApiPrefixes(items) {
1403
1951
  * @returns One entry per distinct failed 404, in trace order.
1404
1952
  */
1405
1953
  function missingApiPrefixRequests(items, apiPrefixes) {
1406
- const prefixes = new Set([...apiPrefixes, ...tracedApiPrefixes(items)]
1407
- .map((prefix) => `/${String(prefix).replace(/^\/+|\/+$/g, '')}`)
1408
- .filter((prefix) => prefix !== '/'));
1409
- if (prefixes.size === 0)
1954
+ const known = knownApiPrefixes(items, apiPrefixes);
1955
+ if (known.length === 0)
1410
1956
  return [];
1411
- const known = [...prefixes];
1412
1957
  const out = [];
1413
1958
  const seen = new Set();
1414
1959
  for (const item of items) {
@@ -1442,6 +1987,22 @@ function missingApiPrefixSentence(request) {
1442
1987
  `without the \`${request.prefix}\` prefix — the HTTP client's base URL is not set: is ` +
1443
1988
  '`setupProviders()` (which runs the HTTP bond setup) called in `main.tsx`?');
1444
1989
  }
1990
+ /**
1991
+ * The sentence for a failing assertion whose awaited text a same-page API response carried.
1992
+ *
1993
+ * It names the response and the fact the trace proves — the value reached the browser and the page
1994
+ * did not render it — and stops there. Which cut drops it (a slice, a filter, a limit) is in the
1995
+ * component that maps the response, and the note cannot see that component; naming the mapping
1996
+ * step is the whole instruction (xr-molecule-flash-m169).
1997
+ *
1998
+ * @param missing - The awaited text and the response that carried it.
1999
+ * @returns The sentence the note leads with.
2000
+ */
2001
+ function missingTextSentence(missing) {
2002
+ return (`The API returned "${missing.text}" (in ${missing.method} ${missing.path}), but the page did ` +
2003
+ `not render it — the page drops it before rendering (a slice/filter/limit in the component ` +
2004
+ `that renders that list); find where that response is mapped.`);
2005
+ }
1445
2006
  /**
1446
2007
  * The note a failed suite run's result carries: what each failing test's page actually sent, and
1447
2008
  * what came back.
@@ -1473,6 +2034,14 @@ function missingApiPrefixSentence(request) {
1473
2034
  * (answered 201, the page still rendering signed-out) never appeared. With no action clock, the
1474
2035
  * row falls back to {@link TraceArtifact.lead}: the page's last answered fetch/xhr.
1475
2036
  *
2037
+ * The action, the URL and the requests are the FAILING ASSERTION'S PAGE's ({@link
2038
+ * TraceArtifact.lastAction}), never the archive's last context: a test that creates its data in a
2039
+ * second browser context writes that context's click and `/editor` snapshot into the same zip, and
2040
+ * reading the concatenation made them the failing page (xr-molecule-flash-m160). The other
2041
+ * contexts' requests are named separately as set-up work. When the trace holds more than one used
2042
+ * context and the assertion's page cannot be identified, the note lists the requests and makes NO
2043
+ * causal claim — no "the test's last action", no "the page stayed at …" — instead of merging them.
2044
+ *
1476
2045
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
1477
2046
  * needs a recorded response, a real failure or a console line ({@link traceRowEvidence}), rows are
1478
2047
  * deduplicated by the test's location, and they are ordered real failure → repeated from an
@@ -1511,6 +2080,13 @@ function missingApiPrefixSentence(request) {
1511
2080
  * xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
1512
2081
  * `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
1513
2082
  *
2083
+ * A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
2084
+ * path or the body) is never that shape: the request's purpose is to obtain a token, so an absent
2085
+ * header is not a client omission ({@link isCredentialExchange}). xr-molecule-flash-m165's login
2086
+ * 401 was answered as one for 40 minutes; the trace also showed the suite's SET-UP traffic going
2087
+ * to `https://api.realworld.show/api` instead of this app, which is what the note leads with
2088
+ * ({@link foreignOriginApiRequests}).
2089
+ *
1514
2090
  * @param items - The traces {@link parseTraceArtifacts} read.
1515
2091
  * @param target - Where the run went — the deployed host, as the note names it.
1516
2092
  * @param earlierFailures - The failing `file:line`s earlier walks of this suite left, so a repeat
@@ -1527,6 +2103,7 @@ function missingApiPrefixSentence(request) {
1527
2103
  * error.
1528
2104
  */
1529
2105
  export function failureTraceNote(items, target, earlierFailures = [], apiPrefixes = [], report = {}) {
2106
+ const failures = report.failures ?? [];
1530
2107
  const candidates = items.filter((item) => traceRowEvidence(item, report));
1531
2108
  if (candidates.length === 0)
1532
2109
  return null;
@@ -1539,7 +2116,7 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1539
2116
  const seen = new Set();
1540
2117
  for (const { item, index } of ranked) {
1541
2118
  // A trace that names no spec cannot be placed; it is never collapsed onto another one.
1542
- const where = item.spec === null ? `#${index}` : whereOf(item);
2119
+ const where = item.spec === null ? `#${index}` : failureWhereOf(item, failures);
1543
2120
  if (seen.has(where))
1544
2121
  continue;
1545
2122
  seen.add(where);
@@ -1577,7 +2154,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1577
2154
  // Every recorded failure is the logout endpoint answering 4xx: the row's own sentence explains
1578
2155
  // it, and neither the handler lead nor the credential reading applies (m156 +24:02).
1579
2156
  const onlyLogoutFailures = failedRequests.length > 0 && failedRequests.every((request) => isLogoutRequest(request));
1580
- const authAdvice = authRefusalAdvice(traced);
2157
+ const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? []);
2158
+ // Set-up traffic the trace shows going to an origin other than the app's (m165): the rows it
2159
+ // creates are not in this app's database, so the note says it before any app-side reading.
2160
+ const foreignOrigins = foreignOriginApiRequests(traced, apiPrefixes);
2161
+ const [firstForeign] = foreignOrigins;
2162
+ const setupOriginLead = firstForeign === undefined ? '' : foreignOriginSentence(firstForeign, target);
1581
2163
  // The stalled-navigation reading, per traced test: non-null only when the RUNNER says this
1582
2164
  // test's failing operation is a navigation and a document subresource still pending at the end
1583
2165
  // started inside that wait ({@link stalledNavigation}).
@@ -1593,6 +2175,9 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1593
2175
  ];
1594
2176
  const anyStalled = stalls.some((stall) => stall !== null);
1595
2177
  const rows = [];
2178
+ // Rows whose failing action sent nothing and left the page on a new route: the page is still
2179
+ // showing the previous route's data. Counted so the note can say it once for the whole run.
2180
+ let staleRouteRows = 0;
1596
2181
  for (const [index, item] of traced.entries()) {
1597
2182
  // The test's LAST action before the failing assertion leads the row (m148): the requests it
1598
2183
  // caused and what came back explain a page that never showed the signed-in state, while the
@@ -1630,7 +2215,26 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1630
2215
  const parts = [];
1631
2216
  if (step !== undefined && !stalledNav) {
1632
2217
  const head = `the test's last action was ${step.api} at ${actionClock(item, step.start)}`;
1633
- if (stepRequests.length === 0) {
2218
+ const pageBefore = step.pageBefore;
2219
+ const pageAfter = step.pageAfter;
2220
+ // The action asked the API for nothing and the page ended up on another route: the page is
2221
+ // still showing the data it fetched for the route before, which is why the new route's list
2222
+ // is wrong (xr-molecule-flash-m163 +10:00 — the note printed "it sent no request" beside
2223
+ // "the page then navigated to …", then pointed at a response body that never existed, and
2224
+ // the run spent six minutes on markup and CSS).
2225
+ const staleRoute = stepRequests.length === 0 &&
2226
+ pageBefore !== undefined &&
2227
+ pageAfter !== undefined &&
2228
+ pageBefore !== pageAfter;
2229
+ if (staleRoute) {
2230
+ staleRouteRows += 1;
2231
+ parts.push(`${head}, and it sent no request — the page moved from ${pageBefore} to ${pageAfter}. ` +
2232
+ `The page changed route but fetched nothing new, so it is still showing the previous ` +
2233
+ `route's data: check how the page reads the route (a param the route does not define ` +
2234
+ `is always undefined; a missing effect dependency on the route means the fetch never ` +
2235
+ `re-runs), not the markup or the CSS`);
2236
+ }
2237
+ else if (stepRequests.length === 0) {
1634
2238
  parts.push(`${head}, and it sent no request`);
1635
2239
  }
1636
2240
  else {
@@ -1642,10 +2246,10 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1642
2246
  }
1643
2247
  // What the page did next: a navigation shows in the main frame's URL even when the app moved
1644
2248
  // client-side and the network log recorded no document request.
1645
- if (step.pageAfter !== undefined) {
1646
- parts.push(step.pageBefore !== undefined && step.pageBefore !== step.pageAfter
1647
- ? `the page then navigated to ${step.pageAfter}`
1648
- : `the page stayed at ${step.pageAfter}`);
2249
+ if (!staleRoute && pageAfter !== undefined) {
2250
+ parts.push(pageBefore !== undefined && pageBefore !== pageAfter
2251
+ ? `the page then navigated to ${pageAfter}`
2252
+ : `the page stayed at ${pageAfter}`);
1649
2253
  }
1650
2254
  // A 4xx on the logout endpoint with the page unmoved is not a backend failure: the auth
1651
2255
  // client swallows the call and clears the session, and the URL can only move if the page
@@ -1702,14 +2306,29 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1702
2306
  .slice(0, 2)
1703
2307
  .map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 160))
1704
2308
  .join(' | ')}`);
1705
- rows.push(` ${index + 1}. ${whereOf(item)} — ${parts.join(' — ')}`);
2309
+ // A request from another browser context the test opened is not the failing action's effect:
2310
+ // it is set-up work on a different page, and it is named as such rather than merged into the
2311
+ // row (xr-molecule-flash-m160: the helper context's POST /api/articles was printed as the
2312
+ // click's effect while the assertion had failed on the profile page).
2313
+ const others = (item.otherContexts ?? []).filter((context) => context.requests.length > 0);
2314
+ for (const [otherIndex, context] of others.slice(0, 2).entries()) {
2315
+ const label = otherIndex === 0
2316
+ ? 'a second browser context, opened by the test for set-up and not the page the assertion ran on'
2317
+ : 'another browser context the test opened';
2318
+ parts.push(`${label}: ${context.requests.slice(0, 2).map(requestLine).join('; ')}`);
2319
+ }
2320
+ rows.push(` ${index + 1}. ${failureWhereOf(item, failures)} — ${parts.join(' — ')}`);
1706
2321
  }
1707
2322
  // Distinct locations the note did not name: the repeats collapse, so the count is what is left
1708
2323
  // to look at, not how many zips Playwright wrote.
1709
- const distinct = new Set(candidates.map((item, index) => (item.spec === null ? `#${index}` : whereOf(item))));
2324
+ const distinct = new Set(candidates.map((item, index) => item.spec === null ? `#${index}` : failureWhereOf(item, failures)));
1710
2325
  const more = distinct.size > traced.length
1711
2326
  ? `\n … and ${distinct.size - traced.length} more traced failures`
1712
2327
  : '';
2328
+ // Every row came from a trace with more than one browser context and no page to tie the
2329
+ // assertion to: the note may list the requests, and may not say what action sent them or where
2330
+ // "the page" went (m160).
2331
+ const allUnattributed = traced.every((item) => item.unattributed === true);
1713
2332
  // A run whose pages never loaded has one cause, and it is not in the app: every traced failure is
1714
2333
  // a `page.goto` that timed out on a document the browser could not load. The transport error is
1715
2334
  // the first line the executor needs — m118 (+37:09) read 14 goto timeouts as app failures while
@@ -1752,12 +2371,50 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1752
2371
  `and if the stall repeats measure that exact fetch ` +
1753
2372
  `(\`curl -o /dev/null -w '%{time_starttransfer} %{time_total}' <url>\`) before ` +
1754
2373
  `treating it as the app's.\n\n`);
1755
- return (transportLead +
2374
+ // Every row is a route change that fetched nothing: the cause and the next read are the same
2375
+ // for the whole run, and the generic "read the response body" paragraph would point at a
2376
+ // response that never existed (xr-molecule-flash-m163 +10:00).
2377
+ const everyStaleRoute = staleRouteRows > 0 &&
2378
+ staleRouteRows === traced.length &&
2379
+ !anyFailed &&
2380
+ !anyStalled &&
2381
+ !allUnattributed &&
2382
+ transport === null;
2383
+ const staleRouteLead = everyStaleRoute
2384
+ ? `Every failure this run traced is the same shape: the failing action asked the API for ` +
2385
+ `nothing and the page changed route, so the page is still showing the data it fetched for ` +
2386
+ `the route before. Read how each page derives its query from the route — a param the route ` +
2387
+ `does not define is always undefined, and a missing effect dependency on the route means ` +
2388
+ `the fetch never re-runs — before touching the markup or the CSS.\n\n`
2389
+ : '';
2390
+ // An assertion whose awaited text a same-page API response carried: the fact the run needs
2391
+ // first, before any row, is that the value arrived and the page dropped it (m169's missing
2392
+ // "trending" came back from GET /api/tags while the sidebar rendered a cut list). Each distinct
2393
+ // response is named once; the note makes no claim about which cut did it.
2394
+ const missingTexts = [
2395
+ ...new Map(traced
2396
+ .filter((item) => item.missingText !== undefined)
2397
+ .map((item) => {
2398
+ const missing = item.missingText;
2399
+ return [`${missing.method} ${missing.path}\u0000${missing.text}`, missing];
2400
+ })).values(),
2401
+ ];
2402
+ const missingTextLead = missingTexts.length === 0
2403
+ ? ''
2404
+ : `${missingTexts.slice(0, 2).map(missingTextSentence).join(' ')}\n\n`;
2405
+ return (missingTextLead +
2406
+ transportLead +
2407
+ setupOriginLead +
1756
2408
  stalledLead +
2409
+ staleRouteLead +
1757
2410
  `The platform read the failing tests' own Playwright traces from the run against ${target} ` +
1758
- `(what each page sent and got — first what the test's last action sent and where the page went ` +
1759
- `next, then the requests that failed, the ones that got no response, and its last answered ` +
1760
- `ones):\n` +
2411
+ (allUnattributed
2412
+ ? `(what each page sent and got — the requests their contexts recorded, with no claim about ` +
2413
+ `which action caused them, then the ones that failed, the ones that got no response, and ` +
2414
+ `the last answered ones):\n`
2415
+ : `(what each page sent and got — first what the test's last action sent and where the page ` +
2416
+ `went next, then the requests that failed, the ones that got no response, and its last ` +
2417
+ `answered ones):\n`) +
1761
2418
  `${rows.join('\n')}${more}\n` +
1762
2419
  (anyFailed
1763
2420
  ? everyFailureLacksPrefix
@@ -1787,13 +2444,24 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1787
2444
  'not finish loading because its own subresource never answered. Do not read this as a ' +
1788
2445
  'page-state bug: re-run these tests against the same URL first, and change the app only ' +
1789
2446
  'if the same navigation times out again with no stalled asset in the trace.'
1790
- : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
1791
- "page, not the API. Start with what the test's last action sent (the first line of each " +
1792
- 'row) and what the page did next: a request that answered (2xx included) while the page ' +
1793
- 'still did not show the state the assertion waited on is the fact to read — its status ' +
1794
- 'and response body, not the selector. If the request is not the one the test meant — an ' +
1795
- 'unfollow sent where the test clicked follow — the state the action read was stale ' +
1796
- 'before it.') +
2447
+ : allUnattributed
2448
+ ? 'No listed request failed — none got a 4xx/5xx or a network error. The trace held more ' +
2449
+ 'than one browser context and nothing ties a page to the failing assertion, so no ' +
2450
+ 'action or URL is attributed here: read the requests above together with the runner’s ' +
2451
+ 'own failure text to tell which page the test asserted on, and read that page’s state, ' +
2452
+ 'not the selector.'
2453
+ : everyStaleRoute
2454
+ ? 'No listed request failed — none got a 4xx/5xx or a network error — and the failing ' +
2455
+ 'action sent no request at all, so there is no response to read. The page changed ' +
2456
+ 'route and fetched nothing, which is why it still shows the previous route’s data: ' +
2457
+ 'fix how the page reads the route, not the selector.'
2458
+ : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
2459
+ "page, not the API. Start with what the test's last action sent (the first line of each " +
2460
+ 'row) and what the page did next: a request that answered (2xx included) while the page ' +
2461
+ 'still did not show the state the assertion waited on is the fact to read — its status ' +
2462
+ 'and response body, not the selector. If the request is not the one the test meant — an ' +
2463
+ 'unfollow sent where the test clicked follow — the state the action read was stale ' +
2464
+ 'before it.') +
1797
2465
  prefixAdvice +
1798
2466
  (wrapped
1799
2467
  ? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +
@@ -1927,24 +2595,27 @@ async function readRunConfig(command, cwd, readFile) {
1927
2595
  return '';
1928
2596
  }
1929
2597
  /**
1930
- * The command a DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
2598
+ * The command a Playwright run needs to leave a readable trace behind, when the suite's config
1931
2599
  * would record none.
1932
2600
  *
1933
2601
  * The failing tests' requests are only readable if the run wrote them down (see the module doc),
1934
2602
  * and the config is where the decision about that lives — the platform does not edit the suite's
1935
- * config, so it passes the flag instead, and only where it can do no harm: on a run narrowed to a
1936
- * part of the suite or capped ({@link isDiagnosisRun}), never on the whole-suite proof run, and
1937
- * never over a `--trace` the executor wrote itself. A capped walk is exactly the run whose
1938
- * failures someone is about to work on; the proof run is graded as it is.
2603
+ * config, so it passes the flag instead, on every Playwright run that starts tests and does not
2604
+ * already decide its own `--trace`. That includes the whole-suite proof run, which an earlier
2605
+ * version of this function skipped on the reasoning that its flags are graded: the flag changes
2606
+ * what is RETAINED, not what passes. `retain-on-failure` keeps the trace only of a test that
2607
+ * fails — Playwright records it and deletes it when the test passes (playwright's
2608
+ * `_shouldAbandonTrace` unlinks the temporary trace files of a passing test) — so a green proof
2609
+ * run keeps nothing extra. A failing one keeps the network log its failures need.
2610
+ * xr-molecule-flash-m163 (+24:58) is why the proof run is no longer skipped: its one deployed
2611
+ * failure left only an `error-context.md`, the executor spent the next calls hunting a `trace.zip`
2612
+ * that could not exist, and then shipped a guessed fix.
1939
2613
  *
1940
2614
  * @param args - The command, the directory it starts in, and the backend's file read.
1941
2615
  * @returns The command to run and the sentence to report, or the command unchanged.
1942
2616
  */
1943
2617
  export async function diagnosisTraceCommand(args) {
1944
2618
  const unchanged = { command: args.command, change: null };
1945
- // The proof run is the one run this must never touch: its flags are graded.
1946
- if (!isDiagnosisRun(args.command))
1947
- return unchanged;
1948
2619
  if (namesTraceMode(args.command))
1949
2620
  return unchanged;
1950
2621
  // Only a Playwright run leaves a trace to read.
@@ -1968,7 +2639,8 @@ export async function diagnosisTraceCommand(args) {
1968
2639
  return {
1969
2640
  command: `${args.command.slice(0, at)} ${DIAGNOSIS_TRACE_FLAG}${args.command.slice(at)}`,
1970
2641
  change: `Added \`${DIAGNOSIS_TRACE_FLAG}\`: the suite's config records no trace for a failure` +
1971
- `${/trace:/.test(config) ? '' : ' (it sets none)'}, and this diagnosis run's failures are ` +
1972
- 'what the platform reads the network log from. The proof run keeps the suite’s own settings.',
2642
+ `${/trace:/.test(config) ? '' : ' (it sets none)'}, and a failing test's own network log is ` +
2643
+ 'what the platform reads the cause from. It keeps a trace only for a test that fails, so a ' +
2644
+ 'green run retains nothing.',
1973
2645
  };
1974
2646
  }