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