@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.
- package/README.md +98 -15
- package/dist/suite-overlap.d.ts.map +1 -1
- package/dist/suite-overlap.js +7 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +99 -23
- package/dist/trace-artifacts.d.ts +78 -5
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +517 -102
- package/dist/utilities.d.ts +27 -4
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +60 -7
- package/package.json +1 -1
package/dist/trace-artifacts.js
CHANGED
|
@@ -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
|
|
232
|
-
const
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
238
|
-
for (const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
289
|
-
// the
|
|
290
|
-
//
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
464
|
-
//
|
|
465
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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}` :
|
|
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
|
-
|
|
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 (
|
|
1593
|
-
parts.push(
|
|
1594
|
-
? `the page then navigated to ${
|
|
1595
|
-
: `the page stayed at ${
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
:
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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)` / ' +
|