@molecule/api-ai-tools 1.9.14 → 1.9.16

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.
@@ -42,6 +42,13 @@ export const MAX_TRACED_CONSOLE = 4;
42
42
  export const MAX_TRACED_ACTIONS = 40;
43
43
  /** How much of a 4xx request's sent body (or its response body) the trace read keeps. */
44
44
  export const MAX_TRACED_BODY_CHARS = 300;
45
+ /**
46
+ * How much of a websocket's frames the trace read keeps. Longer than {@link MAX_TRACED_BODY_CHARS}
47
+ * because a frame read back to its own message still has the message, the module id and the
48
+ * location to carry — Vite's transform error alone is a sentence longer than the plain body cap,
49
+ * and the note prints its first 120–200 characters anyway (xr-molecule-flash-m157).
50
+ */
51
+ const MAX_TRACED_WEBSOCKET_CHARS = 600;
45
52
  /** How many tests the note in a run's result names. */
46
53
  export const MAX_TRACE_NOTE_TESTS = 5;
47
54
  /** How many requests the note names per test. */
@@ -77,6 +84,13 @@ const TRACE_NOTE_FAILURE_CHARS = 120;
77
84
  * test's trace holds the whole page load. A request with no response and NO error text was aborted
78
85
  * or still in flight when the test ended; it is kept too, but as what it is, not as a failure.
79
86
  *
87
+ * A trace.zip holds ONE trace member per browser context the test used (beside the runner's own
88
+ * `test.trace`), and the archive's member order is not the test's chronology. Each member is read
89
+ * as its own context: actions and requests keep their context id, requests and frame snapshots
90
+ * keep the pageId Playwright records, and the failing assertion's page — the page its own step id
91
+ * ran on — decides which actions, snapshots and requests become the test's row. What the test's
92
+ * other browser contexts recorded is reported apart, as `otherContexts` (xr-molecule-flash-m160).
93
+ *
80
94
  * A request answered 4xx by the app's OWN API also keeps what the page SENT and the first of what
81
95
  * came back: Playwright stores either inline (`text`) or in a resource entry (`_file`), and both
82
96
  * are read. That is where a wrapped body shows: xr-molecule-flash-m111's helper passed
@@ -97,6 +111,7 @@ const MAX_NOTE = ${MAX_TRACE_NOTE_REQUESTS}
97
111
  const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
98
112
  const MAX_ACTIONS = ${MAX_TRACED_ACTIONS}
99
113
  const MAX_BODY = ${MAX_TRACED_BODY_CHARS}
114
+ const MAX_WS = ${MAX_TRACED_WEBSOCKET_CHARS}
100
115
  const MAX_URL = 200
101
116
  // A console line that is not an error but still has to reach the note (m148): one a molecule
102
117
  // package wrote — it names the package, the \`[molecule/…]\` prefix, or the \`[scope]\` prefix
@@ -199,6 +214,51 @@ function bodyText(buf, entries, value, max) {
199
214
  return text.indexOf('\\u0000') === -1 ? text : ''
200
215
  }
201
216
 
217
+ // A websocket's content is a JSONL stream of frames (\`{type, time, opcode, data}\`), and the data
218
+ // is the page's own message. The envelope is ~35 characters on every frame, so cutting the raw
219
+ // stream lands inside it and never reaches the text that names a cause: xr-molecule-flash-m157's
220
+ // Vite HMR error frame was \`{"type":"receive","time":…,"opcode":1,"data":"{\\"type\\":\\"error\\",
221
+ // \\"err\\":{\\"message\\":\\"Cannot import non-asset file /styles.css which is inside /public …\` and
222
+ // every note stopped at \`{"type":"error","err":{"message":"Cannot…\`. Read the frames and print
223
+ // what they carry — a Vite error's message, id and loc, or the JSON a frame sends — so the cut
224
+ // lands in the message itself.
225
+ function websocketText(raw) {
226
+ const errors = []
227
+ const others = []
228
+ for (const line of String(raw).split('\\n')) {
229
+ if (!line.trim()) continue
230
+ let frame
231
+ try { frame = JSON.parse(line) } catch (e) { continue }
232
+ if (!frame || typeof frame.data !== 'string') continue
233
+ let data = frame.data
234
+ try { data = JSON.parse(frame.data) } catch (e) { /* a text frame: print the data as it is */ }
235
+ const err = data && typeof data === 'object' && data.type === 'error' ? data.err : null
236
+ if (err && typeof err.message === 'string') {
237
+ let text = 'vite error: ' + err.message
238
+ if (err.id !== undefined && err.id !== null && err.id !== '')
239
+ text += ' (id: ' + String(err.id) + ')'
240
+ const loc = err.loc
241
+ if (loc && typeof loc === 'object') {
242
+ const file = typeof loc.file === 'string' && loc.file ? loc.file : String(err.id || '')
243
+ text += ' (loc: ' + file +
244
+ (typeof loc.line === 'number' ? ':' + loc.line + ':' + (typeof loc.column === 'number' ? loc.column : 0) : '') +
245
+ ')'
246
+ } else if (typeof loc === 'string' && loc) text += ' (loc: ' + loc + ')'
247
+ errors.push(text)
248
+ } else if (typeof data === 'string') others.push(data)
249
+ else others.push(JSON.stringify(data))
250
+ }
251
+ return errors.concat(others).join(' ')
252
+ }
253
+
254
+ // What a request got back, as the note reads it: a websocket's frames are read as the messages
255
+ // they carry (m157), every other request's body as stored.
256
+ function gotText(buf, entries, r) {
257
+ if (r.type !== 'websocket') return bodyText(buf, entries, r.content, MAX_BODY)
258
+ const raw = bodyText(buf, entries, r.content, 8000)
259
+ return (websocketText(raw) || raw).slice(0, MAX_WS)
260
+ }
261
+
202
262
  let origin = ''
