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