203
263
  function pathOf(url) {
204
264
  try {
@@ -223,26 +283,69 @@ const found = []
223
283
  walk(ROOT, 5, found)
224
284
  found.sort(function (a, b) { return b.mtime - a.mtime })
225
285
 
286
+ // A trace.zip holds ONE trace member per browser context the test used (1-trace.trace,
287
+ // 3-trace.trace, …) beside the test runner's own test.trace. The archive ORDER of those members
288
+ // is not the test's chronology: xr-molecule-flash-m160's helper context was written after the
289
+ // primary page's, so the concatenation's last user action was the helper's editor click and the
290
+ // last frame snapshot was /editor, while the assertion had failed on the primary page at
291
+ // /profile/:username. Each member is read as its own context, and everything below attributes a
292
+ // request, an action and a frame snapshot to the page and context that recorded it.
293
+ function traceContextOf(name) {
294
+ if (name === 'test.trace') return 'test'
295
+ if (name === 'trace.trace') return '0'
296
+ const m = /^(.+)-trace\\.trace$/.exec(name)
297
+ return m === null ? null : m[1]
298
+ }
299
+ function networkContextOf(name) {
300
+ if (name === 'trace.network') return '0'
301
+ const m = /^(.+)-trace\\.network$/.exec(name)
302
+ return m === null ? null : m[1]
303
+ }
304
+
226
305
  for (const one of found.slice(0, MAX_TRACES)) {
227
306
  let buf
228
307
  try { buf = fs.readFileSync(one.file) } catch (e) { continue }
229
308
  let entries
230
309
  try { entries = zipEntries(buf) } catch (e) { continue }
231
- const network = []
232
- const trace = []
310
+ const runner = []
311
+ const contexts = []
312
+ const contextById = {}
313
+ function contextFor(id) {
314
+ let ctx = contextById[id]
315
+ if (!ctx) {
316
+ ctx = { id: id, trace: [], network: [], requests: [], actions: [], frames: [] }
317
+ contextById[id] = ctx
318
+ contexts.push(ctx)
319
+ }
320
+ return ctx
321
+ }
233
322
  for (const entry of entries) {
234
- if (/\\.network$/.test(entry.name)) network.push.apply(network, ndjson(zipData(buf, entry)))
235
- else if (/\\.trace$/.test(entry.name)) trace.push.apply(trace, ndjson(zipData(buf, entry)))
323
+ const traceContext = traceContextOf(entry.name)
324
+ if (traceContext === 'test') {
325
+ runner.push.apply(runner, ndjson(zipData(buf, entry)))
326
+ continue
327
+ }
328
+ if (traceContext !== null) {
329
+ const ctx = contextFor(traceContext)
330
+ ctx.trace.push.apply(ctx.trace, ndjson(zipData(buf, entry)))
331
+ continue
332
+ }
333
+ const networkContext = networkContextOf(entry.name)
334
+ if (networkContext !== null) {
335
+ const ctx = contextFor(networkContext)
336
+ ctx.network.push.apply(ctx.network, ndjson(zipData(buf, entry)))
337
+ }
236
338
  }
237
- const requests = []
238
- for (const line of network) {
239
- if (!line || line.type !== 'resource-snapshot') continue
240
- const snapshot = line.snapshot || {}
241
- const request = snapshot.request || {}
242
- if (!request.url || !request.method) continue
243
- const response = snapshot.response || {}
244
- const type = String(snapshot._resourceType || '')
245
- if (!origin) origin = type === 'document' ? originOf(String(request.url)) : ''
339
+ const requestsAll = []
340
+ for (const ctx of contexts) {
341
+ for (const line of ctx.network) {
342
+ if (!line || line.type !== 'resource-snapshot') continue
343
+ const snapshot = line.snapshot || {}
344
+ const request = snapshot.request || {}
345
+ if (!request.url || !request.method) continue
346
+ const response = snapshot.response || {}
347
+ const type = String(snapshot._resourceType || '')
348
+ if (!origin) origin = type === 'document' ? originOf(String(request.url)) : ''
246
349
  // What the request carried for CREDENTIALS — never the secret itself. A 401 on the app's own
247
350
  // API cannot be read without this: m148's favorite POST answered 401 with no Authorization
248
351
  // header at all, so the client never attached the token, and the note sent the run to the
@@ -259,51 +362,213 @@ for (const one of found.slice(0, MAX_TRACES)) {
259
362
  const authSpace = authValue.search(/\\s/)
260
363
  const authWord = authSpace > 0 ? authValue.slice(0, authSpace) : ''
261
364
  const authScheme = /^[A-Za-z][A-Za-z0-9._~+/-]{0,23}$/.test(authWord) ? authWord : undefined
262
- const sentCookie = headerList.some(function (h) {
263
- return h && typeof h.name === 'string' && /^cookie$/i.test(h.name)
264
- })
265
- requests.push({
266
- method: String(request.method).toUpperCase(),
267
- path: String(request.url).slice(0, MAX_URL),
268
- status: Number(response.status) || 0,
269
- failure: typeof response._failureText === 'string' ? response._failureText : undefined,
270
- type: type,
271
- sentAuthorization: authHeader !== undefined,
272
- authorization: authScheme,
273
- sentCookie: sentCookie,
274
- // The HAR entry carries the monotonic clock the trace's action entries use, so a request
275
- // can be attributed to the action that caused it (see \`lastAction\` below).
276
- startedAt: typeof snapshot._monotonicTime === 'number' ? snapshot._monotonicTime : undefined,
277
- postData: request.postData,
278
- content: response.content,
279
- })
365
+ const sentCookie = headerList.some(function (h) {
366
+ return h && typeof h.name === 'string' && /^cookie$/i.test(h.name)
367
+ })
368
+ const parsed = {
369
+ method: String(request.method).toUpperCase(),
370
+ path: String(request.url).slice(0, MAX_URL),
371
+ status: Number(response.status) || 0,
372
+ failure: typeof response._failureText === 'string' ? response._failureText : undefined,
373
+ type: type,
374
+ sentAuthorization: authHeader !== undefined,
375
+ authorization: authScheme,
376
+ sentCookie: sentCookie,
377
+ // The HAR entry carries the monotonic clock the trace's action entries use, so a request
378
+ // can be attributed to the action that caused it (see \`lastAction\` below).
379
+ startedAt: typeof snapshot._monotonicTime === 'number' ? snapshot._monotonicTime : undefined,
380
+ // The context member and the page ref the HAR entry records: a request made by the test's
381
+ // helper context is not the failing page's, however late the archive wrote it (m160).
382
+ context: ctx.id,
383
+ pageId: typeof snapshot.pageref === 'string' ? snapshot.pageref : undefined,
384
+ postData: request.postData,
385
+ content: response.content,
386
+ }
387
+ ctx.requests.push(parsed)
388
+ requestsAll.push(parsed)
389
+ }
280
390
  }
281
391
  if (!origin) {
282
- const first = requests.find(function (r) { return r.type === 'document' }) || requests[0]
392
+ const first = requestsAll.find(function (r) { return r.type === 'document' }) || requestsAll[0]
283
393
  origin = first ? originOf(first.path) : ''
284
394
  }
285
- for (const r of requests) r.path = pathOf(r.path).slice(0, MAX_URL)
395
+ for (const r of requestsAll) r.path = pathOf(r.path).slice(0, MAX_URL)
286
396
  // The page's own actions, timed. before/after pair on the call id, and only the LIBRARY
287
397
  // traces carry a class and a method (Frame.goto, Locator.fill); the test runner's own
288
- // trace times its hooks and fixtures with class Test, which is not a page action. Read BEFORE
289
- // the request buckets: whether the navigation itself finished decides whether a subresource
290
- // with no response is evidence (see \`navigationOpen\` below).
291
- const ended = {}
292
- for (const entry of trace) {
293
- if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
294
- ended[entry.callId] = entry.endTime
398
+ // trace times its hooks and fixtures with class Test, which is not a page action. Each action
399
+ // keeps the context it came from and the page/frame its frame snapshot names, so the failing
400
+ // assertion's own page can be selected below (m160).
401
+ const actionsAll = []
402
+ const framesAll = []
403
+ for (const ctx of contexts) {
404
+ const ended = {}
405
+ for (const entry of ctx.trace) {
406
+ if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
407
+ ended[entry.callId] = entry.endTime
408
+ }
409
+ const pageByCall = {}
410
+ for (const entry of ctx.trace) {
411
+ if (!entry || entry.type !== 'frame-snapshot') continue
412
+ const snap = entry.snapshot || {}
413
+ if (typeof snap.callId === 'string' && typeof snap.pageId === 'string' && pageByCall[snap.callId] === undefined)
414
+ pageByCall[snap.callId] = snap.pageId
415
+ if (snap.isMainFrame === false) continue
416
+ if (typeof snap.frameUrl !== 'string' || typeof snap.timestamp !== 'number') continue
417
+ ctx.frames.push({
418
+ url: pathOf(snap.frameUrl).slice(0, MAX_URL),
419
+ at: snap.timestamp,
420
+ context: ctx.id,
421
+ pageId: typeof snap.pageId === 'string' ? snap.pageId : undefined,
422
+ frameId: typeof snap.frameId === 'string' ? snap.frameId : undefined,
423
+ })
424
+ }
425
+ for (const entry of ctx.trace) {
426
+ if (!entry || entry.type !== 'before') continue
427
+ if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
428
+ if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
429
+ const action = {
430
+ api: entry.class + '.' + entry.method,
431
+ start: entry.startTime,
432
+ end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
433
+ context: ctx.id,
434
+ pageId: typeof entry.callId === 'string' ? pageByCall[entry.callId] : undefined,
435
+ frameId: undefined,
436
+ stepId: typeof entry.stepId === 'string' ? entry.stepId : undefined,
437
+ }
438
+ if (action.pageId !== undefined) {
439
+ for (const frame of ctx.frames) {
440
+ if (frame.pageId !== action.pageId) continue
441
+ action.frameId = frame.frameId
442
+ break
443
+ }
444
+ }
445
+ ctx.actions.push(action)
446
+ }
447
+ // Monotonic time, not archive order: two contexts interleave in the zip, and one context's
448
+ // own actions still have to be read in the order the page performed them.
449
+ ctx.actions.sort(function (a, b) { return a.start - b.start })
450
+ ctx.frames.sort(function (a, b) { return a.at - b.at })
451
+ for (const action of ctx.actions) actionsAll.push(action)
452
+ for (const frame of ctx.frames) framesAll.push(frame)
295
453
  }
296
- const actions = []
297
- for (const entry of trace) {
298
- if (!entry || entry.type !== 'before') continue
299
- if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
300
- if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
301
- actions.push({
302
- api: entry.class + '.' + entry.method,
303
- start: entry.startTime,
304
- end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
305
- })
454
+ // The step the runner itself reports as failed, and the location it names. The runner writes
455
+ // the error on the failed step's \`after\` entry; taking the LAST step with a spec stack instead
456
+ // named the afterEach's \`context.close()\` (m160's social.spec.ts:13) rather than the strict-mode
457
+ // assertion at :90. The first error is the test's own failure — an afterEach that also fails
458
+ // runs after it.
459
+ let failedStepId = null
460
+ let failedStep = null
461
+ let failedEnd = null
462
+ let failureSpec = null
463
+ let failureLine = 0
464
+ let lastSpec = null
465
+ let lastSpecLine = 0
466
+ for (const entry of runner) {
467
+ if (!entry) continue
468
+ if (entry.type === 'after' && entry.error && typeof entry.callId === 'string' && failedStepId === null) {
469
+ failedStepId = entry.callId
470
+ failedEnd = typeof entry.endTime === 'number' ? entry.endTime : null
471
+ }
472
+ }
473
+ // The fallback location — the last step that names a spec — is read from every member, runner
474
+ // and library alike: a trace written without a test.trace (a synthetic fixture, an older
475
+ // Playwright) still names its spec on the library entries, and dropping that would leave the
476
+ // row placeless.
477
+ for (const traceEntries of [runner].concat(contexts.map(function (ctx) { return ctx.trace }))) {
478
+ for (const entry of traceEntries) {
479
+ if (!entry || entry.type !== 'before' || !Array.isArray(entry.stack)) continue
480
+ for (const frame of entry.stack) {
481
+ if (typeof frame.file !== 'string' || !/\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) continue
482
+ lastSpec = specPathOf(frame.file)
483
+ lastSpecLine = Number(frame.line) || 0
484
+ break
485
+ }
486
+ }
306
487
  }
488
+ if (failedStepId !== null) {
489
+ for (const entry of runner) {
490
+ if (!entry || entry.type !== 'before' || entry.callId !== failedStepId) continue
491
+ failedStep = entry
492
+ if (!Array.isArray(entry.stack)) break
493
+ for (const frame of entry.stack) {
494
+ if (typeof frame.file !== 'string' || !/\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) continue
495
+ failureSpec = specPathOf(frame.file)
496
+ failureLine = Number(frame.line) || 0
497
+ break
498
+ }
499
+ break
500
+ }
501
+ }
502
+ const spec = failureSpec !== null ? failureSpec : lastSpec
503
+ const line = failureSpec !== null ? failureLine : lastSpecLine
504
+ // The page the failing step ran on. The library trace records the runner's step id on every
505
+ // action it performed for that step, and each action's frame snapshot carries its pageId — so
506
+ // the failing assertion's page is the step's own page, never the last page any context left
507
+ // behind (m160: the helper context's /editor won because its member came later in the zip).
508
+ let failedPageIds = []
509
+ if (failedStepId !== null) {
510
+ for (const action of actionsAll) {
511
+ if (action.stepId !== failedStepId || action.pageId === undefined) continue
512
+ if (failedPageIds.indexOf(action.pageId) === -1) failedPageIds.push(action.pageId)
513
+ }
514
+ // A step with no library action of its own (a plain \`page.waitForTimeout\`, a runner-only
515
+ // step) falls back to the actions that ran inside its own interval.
516
+ if (failedPageIds.length === 0 && failedStep !== null && typeof failedStep.startTime === 'number') {
517
+ for (const action of actionsAll) {
518
+ if (action.pageId === undefined) continue
519
+ if (action.start < failedStep.startTime - 1) continue
520
+ if (failedEnd !== null && action.start > failedEnd + 1) continue
521
+ if (failedPageIds.indexOf(action.pageId) === -1) failedPageIds.push(action.pageId)
522
+ }
523
+ }
524
+ }
525
+ let failedPage = failedPageIds.length === 1 ? failedPageIds[0] : null
526
+ // A member with no action, request or frame is not a context the test used: Playwright writes
527
+ // a trace entry for a context it opened and closed without a page, and counting that as a
528
+ // second context would make every ordinary trace unattributable.
529
+ const usedContexts = contexts.filter(function (ctx) {
530
+ return ctx.actions.length > 0 || ctx.requests.length > 0 || ctx.frames.length > 0
531
+ })
532
+ // One context, one page: nothing to attribute, and no second context to merge.
533
+ if (failedPage === null && usedContexts.length === 1) {
534
+ const pages = []
535
+ for (const frame of framesAll) {
536
+ if (frame.pageId !== undefined && pages.indexOf(frame.pageId) === -1) pages.push(frame.pageId)
537
+ }
538
+ if (pages.length === 1) failedPage = pages[0]
539
+ }
540
+ let failedContext = null
541
+ if (failedPage !== null) {
542
+ for (const frame of framesAll) {
543
+ if (frame.pageId !== failedPage) continue
544
+ failedContext = frame.context
545
+ break
546
+ }
547
+ if (failedContext === null) {
548
+ for (const action of actionsAll) {
549
+ if (action.pageId !== failedPage) continue
550
+ failedContext = action.context
551
+ break
552
+ }
553
+ }
554
+ if (failedContext === null) {
555
+ for (const r of requestsAll) {
556
+ if (r.pageId !== failedPage) continue
557
+ failedContext = r.context
558
+ break
559
+ }
560
+ }
561
+ }
562
+ // Two used contexts and no identified page: the note makes no causal claim rather than
563
+ // merging them (m160's rule). With one used context everything in it belongs to the test's page.
564
+ const unattributed = failedPage === null && usedContexts.length > 1
565
+ const actions = failedPage === null ? actionsAll : actionsAll.filter(function (a) { return a.pageId === failedPage })
566
+ const frames = failedPage === null ? (usedContexts.length === 1 ? framesAll : []) : framesAll.filter(function (f) { return f.pageId === failedPage })
567
+ let requests = failedPage === null ? requestsAll : requestsAll.filter(function (r) { return r.pageId === failedPage })
568
+ // A trace whose HAR entries carry no page ref: the failing context's own requests are still
569
+ // its page's, because a context in a test with one page has nowhere else to send them.
570
+ if (failedPage !== null && requests.length === 0 && failedContext !== null)
571
+ requests = requestsAll.filter(function (r) { return r.context === failedContext })
307
572
  // A \`page.goto\` that has no end time: the page never finished loading. When that is the
308
573
  // failure, a document subresource with no response is the reason — the navigation waits for
309
574
  // its stylesheets and scripts — and it is evidence, not the aborted noise a finished
@@ -393,7 +658,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
393
658
  // then rendering signed-out) leads instead of the Home page's own fetch.
394
659
  const USER_ACTION = /\\.(?:click|dblclick|tap|fill|press|pressSequentially|selectOption|check|uncheck|setInputFiles|goto|type)$/
395
660
  const userActions = actions.filter(function (a) { return USER_ACTION.test(a.api) })
396
- const lastAction = userActions.length > 0 ? userActions[userActions.length - 1] : null
661
+ // With two contexts and no identified page there is no causal claim to make: the last action
662
+ // of the concatenation could belong to the helper's page (m160).
663
+ const lastAction = unattributed || userActions.length === 0 ? null : userActions[userActions.length - 1]
397
664
  // A request the action caused started after it (a millisecond of clock slack) and before the
398
665
  // assertion — which is everything later in the trace. A request that started before it is the
399
666
  // page's own load, not the action's; the page shell's files are never the step's, except a
@@ -431,7 +698,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
431
698
  for (const i of actionIndexes) {
432
699
  const r = requests[i]
433
700
  const sent = bodyText(buf, entries, r.postData, MAX_BODY)
434
- const got = bodyText(buf, entries, r.content, MAX_BODY)
701
+ const got = gotText(buf, entries, r)
435
702
  if (sent) r.sent = sent
436
703
  if (got) r.got = got
437
704
  }
@@ -439,7 +706,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
439
706
  if (r.status < 400 || r.status >= 500) continue
440
707
  if (/^https?:/i.test(r.path)) continue
441
708
  const sent = bodyText(buf, entries, r.postData, MAX_BODY)
442
- const got = bodyText(buf, entries, r.content, MAX_BODY)
709
+ const got = gotText(buf, entries, r)
443
710
  if (sent) r.sent = sent
444
711
  if (got) r.got = got
445
712
  }
@@ -458,19 +725,13 @@ for (const one of found.slice(0, MAX_TRACES)) {
458
725
  ...(r.sentAuthorization ? { sentAuthorization: true } : {}),
459
726
  ...(r.authorization ? { authorization: r.authorization } : {}),
460
727
  ...(r.sentCookie ? { sentCookie: true } : {}),
728
+ ...(r.context ? { context: r.context } : {}),
729
+ ...(r.pageId ? { pageId: r.pageId } : {}),
461
730
  }
462
731
  }
463
- // The main frame's URL, timed: what the page showed when the action ran, and where it ended up.
464
- // The trace records these around every action, which is how the note can say what the page did
465
- // after the request — a client-side redirect never appears in the network log as a document.
466
- const frames = []
467
- for (const entry of trace) {
468
- if (!entry || entry.type !== 'frame-snapshot') continue
469
- const snap = entry.snapshot || {}
470
- if (snap.isMainFrame === false) continue
471
- if (typeof snap.frameUrl !== 'string' || typeof snap.timestamp !== 'number') continue
472
- frames.push({ url: pathOf(snap.frameUrl).slice(0, MAX_URL), at: snap.timestamp })
473
- }
732
+ // The failing page's own frame snapshots, timed: what the page showed when the action ran, and
733
+ // where it ended up. They are the snapshots of the assertion's page only — another context's
734
+ // editor page is not where this page went (m160) — and they are ordered by the trace's clock.
474
735
  let last = null
475
736
  if (lastAction !== null) {
476
737
  let pageBefore
@@ -494,8 +755,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
494
755
  }
495
756
  // The fallback lead stays for traces with no action clock. With an action, only a request it
496
757
  // caused is the lead; an action that sent none leaves the page's own load to the row's
497
- // "also answered" bucket, not to a lead that would hide it.
498
- if (lead === null && lastAction === null) {
758
+ // "also answered" bucket, not to a lead that would hide it. With two contexts and no identified
759
+ // page there is no causal lead at all (m160).
760
+ if (!unattributed && lead === null && lastAction === null) {
499
761
  let leadIndex = -1
500
762
  for (let i = requests.length - 1; i >= 0; i--) {
501
763
  const r = requests[i]
@@ -509,7 +771,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
509
771
  if (leadIndex !== -1) {
510
772
  const r = requests[leadIndex]
511
773
  const sent = bodyText(buf, entries, r.postData, MAX_BODY)
512
- const got = bodyText(buf, entries, r.content, MAX_BODY)
774
+ const got = gotText(buf, entries, r)
513
775
  lead = wire(r)
514
776
  if (sent) lead.sent = sent
515
777
  if (got) lead.got = got
@@ -555,24 +817,49 @@ for (const one of found.slice(0, MAX_TRACES)) {
555
817
  const consoleErrors = []
556
818
  const consoleSignals = []
557
819
  const consoleWarnings = []
558
- let spec = null
559
- let line = 0
560
- for (const entry of trace) {
561
- if (!entry) continue
562
- if (entry.type === 'console' && typeof entry.text === 'string') {
563
- if (entry.messageType === 'error') consoleErrors.push(entry.text)
564
- else if (consoleSignal(entry.text)) consoleSignals.push(entry.text)
565
- else if (entry.messageType === 'warning' || entry.messageType === 'warn')
566
- consoleWarnings.push(entry.text)
567
- } else if (entry.type === 'event' && entry.method === 'pageError') {
568
- const message = messageOf(entry.params)
569
- if (message) consoleErrors.push(message)
570
- } else if (entry.type === 'before' && Array.isArray(entry.stack) && entry.stack.length) {
571
- const frame = entry.stack[0] || {}
572
- if (typeof frame.file === 'string' && /\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) {
573
- spec = specPathOf(frame.file)
574
- line = Number(frame.line) || 0
820
+ // The console lines of the page the assertion ran on; with no identified page, of every context.
821
+ const consoleTraces =
822
+ failedContext === null
823
+ ? contexts.map(function (ctx) { return ctx.trace })
824
+ : contexts.filter(function (ctx) { return ctx.id === failedContext }).map(function (ctx) { return ctx.trace })
825
+ for (const traceEntries of consoleTraces) {
826
+ for (const entry of traceEntries) {
827
+ if (!entry) continue
828
+ if (entry.type === 'console' && typeof entry.text === 'string') {
829
+ if (entry.messageType === 'error') consoleErrors.push(entry.text)
830
+ else if (consoleSignal(entry.text)) consoleSignals.push(entry.text)
831
+ else if (entry.messageType === 'warning' || entry.messageType === 'warn')
832
+ consoleWarnings.push(entry.text)
833
+ } else if (entry.type === 'event' && entry.method === 'pageError') {
834
+ const message = messageOf(entry.params)
835
+ if (message) consoleErrors.push(message)
836
+ }
837
+ }
838
+ }
839
+ // Evidence from the OTHER browser contexts the test opened — kept apart from the failing page's
840
+ // requests and labelled as set-up work, never as the failing action's effect (m160).
841
+ const otherContexts = []
842
+ if (failedContext !== null) {
843
+ for (const ctx of usedContexts) {
844
+ if (ctx.id === failedContext) continue
845
+ const picked = []
846
+ for (const r of ctx.requests) {
847
+ if (shellRequest(r)) continue
848
+ const sent = bodyText(buf, entries, r.postData, MAX_BODY)
849
+ const got = gotText(buf, entries, r)
850
+ if (sent) r.sent = sent
851
+ if (got) r.got = got
852
+ picked.push(r)
575
853
  }
854
+ if (picked.length === 0 && ctx.actions.length === 0) continue
855
+ // Failures first, then by the trace's clock: a 401 from the helper context is worth naming
856
+ // even when its later answered calls would fill the bound.
857
+ picked.sort(function (a, b) {
858
+ const left = failedRequest(a) ? 0 : 1
859
+ const right = failedRequest(b) ? 0 : 1
860
+ return left === right ? (a.startedAt || 0) - (b.startedAt || 0) : left - right
861
+ })
862
+ otherContexts.push({ context: ctx.id, requests: picked.slice(0, MAX_NOTE).map(wire) })
576
863
  }
577
864
  }
578
865
  // Chromium reports every failed request as a console error too; where the page logged something
@@ -596,6 +883,8 @@ for (const one of found.slice(0, MAX_TRACES)) {
596
883
  requests: chosen.slice(0, MAX_REQUESTS),
597
884
  console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
598
885
  actions: actions.slice(-MAX_ACTIONS),
886
+ ...(unattributed ? { unattributed: true } : {}),
887
+ ...(otherContexts.length > 0 ? { otherContexts: otherContexts } : {}),
599
888
  }) + '\\n')
600
889
  }
601
890
  `;
@@ -624,6 +913,8 @@ function parseTraceRequest(value) {
624
913
  ? { authorization: r.authorization }
625
914
  : {}),
626
915
  ...(r.sentCookie === true ? { sentCookie: true } : {}),
916
+ ...(typeof r.context === 'string' && r.context ? { context: r.context } : {}),
917
+ ...(typeof r.pageId === 'string' && r.pageId ? { pageId: r.pageId } : {}),
627
918
  };
628
919
  }
629
920
  /**
@@ -671,6 +962,17 @@ export function parseTraceArtifacts(stdout) {
671
962
  const actions = Array.isArray(raw.actions) ? raw.actions : [];
672
963
  const lead = parseTraceRequest(raw.lead);
673
964
  const lastAction = parseLastAction(raw.lastAction);
965
+ const otherContexts = Array.isArray(raw.otherContexts)
966
+ ? raw.otherContexts
967
+ .filter((c) => typeof c === 'object' && c !== null)
968
+ .filter((c) => typeof c.context === 'string')
969
+ .map((c) => ({
970
+ context: String(c.context),
971
+ requests: (Array.isArray(c.requests) ? c.requests : [])
972
+ .map(parseTraceRequest)
973
+ .filter((request) => request !== null),
974
+ }))
975
+ : [];
674
976
  out.push({
675
977
  spec: typeof raw.spec === 'string' ? raw.spec : null,
676
978
  line: typeof raw.line === 'number' ? raw.line : 0,
@@ -687,7 +989,12 @@ export function parseTraceArtifacts(stdout) {
687
989
  api: String(a.api),
688
990
  start: Number(a.start),
689
991
  end: a.end === null || a.end === undefined ? null : Number(a.end),
992
+ ...(typeof a.context === 'string' ? { context: a.context } : {}),
993
+ ...(typeof a.pageId === 'string' ? { pageId: a.pageId } : {}),
994
+ ...(typeof a.frameId === 'string' ? { frameId: a.frameId } : {}),
690
995
  })),
996
+ ...(otherContexts.length === 0 ? {} : { otherContexts }),
997
+ ...(raw.unattributed === true ? { unattributed: true } : {}),
691
998
  });
692
999
  }
693
1000
  catch (_error) {
@@ -1009,6 +1316,36 @@ function runFailureOf(item, failures) {
1009
1316
  }
1010
1317
  return undefined;
1011
1318
  }
1319
+ /**
1320
+ * Where the row names this failing test.
1321
+ *
1322
+ * The trace names the line of the step it recorded — the failing assertion when the runner wrote
1323
+ * its error on that step, and the suite's `afterEach` (`context.close()`) when it did not. The
1324
+ * runner's own summary names the test's DECLARATION line, and that is the location the result
1325
+ * quotes and the line a re-run command takes, so it wins whenever the run reported one for this
1326
+ * file: matched by the trace's own line inside the test body ({@link runFailureOf}), and otherwise
1327
+ * by the file alone when that file has exactly ONE reported failure — an `afterEach` line sits
1328
+ * BEFORE the declaration, so the line window cannot match it (xr-molecule-flash-m163's row said
1329
+ * `navigation.spec.ts:12`, the suite's `afterEach`, for the test declared at `:200`).
1330
+ *
1331
+ * @param item - One traced failure.
1332
+ * @param failures - The runner's report, one entry per failing test.
1333
+ * @returns The `file:line` the row names.
1334
+ */
1335
+ function failureWhereOf(item, failures) {
1336
+ const spec = item.spec;
1337
+ if (spec === null)
1338
+ return whereOf(item);
1339
+ let reported = runFailureOf(item, failures);
1340
+ if (reported === undefined) {
1341
+ const sameFile = failures.filter((failure) => sameLocationFile(spec, failure.spec));
1342
+ if (sameFile.length === 1)
1343
+ reported = sameFile[0];
1344
+ }
1345
+ if (reported === undefined)
1346
+ return whereOf(item);
1347
+ return reported.line > 0 ? `${reported.spec}:${reported.line}` : reported.spec;
1348
+ }
1012
1349
  /**
1013
1350
  * Whether a pending request's interval covers a navigation's wait: it started while the
1014
1351
  * navigation was loading (at or after the wait began) and it has no recorded response, so it was
@@ -1420,6 +1757,14 @@ function missingApiPrefixSentence(request) {
1420
1757
  * (answered 201, the page still rendering signed-out) never appeared. With no action clock, the
1421
1758
  * row falls back to {@link TraceArtifact.lead}: the page's last answered fetch/xhr.
1422
1759
  *
1760
+ * The action, the URL and the requests are the FAILING ASSERTION'S PAGE's ({@link
1761
+ * TraceArtifact.lastAction}), never the archive's last context: a test that creates its data in a
1762
+ * second browser context writes that context's click and `/editor` snapshot into the same zip, and
1763
+ * reading the concatenation made them the failing page (xr-molecule-flash-m160). The other
1764
+ * contexts' requests are named separately as set-up work. When the trace holds more than one used
1765
+ * context and the assertion's page cannot be identified, the note lists the requests and makes NO
1766
+ * causal claim — no "the test's last action", no "the page stayed at …" — instead of merging them.
1767
+ *
1423
1768
  * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
1424
1769
  * needs a recorded response, a real failure or a console line ({@link traceRowEvidence}), rows are
1425
1770
  * deduplicated by the test's location, and they are ordered real failure → repeated from an
@@ -1474,6 +1819,7 @@ function missingApiPrefixSentence(request) {
1474
1819
  * error.
1475
1820
  */
1476
1821
  export function failureTraceNote(items, target, earlierFailures = [], apiPrefixes = [], report = {}) {
1822
+ const failures = report.failures ?? [];
1477
1823
  const candidates = items.filter((item) => traceRowEvidence(item, report));
1478
1824
  if (candidates.length === 0)
1479
1825
  return null;
@@ -1486,7 +1832,7 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1486
1832
  const seen = new Set();
1487
1833
  for (const { item, index } of ranked) {
1488
1834
  // A trace that names no spec cannot be placed; it is never collapsed onto another one.
1489
- const where = item.spec === null ? `#${index}` : whereOf(item);
1835
+ const where = item.spec === null ? `#${index}` : failureWhereOf(item, failures);
1490
1836
  if (seen.has(where))
1491
1837
  continue;
1492
1838
  seen.add(where);
@@ -1540,6 +1886,9 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1540
1886
  ];
1541
1887
  const anyStalled = stalls.some((stall) => stall !== null);
1542
1888
  const rows = [];
1889
+ // Rows whose failing action sent nothing and left the page on a new route: the page is still
1890
+ // showing the previous route's data. Counted so the note can say it once for the whole run.
1891
+ let staleRouteRows = 0;
1543
1892
  for (const [index, item] of traced.entries()) {
1544
1893
  // The test's LAST action before the failing assertion leads the row (m148): the requests it
1545
1894
  // caused and what came back explain a page that never showed the signed-in state, while the
@@ -1577,7 +1926,26 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1577
1926
  const parts = [];
1578
1927
  if (step !== undefined && !stalledNav) {
1579
1928
  const head = `the test's last action was ${step.api} at ${actionClock(item, step.start)}`;
1580
- if (stepRequests.length === 0) {
1929
+ const pageBefore = step.pageBefore;
1930
+ const pageAfter = step.pageAfter;
1931
+ // The action asked the API for nothing and the page ended up on another route: the page is
1932
+ // still showing the data it fetched for the route before, which is why the new route's list
1933
+ // is wrong (xr-molecule-flash-m163 +10:00 — the note printed "it sent no request" beside
1934
+ // "the page then navigated to …", then pointed at a response body that never existed, and
1935
+ // the run spent six minutes on markup and CSS).
1936
+ const staleRoute = stepRequests.length === 0 &&
1937
+ pageBefore !== undefined &&
1938
+ pageAfter !== undefined &&
1939
+ pageBefore !== pageAfter;
1940
+ if (staleRoute) {
1941
+ staleRouteRows += 1;
1942
+ parts.push(`${head}, and it sent no request — the page moved from ${pageBefore} to ${pageAfter}. ` +
1943
+ `The page changed route but fetched nothing new, so it is still showing the previous ` +
1944
+ `route's data: check how the page reads the route (a param the route does not define ` +
1945
+ `is always undefined; a missing effect dependency on the route means the fetch never ` +
1946
+ `re-runs), not the markup or the CSS`);
1947
+ }
1948
+ else if (stepRequests.length === 0) {
1581
1949
  parts.push(`${head}, and it sent no request`);
1582
1950
  }
1583
1951
  else {
@@ -1589,10 +1957,10 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1589
1957
  }
1590
1958
  // What the page did next: a navigation shows in the main frame's URL even when the app moved
1591
1959
  // client-side and the network log recorded no document request.
1592
- if (step.pageAfter !== undefined) {
1593
- parts.push(step.pageBefore !== undefined && step.pageBefore !== step.pageAfter
1594
- ? `the page then navigated to ${step.pageAfter}`
1595
- : `the page stayed at ${step.pageAfter}`);
1960
+ if (!staleRoute && pageAfter !== undefined) {
1961
+ parts.push(pageBefore !== undefined && pageBefore !== pageAfter
1962
+ ? `the page then navigated to ${pageAfter}`
1963
+ : `the page stayed at ${pageAfter}`);
1596
1964
  }
1597
1965
  // A 4xx on the logout endpoint with the page unmoved is not a backend failure: the auth
1598
1966
  // client swallows the call and clears the session, and the URL can only move if the page
@@ -1649,14 +2017,29 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1649
2017
  .slice(0, 2)
1650
2018
  .map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 160))
1651
2019
  .join(' | ')}`);
1652
- rows.push(` ${index + 1}. ${whereOf(item)} — ${parts.join(' — ')}`);
2020
+ // A request from another browser context the test opened is not the failing action's effect:
2021
+ // it is set-up work on a different page, and it is named as such rather than merged into the
2022
+ // row (xr-molecule-flash-m160: the helper context's POST /api/articles was printed as the
2023
+ // click's effect while the assertion had failed on the profile page).
2024
+ const others = (item.otherContexts ?? []).filter((context) => context.requests.length > 0);
2025
+ for (const [otherIndex, context] of others.slice(0, 2).entries()) {
2026
+ const label = otherIndex === 0
2027
+ ? 'a second browser context, opened by the test for set-up and not the page the assertion ran on'
2028
+ : 'another browser context the test opened';
2029
+ parts.push(`${label}: ${context.requests.slice(0, 2).map(requestLine).join('; ')}`);
2030
+ }
2031
+ rows.push(` ${index + 1}. ${failureWhereOf(item, failures)} — ${parts.join(' — ')}`);
1653
2032
  }
1654
2033
  // Distinct locations the note did not name: the repeats collapse, so the count is what is left
1655
2034
  // to look at, not how many zips Playwright wrote.
1656
- const distinct = new Set(candidates.map((item, index) => (item.spec === null ? `#${index}` : whereOf(item))));
2035
+ const distinct = new Set(candidates.map((item, index) => item.spec === null ? `#${index}` : failureWhereOf(item, failures)));
1657
2036
  const more = distinct.size > traced.length
1658
2037
  ? `\n … and ${distinct.size - traced.length} more traced failures`
1659
2038
  : '';
2039
+ // Every row came from a trace with more than one browser context and no page to tie the
2040
+ // assertion to: the note may list the requests, and may not say what action sent them or where
2041
+ // "the page" went (m160).
2042
+ const allUnattributed = traced.every((item) => item.unattributed === true);
1660
2043
  // A run whose pages never loaded has one cause, and it is not in the app: every traced failure is
1661
2044
  // a `page.goto` that timed out on a document the browser could not load. The transport error is
1662
2045
  // the first line the executor needs — m118 (+37:09) read 14 goto timeouts as app failures while
@@ -1699,12 +2082,33 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1699
2082
  `and if the stall repeats measure that exact fetch ` +
1700
2083
  `(\`curl -o /dev/null -w '%{time_starttransfer} %{time_total}' <url>\`) before ` +
1701
2084
  `treating it as the app's.\n\n`);
2085
+ // Every row is a route change that fetched nothing: the cause and the next read are the same
2086
+ // for the whole run, and the generic "read the response body" paragraph would point at a
2087
+ // response that never existed (xr-molecule-flash-m163 +10:00).
2088
+ const everyStaleRoute = staleRouteRows > 0 &&
2089
+ staleRouteRows === traced.length &&
2090
+ !anyFailed &&
2091
+ !anyStalled &&
2092
+ !allUnattributed &&
2093
+ transport === null;
2094
+ const staleRouteLead = everyStaleRoute
2095
+ ? `Every failure this run traced is the same shape: the failing action asked the API for ` +
2096
+ `nothing and the page changed route, so the page is still showing the data it fetched for ` +
2097
+ `the route before. Read how each page derives its query from the route — a param the route ` +
2098
+ `does not define is always undefined, and a missing effect dependency on the route means ` +
2099
+ `the fetch never re-runs — before touching the markup or the CSS.\n\n`
2100
+ : '';
1702
2101
  return (transportLead +
1703
2102
  stalledLead +
2103
+ staleRouteLead +
1704
2104
  `The platform read the failing tests' own Playwright traces from the run against ${target} ` +
1705
- `(what each page sent and got — first what the test's last action sent and where the page went ` +
1706
- `next, then the requests that failed, the ones that got no response, and its last answered ` +
1707
- `ones):\n` +
2105
+ (allUnattributed
2106
+ ? `(what each page sent and got — the requests their contexts recorded, with no claim about ` +
2107
+ `which action caused them, then the ones that failed, the ones that got no response, and ` +
2108
+ `the last answered ones):\n`
2109
+ : `(what each page sent and got — first what the test's last action sent and where the page ` +
2110
+ `went next, then the requests that failed, the ones that got no response, and its last ` +
2111
+ `answered ones):\n`) +
1708
2112
  `${rows.join('\n')}${more}\n` +
1709
2113
  (anyFailed
1710
2114
  ? everyFailureLacksPrefix
@@ -1734,13 +2138,24 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1734
2138
  'not finish loading because its own subresource never answered. Do not read this as a ' +
1735
2139
  'page-state bug: re-run these tests against the same URL first, and change the app only ' +
1736
2140
  'if the same navigation times out again with no stalled asset in the trace.'
1737
- : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
1738
- "page, not the API. Start with what the test's last action sent (the first line of each " +
1739
- 'row) and what the page did next: a request that answered (2xx included) while the page ' +
1740
- 'still did not show the state the assertion waited on is the fact to read — its status ' +
1741
- 'and response body, not the selector. If the request is not the one the test meant — an ' +
1742
- 'unfollow sent where the test clicked follow — the state the action read was stale ' +
1743
- 'before it.') +
2141
+ : allUnattributed
2142
+ ? 'No listed request failed — none got a 4xx/5xx or a network error. The trace held more ' +
2143
+ 'than one browser context and nothing ties a page to the failing assertion, so no ' +
2144
+ 'action or URL is attributed here: read the requests above together with the runner’s ' +
2145
+ 'own failure text to tell which page the test asserted on, and read that page’s state, ' +
2146
+ 'not the selector.'
2147
+ : everyStaleRoute
2148
+ ? 'No listed request failed — none got a 4xx/5xx or a network error — and the failing ' +
2149
+ 'action sent no request at all, so there is no response to read. The page changed ' +
2150
+ 'route and fetched nothing, which is why it still shows the previous route’s data: ' +
2151
+ 'fix how the page reads the route, not the selector.'
2152
+ : 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
2153
+ "page, not the API. Start with what the test's last action sent (the first line of each " +
2154
+ 'row) and what the page did next: a request that answered (2xx included) while the page ' +
2155
+ 'still did not show the state the assertion waited on is the fact to read — its status ' +
2156
+ 'and response body, not the selector. If the request is not the one the test meant — an ' +
2157
+ 'unfollow sent where the test clicked follow — the state the action read was stale ' +
2158
+ 'before it.') +
1744
2159
  prefixAdvice +
1745
2160
  (wrapped
1746
2161
  ? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +