@molecule/api-ai-tools 1.10.5 → 1.10.6
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 +39 -1
- package/dist/trace-artifacts.d.ts +70 -6
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +610 -53
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
|
3
3
|
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
4
|
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
5
|
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
-
Generated: 2026-10-
|
|
6
|
+
Generated: 2026-10-19T13:08:52.848Z
|
|
7
7
|
-->
|
|
8
8
|
|
|
9
9
|
# @molecule/api-ai-tools
|
|
@@ -584,6 +584,13 @@ selector. xr-molecule-flash-m169 (+13:09) spent its fix rounds on the wrong comp
|
|
|
584
584
|
markup detail while `GET /api/tags` was returning the missing "trending" and `PopularTags`
|
|
585
585
|
sliced the response to its first 20 entries (the m99 top-N class, in the frontend).
|
|
586
586
|
|
|
587
|
+
The text must be a value the response held, never a substring of one: a whole JSON string or
|
|
588
|
+
number value, or a word bounded by non-alphanumerics. A text of 2 characters or fewer, or one
|
|
589
|
+
made of digits only, must be a whole JSON value. xr-molecule-flash-m208's pagination assertion
|
|
590
|
+
waited for "2" and the raw substring hit `pag1792394785820` in a tags response, so the note led
|
|
591
|
+
with a response that had nothing to do with the failure; when no response holds the text that
|
|
592
|
+
way, the field is absent and the note leads with its next finding.
|
|
593
|
+
|
|
587
594
|
```typescript
|
|
588
595
|
interface TraceMissingText {
|
|
589
596
|
/** The text the assertion waited for (`trending`). */
|
|
@@ -765,6 +772,15 @@ interface TraceArtifact {
|
|
|
765
772
|
* leaving the run to probe computed styles (xr-molecule-flash-m197).
|
|
766
773
|
*/
|
|
767
774
|
hiddenBox?: TraceHiddenBox
|
|
775
|
+
/**
|
|
776
|
+
* The method+path groups this test's page sent {@link TRACE_NOTE_LOOP_REPEATS} times or more,
|
|
777
|
+
* most-repeated first — a render loop the note leads with (xr-molecule-flash-m215: 2,106
|
|
778
|
+
* `GET /api/articles/<slug>` in one test, printed as four identical `→ 200` lines). Counted over
|
|
779
|
+
* every network entry of the failing page, not the capped {@link TraceArtifact.requests} list.
|
|
780
|
+
*
|
|
781
|
+
* Absent when no group reached the threshold, and on traces read before this field existed.
|
|
782
|
+
*/
|
|
783
|
+
repeated?: TraceRequestRepeat[]
|
|
768
784
|
}
|
|
769
785
|
```
|
|
770
786
|
|
|
@@ -819,6 +835,13 @@ interface TraceRunReport {
|
|
|
819
835
|
* Empty when the caller could not read the app's config.
|
|
820
836
|
*/
|
|
821
837
|
authEndpoints?: readonly string[]
|
|
838
|
+
/**
|
|
839
|
+
* The app source files whose code sends a repeated request, read by the caller from the
|
|
840
|
+
* project (`app/src/**`) before this note is written. A loop's sentence names the component to
|
|
841
|
+
* read instead of leaving the run to search for it (xr-molecule-flash-m215's `Editor.tsx`).
|
|
842
|
+
* Empty when the caller could not read the app or no file states the path.
|
|
843
|
+
*/
|
|
844
|
+
callSites?: readonly { method: string; path: string; file: string }[]
|
|
822
845
|
}
|
|
823
846
|
```
|
|
824
847
|
|
|
@@ -4112,6 +4135,21 @@ How many requests the note names per test.
|
|
|
4112
4135
|
const MAX_TRACE_NOTE_REQUESTS: 4
|
|
4113
4136
|
```
|
|
4114
4137
|
|
|
4138
|
+
#### `TRACE_NOTE_LOOP_REPEATS`
|
|
4139
|
+
|
|
4140
|
+
How many times ONE test may send the same method+path before the note calls it a render loop.
|
|
4141
|
+
|
|
4142
|
+
xr-molecule-flash-m215 (+27:27 → +29:42): an Editor effect with `[seeded, slug, t]` in its
|
|
4143
|
+
dependency list re-fetched `GET /api/articles/<slug>` 2,106 times in one test. The trace reader
|
|
4144
|
+
caps and de-duplicates its request list, so the note printed four identical `→ 200` lines and
|
|
4145
|
+
the run patched the component's seeding, rebuilt and re-ran before unzipping the trace by hand.
|
|
4146
|
+
Twenty is far above the retries a correct page makes (a few on a flaky call) and far below any
|
|
4147
|
+
render loop.
|
|
4148
|
+
|
|
4149
|
+
```typescript
|
|
4150
|
+
const TRACE_NOTE_LOOP_REPEATS: 20
|
|
4151
|
+
```
|
|
4152
|
+
|
|
4115
4153
|
#### `TRACE_NOTE_URL_CHARS`
|
|
4116
4154
|
|
|
4117
4155
|
How long a URL the note prints, before the middle is cut.
|
|
@@ -181,6 +181,13 @@ export interface TraceContextEvidence {
|
|
|
181
181
|
* selector. xr-molecule-flash-m169 (+13:09) spent its fix rounds on the wrong component and on a
|
|
182
182
|
* markup detail while `GET /api/tags` was returning the missing "trending" and `PopularTags`
|
|
183
183
|
* sliced the response to its first 20 entries (the m99 top-N class, in the frontend).
|
|
184
|
+
*
|
|
185
|
+
* The text must be a value the response held, never a substring of one: a whole JSON string or
|
|
186
|
+
* number value, or a word bounded by non-alphanumerics. A text of 2 characters or fewer, or one
|
|
187
|
+
* made of digits only, must be a whole JSON value. xr-molecule-flash-m208's pagination assertion
|
|
188
|
+
* waited for "2" and the raw substring hit `pag1792394785820` in a tags response, so the note led
|
|
189
|
+
* with a response that had nothing to do with the failure; when no response holds the text that
|
|
190
|
+
* way, the field is absent and the note leads with its next finding.
|
|
184
191
|
*/
|
|
185
192
|
export interface TraceMissingText {
|
|
186
193
|
/** The text the assertion waited for (`trending`). */
|
|
@@ -256,6 +263,27 @@ export interface TraceHiddenBox {
|
|
|
256
263
|
/** True when the element has no text and no element children at all. */
|
|
257
264
|
empty?: true;
|
|
258
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* One method+path a test's page sent many times — the trace of a render loop.
|
|
268
|
+
*
|
|
269
|
+
* Counted over EVERY network entry the failing page recorded, before the trace reader's per-trace
|
|
270
|
+
* cap and its method+path+status dedupe, because those two bounds are exactly what hid m215's
|
|
271
|
+
* 2,106-request loop: the note could only ever see four of them.
|
|
272
|
+
*/
|
|
273
|
+
interface TraceRequestRepeat {
|
|
274
|
+
/** The HTTP method (`GET`). */
|
|
275
|
+
method: string;
|
|
276
|
+
/** The path, query and fragment dropped (`/api/articles/test-article-1`). */
|
|
277
|
+
path: string;
|
|
278
|
+
/** How many times the page sent it in this test. */
|
|
279
|
+
count: number;
|
|
280
|
+
/** The last response status recorded. */
|
|
281
|
+
status?: number;
|
|
282
|
+
/** The browser's resource type (`fetch`, `xhr`, `document`). */
|
|
283
|
+
type?: string;
|
|
284
|
+
/** How long the repeated requests spanned, on the trace's own clock (ms). */
|
|
285
|
+
ms?: number;
|
|
286
|
+
}
|
|
259
287
|
/** What one trace says: the test it belongs to, the requests it made, and its console lines. */
|
|
260
288
|
export interface TraceArtifact {
|
|
261
289
|
/** The failure's spec file, relative to the run's directory, when the trace names one. */
|
|
@@ -348,6 +376,15 @@ export interface TraceArtifact {
|
|
|
348
376
|
* leaving the run to probe computed styles (xr-molecule-flash-m197).
|
|
349
377
|
*/
|
|
350
378
|
hiddenBox?: TraceHiddenBox;
|
|
379
|
+
/**
|
|
380
|
+
* The method+path groups this test's page sent {@link TRACE_NOTE_LOOP_REPEATS} times or more,
|
|
381
|
+
* most-repeated first — a render loop the note leads with (xr-molecule-flash-m215: 2,106
|
|
382
|
+
* `GET /api/articles/<slug>` in one test, printed as four identical `→ 200` lines). Counted over
|
|
383
|
+
* every network entry of the failing page, not the capped {@link TraceArtifact.requests} list.
|
|
384
|
+
*
|
|
385
|
+
* Absent when no group reached the threshold, and on traces read before this field existed.
|
|
386
|
+
*/
|
|
387
|
+
repeated?: TraceRequestRepeat[];
|
|
351
388
|
}
|
|
352
389
|
/** How many traces one read reports, newest first. */
|
|
353
390
|
export declare const MAX_TRACE_ARTIFACTS = 12;
|
|
@@ -366,6 +403,17 @@ export declare const MAX_TRACED_BODY_CHARS = 300;
|
|
|
366
403
|
export declare const MAX_TRACE_NOTE_TESTS = 5;
|
|
367
404
|
/** How many requests the note names per test. */
|
|
368
405
|
export declare const MAX_TRACE_NOTE_REQUESTS = 4;
|
|
406
|
+
/**
|
|
407
|
+
* How many times ONE test may send the same method+path before the note calls it a render loop.
|
|
408
|
+
*
|
|
409
|
+
* xr-molecule-flash-m215 (+27:27 → +29:42): an Editor effect with `[seeded, slug, t]` in its
|
|
410
|
+
* dependency list re-fetched `GET /api/articles/<slug>` 2,106 times in one test. The trace reader
|
|
411
|
+
* caps and de-duplicates its request list, so the note printed four identical `→ 200` lines and
|
|
412
|
+
* the run patched the component's seeding, rebuilt and re-ran before unzipping the trace by hand.
|
|
413
|
+
* Twenty is far above the retries a correct page makes (a few on a flaky call) and far below any
|
|
414
|
+
* render loop.
|
|
415
|
+
*/
|
|
416
|
+
export declare const TRACE_NOTE_LOOP_REPEATS = 20;
|
|
369
417
|
/** How long a URL the note prints, before the middle is cut. */
|
|
370
418
|
export declare const TRACE_NOTE_URL_CHARS = 96;
|
|
371
419
|
/** How much of the sent body the note prints — enough to see a `{"data":…}` wrapper. */
|
|
@@ -511,6 +559,17 @@ export interface TraceRunReport {
|
|
|
511
559
|
* Empty when the caller could not read the app's config.
|
|
512
560
|
*/
|
|
513
561
|
authEndpoints?: readonly string[];
|
|
562
|
+
/**
|
|
563
|
+
* The app source files whose code sends a repeated request, read by the caller from the
|
|
564
|
+
* project (`app/src/**`) before this note is written. A loop's sentence names the component to
|
|
565
|
+
* read instead of leaving the run to search for it (xr-molecule-flash-m215's `Editor.tsx`).
|
|
566
|
+
* Empty when the caller could not read the app or no file states the path.
|
|
567
|
+
*/
|
|
568
|
+
callSites?: readonly {
|
|
569
|
+
method: string;
|
|
570
|
+
path: string;
|
|
571
|
+
file: string;
|
|
572
|
+
}[];
|
|
514
573
|
}
|
|
515
574
|
/**
|
|
516
575
|
* Whether a request PATH asks the app to mint a credential — a login, a registration, a token
|
|
@@ -624,12 +683,16 @@ export declare function hiddenBoxNote(items: readonly TraceArtifact[]): string |
|
|
|
624
683
|
* config names; the traces add what their own requests show) and never assumed to be `/api`.
|
|
625
684
|
*
|
|
626
685
|
* A 401/403 on the app's own origin is answered from the request's own credential record instead
|
|
627
|
-
* ({@link authRefusalSentence}):
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* xr-molecule-flash-
|
|
686
|
+
* ({@link authRefusalSentence}): a header the API then refused moves the answer to the server. No
|
|
687
|
+
* header is read together with the trace's session evidence — the failing test's identity, a
|
|
688
|
+
* credential exchange it answered 2xx, a request the client already attached a token to — and only
|
|
689
|
+
* positive evidence may prescribe the client's token wiring; otherwise the sentence is
|
|
690
|
+
* observational and, for a navigation failure, names the URL transition and the route guard
|
|
691
|
+
* (xr-molecule-flash-m221: a signed-out redirect test's `GET /api/articles/feed → 401` sent the
|
|
692
|
+
* model into replacing a working HTTP client, which lost the session token for ~28 minutes). The
|
|
693
|
+
* handler sentence is kept for the rest — 404/422/500 — and dropped when every failure was a
|
|
694
|
+
* credential refusal, because a trace that proves the header was never sent cannot be explained by
|
|
695
|
+
* the handler. xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
|
|
633
696
|
* `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
|
|
634
697
|
*
|
|
635
698
|
* A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
|
|
@@ -703,4 +766,5 @@ export declare function diagnosisTraceCommand(args: {
|
|
|
703
766
|
cwd: string;
|
|
704
767
|
readFile: (path: string) => Promise<string>;
|
|
705
768
|
}): Promise<DiagnosisTraceRewrite>;
|
|
769
|
+
export {};
|
|
706
770
|
//# sourceMappingURL=trace-artifacts.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAKH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAA;IACd;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,yFAAyF;IACzF,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;;OAIG;IACH,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,yFAAyF;IACzF,GAAG,EAAE,MAAM,CAAA;IACX,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,2FAA2F;IAC3F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,oBAAoB;IACnC,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,QAAQ,EAAE,YAAY,EAAE,CAAA;CACzB;AAED
|
|
1
|
+
{"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"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"}
|
package/dist/trace-artifacts.js
CHANGED
|
@@ -52,6 +52,17 @@ const MAX_TRACED_WEBSOCKET_CHARS = 600;
|
|
|
52
52
|
export const MAX_TRACE_NOTE_TESTS = 5;
|
|
53
53
|
/** How many requests the note names per test. */
|
|
54
54
|
export const MAX_TRACE_NOTE_REQUESTS = 4;
|
|
55
|
+
/**
|
|
56
|
+
* How many times ONE test may send the same method+path before the note calls it a render loop.
|
|
57
|
+
*
|
|
58
|
+
* xr-molecule-flash-m215 (+27:27 → +29:42): an Editor effect with `[seeded, slug, t]` in its
|
|
59
|
+
* dependency list re-fetched `GET /api/articles/<slug>` 2,106 times in one test. The trace reader
|
|
60
|
+
* caps and de-duplicates its request list, so the note printed four identical `→ 200` lines and
|
|
61
|
+
* the run patched the component's seeding, rebuilt and re-ran before unzipping the trace by hand.
|
|
62
|
+
* Twenty is far above the retries a correct page makes (a few on a flaky call) and far below any
|
|
63
|
+
* render loop.
|
|
64
|
+
*/
|
|
65
|
+
export const TRACE_NOTE_LOOP_REPEATS = 20;
|
|
55
66
|
/** How long a URL the note prints, before the middle is cut. */
|
|
56
67
|
export const TRACE_NOTE_URL_CHARS = 96;
|
|
57
68
|
/** How much of the sent body the note prints — enough to see a `{"data":…}` wrapper. */
|
|
@@ -111,6 +122,7 @@ const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
|
|
|
111
122
|
const MAX_ACTIONS = ${MAX_TRACED_ACTIONS}
|
|
112
123
|
const MAX_BODY = ${MAX_TRACED_BODY_CHARS}
|
|
113
124
|
const MAX_WS = ${MAX_TRACED_WEBSOCKET_CHARS}
|
|
125
|
+
const LOOP_REPEATS = ${TRACE_NOTE_LOOP_REPEATS}
|
|
114
126
|
const MAX_URL = 200
|
|
115
127
|
// A console line that is not an error but still has to reach the note (m148): one a molecule
|
|
116
128
|
// package wrote — it names the package, the \`[molecule/…]\` prefix, or the \`[scope]\` prefix
|
|
@@ -236,6 +248,51 @@ function waitedTextOf(message) {
|
|
|
236
248
|
return quoted(/Expected(?: string)?:\\s*(["'])((?:(?!\\1).)*)\\1/)
|
|
237
249
|
}
|
|
238
250
|
|
|
251
|
+
// Whether a response body CARRIES an awaited text as a value of its own rather than as a substring.
|
|
252
|
+
// m208's pagination assertion waited for the page-link text "2", and that one character occurs
|
|
253
|
+
// inside every tag name the tags response returned (\`pag1792394785820\`): the note led with
|
|
254
|
+
// "The response GET /api/tags contained \\"2\\".", a fact with no lead in it, and the trace row that
|
|
255
|
+
// did explain the failure (a page size of 20 where the suite paginates 10) came after it. A
|
|
256
|
+
// response counts only when the text is a whole JSON string (or number) value it returned, or a
|
|
257
|
+
// word with a non-alphanumeric on both sides. A text of 2 characters or fewer, or one made of
|
|
258
|
+
// digits only, is not a word — it must be a whole JSON value, or no response is named at all and
|
|
259
|
+
// the note leads with its next finding.
|
|
260
|
+
function responseCarriesText(body, text) {
|
|
261
|
+
if (!body || !text) return false
|
|
262
|
+
if (jsonValueEquals(body, text)) return true
|
|
263
|
+
if (text.length <= 2 || /^[0-9]+$/.test(text)) return false
|
|
264
|
+
const alnum = function (ch) { return ch !== '' && /[A-Za-z0-9]/.test(ch) }
|
|
265
|
+
let at = body.indexOf(text)
|
|
266
|
+
while (at !== -1) {
|
|
267
|
+
const before = at === 0 ? '' : body[at - 1]
|
|
268
|
+
const after = at + text.length >= body.length ? '' : body[at + text.length]
|
|
269
|
+
if (!alnum(before) && !alnum(after)) return true
|
|
270
|
+
at = body.indexOf(text, at + 1)
|
|
271
|
+
}
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Whether the body is JSON that holds \`text\` as a whole string value, or as a number whose written
|
|
276
|
+
// form is \`text\` (\`{"page":2}\` carries "2"). A body the trace read cut short does not parse and so
|
|
277
|
+
// proves nothing: only the word-bounded reading above is left, and a short text gets no match.
|
|
278
|
+
function jsonValueEquals(body, text) {
|
|
279
|
+
let parsed
|
|
280
|
+
try { parsed = JSON.parse(body) } catch (e) { return false }
|
|
281
|
+
const walk = function (value, depth) {
|
|
282
|
+
if (depth > 8 || value === null || value === undefined) return false
|
|
283
|
+
if (typeof value === 'string') return value === text
|
|
284
|
+
if (typeof value === 'number') return String(value) === text
|
|
285
|
+
if (Array.isArray(value)) {
|
|
286
|
+
for (let i = 0; i < value.length; i++) if (walk(value[i], depth + 1)) return true
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
if (typeof value !== 'object') return false
|
|
290
|
+
for (const key of Object.keys(value)) if (walk(value[key], depth + 1)) return true
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
293
|
+
return walk(parsed, 0)
|
|
294
|
+
}
|
|
295
|
+
|
|
239
296
|
// The attributes of one serialized element's opening tag (\`class="a b" id="c"\`). The trace's
|
|
240
297
|
// snapshots carry them as an object already; the ASSERTION's error prints the tag as text.
|
|
241
298
|
function attrsOf(source) {
|
|
@@ -811,6 +868,64 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
811
868
|
/^\\/icons\\//.test(p) ||
|
|
812
869
|
/^\\/(?:apple-touch-icon|og-image|splash)[^/]*(?:[?#]|$)/.test(p)
|
|
813
870
|
}
|
|
871
|
+
// The request's identity for the loop count and the action's own list: the path without its
|
|
872
|
+
// query, so 2,106 GETs of the same article are ONE group.
|
|
873
|
+
function requestPathOf(r) {
|
|
874
|
+
return String(r.path).split(/[?#]/)[0]
|
|
875
|
+
}
|
|
876
|
+
// Method+path repeated at least LOOP_REPEATS times by ONE page: a render loop, not a retry
|
|
877
|
+
// (m215). Counted over every network entry of the failing page, BEFORE the per-trace cap and
|
|
878
|
+
// dedupe, so the note can name the real number instead of the four copies the cap kept.
|
|
879
|
+
function repeatedOf(list) {
|
|
880
|
+
const groups = {}
|
|
881
|
+
const order = []
|
|
882
|
+
for (const r of list) {
|
|
883
|
+
if (shellRequest(r)) continue
|
|
884
|
+
const p = requestPathOf(r)
|
|
885
|
+
const key = r.method + ' ' + p
|
|
886
|
+
let group = groups[key]
|
|
887
|
+
if (group === undefined) {
|
|
888
|
+
group = { method: r.method, path: p, count: 0, status: r.status, type: r.type || undefined, first: undefined, last: undefined }
|
|
889
|
+
groups[key] = group
|
|
890
|
+
order.push(key)
|
|
891
|
+
}
|
|
892
|
+
group.count += 1
|
|
893
|
+
group.status = r.status
|
|
894
|
+
if (typeof r.startedAt === 'number') {
|
|
895
|
+
if (group.first === undefined || r.startedAt < group.first) group.first = r.startedAt
|
|
896
|
+
if (group.last === undefined || r.startedAt > group.last) group.last = r.startedAt
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const out = []
|
|
900
|
+
for (const key of order) {
|
|
901
|
+
const group = groups[key]
|
|
902
|
+
if (group.count < LOOP_REPEATS) continue
|
|
903
|
+
out.push({
|
|
904
|
+
method: group.method,
|
|
905
|
+
path: group.path,
|
|
906
|
+
count: group.count,
|
|
907
|
+
...(group.status > 0 ? { status: group.status } : {}),
|
|
908
|
+
...(group.type ? { type: group.type } : {}),
|
|
909
|
+
...(group.first !== undefined && group.last !== undefined
|
|
910
|
+
? { ms: Math.max(0, Math.round(group.last - group.first)) }
|
|
911
|
+
: {}),
|
|
912
|
+
})
|
|
913
|
+
}
|
|
914
|
+
out.sort(function (a, b) { return b.count - a.count })
|
|
915
|
+
return out.slice(0, 3)
|
|
916
|
+
}
|
|
917
|
+
// One entry per method+path, in recording order: a loop's four copies are one line in the row.
|
|
918
|
+
function dedupeByPath(list) {
|
|
919
|
+
const seen = {}
|
|
920
|
+
const out = []
|
|
921
|
+
for (const r of list) {
|
|
922
|
+
const key = r.method + ' ' + requestPathOf(r)
|
|
923
|
+
if (seen[key] === true) continue
|
|
924
|
+
seen[key] = true
|
|
925
|
+
out.push(r)
|
|
926
|
+
}
|
|
927
|
+
return out
|
|
928
|
+
}
|
|
814
929
|
// A failed request: a 4xx/5xx, or no response at all WITH Playwright's error text (a refused
|
|
815
930
|
// connection). No response and no text is an aborted/pending request, not a failure. A 404 on
|
|
816
931
|
// the page shell is excepted, for the reason above.
|
|
@@ -964,6 +1079,12 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
964
1079
|
// carries tag names in its \`tagList\`, while the tags response is the collection whose membership
|
|
965
1080
|
// the assertion tests.
|
|
966
1081
|
//
|
|
1082
|
+
// The text must be a VALUE the response held, not a substring of one (m208): a one-character
|
|
1083
|
+
// awaited text ("2", the page link the pagination assertion waits for) occurs inside every
|
|
1084
|
+
// \`pag…\` tag name, and the note led with "The response GET /api/tags contained \\"2\\"." while the
|
|
1085
|
+
// row that explained the failure said the page size was 20. {@link responseCarriesText} decides,
|
|
1086
|
+
// and when no response holds it that way no response is named.
|
|
1087
|
+
//
|
|
967
1088
|
// The "page drops it before rendering" claim is made only when the trace PROVES it: a successful
|
|
968
1089
|
// GET of the collection that STARTED AFTER the last navigation, and the awaited text absent from
|
|
969
1090
|
// the page's own failure snapshot. A creation POST says nothing about what a collection render
|
|
@@ -984,7 +1105,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
984
1105
|
if (r.type !== 'fetch' && r.type !== 'xhr') continue
|
|
985
1106
|
if (r.status < 200 || r.status >= 400) continue
|
|
986
1107
|
const got = gotText(buf, entries, r)
|
|
987
|
-
if (!got || got
|
|
1108
|
+
if (!got || !responseCarriesText(got, waitedText)) continue
|
|
988
1109
|
if (best === null || got.length < best.got.length) best = { request: r, got: got }
|
|
989
1110
|
// The collection read the claim may rest on: a GET that started after the last navigation
|
|
990
1111
|
// (a request that started before it belongs to the page load, not to the failure).
|
|
@@ -1043,7 +1164,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1043
1164
|
api: lastAction.api,
|
|
1044
1165
|
start: lastAction.start,
|
|
1045
1166
|
end: lastAction.end,
|
|
1046
|
-
requests: actionIndexes.slice(0, MAX_NOTE).map(function (
|
|
1167
|
+
requests: dedupeByPath(actionIndexes.map(function (i) { return requests[i] })).slice(0, MAX_NOTE).map(function (r) { return wire(r) }),
|
|
1047
1168
|
...(pageBefore === undefined ? {} : { pageBefore: pageBefore }),
|
|
1048
1169
|
...(pageAfter === undefined ? {} : { pageAfter: pageAfter }),
|
|
1049
1170
|
}
|
|
@@ -1238,6 +1359,8 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1238
1359
|
hiddenBox === null
|
|
1239
1360
|
)
|
|
1240
1361
|
continue
|
|
1362
|
+
// Counted over the page's EVERY network entry, before the cap and dedupe above (m215).
|
|
1363
|
+
const repeated = repeatedOf(requests)
|
|
1241
1364
|
process.stdout.write('TRACE ' + JSON.stringify({
|
|
1242
1365
|
dir: path.relative(ROOT, path.dirname(one.file)),
|
|
1243
1366
|
spec: spec,
|
|
@@ -1248,6 +1371,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1248
1371
|
// tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
|
|
1249
1372
|
// plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
|
|
1250
1373
|
requests: chosen.slice(0, MAX_REQUESTS),
|
|
1374
|
+
...(repeated.length === 0 ? {} : { repeated: repeated }),
|
|
1251
1375
|
...(missingText === null ? {} : { missingText: missingText }),
|
|
1252
1376
|
...(readContradiction === null ? {} : { readContradiction: readContradiction }),
|
|
1253
1377
|
...(hiddenBox === null ? {} : { hiddenBox: hiddenBox }),
|
|
@@ -1396,6 +1520,38 @@ function parseLastAction(value) {
|
|
|
1396
1520
|
...(typeof raw.pageAfter === 'string' ? { pageAfter: raw.pageAfter } : {}),
|
|
1397
1521
|
};
|
|
1398
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The repeated method+path groups out of a `TRACE` line's json.
|
|
1525
|
+
*
|
|
1526
|
+
* @param value - The line's `repeated`.
|
|
1527
|
+
* @returns The groups, most-repeated first, as the reader printed them.
|
|
1528
|
+
*/
|
|
1529
|
+
function parseRepeated(value) {
|
|
1530
|
+
if (!Array.isArray(value))
|
|
1531
|
+
return [];
|
|
1532
|
+
const out = [];
|
|
1533
|
+
for (const entry of value) {
|
|
1534
|
+
if (typeof entry !== 'object' || entry === null)
|
|
1535
|
+
continue;
|
|
1536
|
+
const raw = entry;
|
|
1537
|
+
if (typeof raw.method !== 'string' || typeof raw.path !== 'string')
|
|
1538
|
+
continue;
|
|
1539
|
+
const count = Number(raw.count);
|
|
1540
|
+
if (!Number.isFinite(count) || count < 2)
|
|
1541
|
+
continue;
|
|
1542
|
+
out.push({
|
|
1543
|
+
method: raw.method,
|
|
1544
|
+
path: raw.path,
|
|
1545
|
+
count,
|
|
1546
|
+
...(Number.isFinite(Number(raw.status)) && Number(raw.status) > 0
|
|
1547
|
+
? { status: Number(raw.status) }
|
|
1548
|
+
: {}),
|
|
1549
|
+
...(typeof raw.type === 'string' && raw.type ? { type: raw.type } : {}),
|
|
1550
|
+
...(Number.isFinite(Number(raw.ms)) && Number(raw.ms) >= 0 ? { ms: Number(raw.ms) } : {}),
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
return out;
|
|
1554
|
+
}
|
|
1399
1555
|
/**
|
|
1400
1556
|
* The traces {@link traceArtifactsScript} printed, parsed.
|
|
1401
1557
|
*
|
|
@@ -1420,6 +1576,7 @@ export function parseTraceArtifacts(stdout) {
|
|
|
1420
1576
|
const missingText = parseMissingText(raw.missingText);
|
|
1421
1577
|
const readContradiction = parseReadContradiction(raw.readContradiction);
|
|
1422
1578
|
const hiddenBox = parseHiddenBox(raw.hiddenBox);
|
|
1579
|
+
const repeated = parseRepeated(raw.repeated);
|
|
1423
1580
|
const otherContexts = Array.isArray(raw.otherContexts)
|
|
1424
1581
|
? raw.otherContexts
|
|
1425
1582
|
.filter((c) => typeof c === 'object' && c !== null)
|
|
@@ -1458,6 +1615,7 @@ export function parseTraceArtifacts(stdout) {
|
|
|
1458
1615
|
...(missingText === null ? {} : { missingText }),
|
|
1459
1616
|
...(readContradiction === null ? {} : { readContradiction }),
|
|
1460
1617
|
...(hiddenBox === null ? {} : { hiddenBox }),
|
|
1618
|
+
...(repeated.length === 0 ? {} : { repeated }),
|
|
1461
1619
|
});
|
|
1462
1620
|
}
|
|
1463
1621
|
catch (_error) {
|
|
@@ -1529,6 +1687,109 @@ function requestWithBody(request, gotChars) {
|
|
|
1529
1687
|
const got = request.got ? ` got ${bodyLine(redactSecrets(request.got), gotChars)}` : '';
|
|
1530
1688
|
return `${requestLabel(request)} → ${status}${sent}${got}`;
|
|
1531
1689
|
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Whether two request paths name the same route: equal, or a `:param` segment on either side
|
|
1692
|
+
* matching the other's value (the app's code writes `/articles/:slug`, the trace records
|
|
1693
|
+
* `/articles/test-article-1`).
|
|
1694
|
+
*
|
|
1695
|
+
* @param a - One path.
|
|
1696
|
+
* @param b - The other.
|
|
1697
|
+
* @returns True when they name the same route.
|
|
1698
|
+
*/
|
|
1699
|
+
function sameTracePath(a, b) {
|
|
1700
|
+
const A = a.split('/').filter(Boolean);
|
|
1701
|
+
const B = b.split('/').filter(Boolean);
|
|
1702
|
+
return (A.length === B.length &&
|
|
1703
|
+
A.every((segment, i) => segment === B[i] || segment.startsWith(':') || B[i].startsWith(':')));
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* The requests of one trace, one entry per method+path, with the trace's own repeat count.
|
|
1707
|
+
*
|
|
1708
|
+
* A loop is why this exists: the reader keeps at most four copies of a method+path, so the row
|
|
1709
|
+
* must print ONE line with the real number (m215's `×2106`) rather than four identical lines.
|
|
1710
|
+
*
|
|
1711
|
+
* @param requests - The requests to print, in the note's own order.
|
|
1712
|
+
* @param repeated - The trace's repeated groups ({@link TraceArtifact.repeated}).
|
|
1713
|
+
* @returns One entry per method+path, first occurrence first.
|
|
1714
|
+
*/
|
|
1715
|
+
function collapseRequests(requests, repeated) {
|
|
1716
|
+
const counts = new Map();
|
|
1717
|
+
for (const group of repeated ?? [])
|
|
1718
|
+
counts.set(`${group.method} ${group.path}`, {
|
|
1719
|
+
count: group.count,
|
|
1720
|
+
...(group.ms === undefined ? {} : { ms: group.ms }),
|
|
1721
|
+
});
|
|
1722
|
+
const out = [];
|
|
1723
|
+
const seen = new Map();
|
|
1724
|
+
for (const request of requests) {
|
|
1725
|
+
const key = `${request.method} ${request.path}`;
|
|
1726
|
+
const existing = seen.get(key);
|
|
1727
|
+
if (existing !== undefined) {
|
|
1728
|
+
// The trace's own count already includes every copy; only a group the reader did not count
|
|
1729
|
+
// is counted here from the copies it kept.
|
|
1730
|
+
if (!counts.has(key))
|
|
1731
|
+
existing.count += 1;
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
const traced = counts.get(key);
|
|
1735
|
+
const entry = {
|
|
1736
|
+
request,
|
|
1737
|
+
count: Math.max(traced?.count ?? 1, 1),
|
|
1738
|
+
...(traced?.ms === undefined ? {} : { ms: traced.ms }),
|
|
1739
|
+
};
|
|
1740
|
+
seen.set(key, entry);
|
|
1741
|
+
out.push(entry);
|
|
1742
|
+
}
|
|
1743
|
+
return out;
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* One collapsed request as the note writes it: `GET /api/articles/x → 200 ×2106 (in 4.1 s)`.
|
|
1747
|
+
*
|
|
1748
|
+
* The count is printed only when the page sent the request more than once, so an ordinary row
|
|
1749
|
+
* reads exactly as before.
|
|
1750
|
+
*
|
|
1751
|
+
* @param shown - The request and its count.
|
|
1752
|
+
* @param gotChars - How much of the response body to print, when the body matters.
|
|
1753
|
+
* @returns The request's clause.
|
|
1754
|
+
*/
|
|
1755
|
+
function shownRequest(shown, gotChars) {
|
|
1756
|
+
const line = gotChars === undefined ? requestLine(shown.request) : requestWithBody(shown.request, gotChars);
|
|
1757
|
+
if (shown.count <= 1)
|
|
1758
|
+
return line;
|
|
1759
|
+
return `${line} ×${shown.count}${shown.ms === undefined ? '' : ` (in ${(shown.ms / 1000).toFixed(1)} s)`}`;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* The sentence a render loop leads the note with.
|
|
1763
|
+
*
|
|
1764
|
+
* The loop is not a flaky API: an effect whose dependency list holds a value that is new on every
|
|
1765
|
+
* render re-fetches forever, and the fix is that dependency list, never the test or the handler.
|
|
1766
|
+
* m215's model found this only by unzipping a trace by hand after a wasted fix and a re-run.
|
|
1767
|
+
*
|
|
1768
|
+
* @param group - The repeated group.
|
|
1769
|
+
* @param file - The app source file whose code sends it, when the caller read one.
|
|
1770
|
+
* @returns The sentence.
|
|
1771
|
+
*/
|
|
1772
|
+
function requestLoopSentence(group, file) {
|
|
1773
|
+
return (`\`${group.method} ${group.path}\` was sent ${group.count.toLocaleString('en-US')} times ` +
|
|
1774
|
+
`in this test — a render loop: an effect whose dependency changes on every render (an ` +
|
|
1775
|
+
`object/array/function created in render, or state the effect itself sets) re-fetches ` +
|
|
1776
|
+
`forever; read ${file === null ? 'the component that fetches it.' : `\`${file}\`.`}`);
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* The app file the caller read for a repeated request, exact path first then a parameter match.
|
|
1780
|
+
*
|
|
1781
|
+
* @param group - The repeated group.
|
|
1782
|
+
* @param callSites - What the caller read from the app's source.
|
|
1783
|
+
* @returns The file, or null when no call stated the path.
|
|
1784
|
+
*/
|
|
1785
|
+
function callSiteOf(group, callSites) {
|
|
1786
|
+
const sites = callSites ?? [];
|
|
1787
|
+
const exact = sites.find((site) => site.method === group.method && site.path === group.path);
|
|
1788
|
+
if (exact !== undefined)
|
|
1789
|
+
return exact.file;
|
|
1790
|
+
const matched = sites.find((site) => site.method === group.method && sameTracePath(site.path, group.path));
|
|
1791
|
+
return matched?.file ?? null;
|
|
1792
|
+
}
|
|
1532
1793
|
/**
|
|
1533
1794
|
* The failing step's own request as the note writes it first: `the failing step sent DELETE
|
|
1534
1795
|
* /api/profiles/x/follow → 200 got {"profile":{…}}`.
|
|
@@ -1968,6 +2229,107 @@ function sameOriginPath(request) {
|
|
|
1968
2229
|
return null;
|
|
1969
2230
|
return raw.replace(/\/+$/, '');
|
|
1970
2231
|
}
|
|
2232
|
+
/** The API prefix a no-response request is judged by when the caller read none from the app. */
|
|
2233
|
+
const DEFAULT_API_PREFIX = '/api';
|
|
2234
|
+
/**
|
|
2235
|
+
* Whether a same-origin path is on the app's own API.
|
|
2236
|
+
*
|
|
2237
|
+
* The caller reads the app's client config (`['/api']`); a trace with no such config is judged by
|
|
2238
|
+
* the conventional `/api` mount. The boundary is explicit so `/apiary` is not a child of `/api`.
|
|
2239
|
+
*
|
|
2240
|
+
* @param path - A same-origin path, without a query or trailing slash.
|
|
2241
|
+
* @param prefixes - The API prefixes the caller read, or empty.
|
|
2242
|
+
* @returns True when the path is under one of them.
|
|
2243
|
+
*/
|
|
2244
|
+
function isApiPath(path, prefixes) {
|
|
2245
|
+
const list = prefixes.length === 0 ? [DEFAULT_API_PREFIX] : prefixes;
|
|
2246
|
+
return list.some((prefix) => {
|
|
2247
|
+
const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
2248
|
+
return path === base || path.startsWith(`${base}/`);
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* The API requests a failing run's traces recorded with NO response, one entry per method+path.
|
|
2253
|
+
*
|
|
2254
|
+
* A request still in flight is not a server failure on its own ({@link unansweredRequest}), and
|
|
2255
|
+
* the note says so. Several API paths left unanswered in one walk is a different fact: the server
|
|
2256
|
+
* stopped answering while the tests ran, and the closing "the cause is in the page" reading is
|
|
2257
|
+
* then false (xr-molecule-flash-m215 +36:28: three API paths got no response and two `page.goto`
|
|
2258
|
+
* calls timed out across a platform restart, and the note sent the run at avatar components and
|
|
2259
|
+
* token signing).
|
|
2260
|
+
*
|
|
2261
|
+
* @param items - The traces the note is about.
|
|
2262
|
+
* @param apiPrefixes - The API prefixes the caller read, or empty.
|
|
2263
|
+
* @returns One entry per distinct method+path, in the order the traces recorded them.
|
|
2264
|
+
*/
|
|
2265
|
+
function unansweredApiRequests(items, apiPrefixes) {
|
|
2266
|
+
const groups = new Map();
|
|
2267
|
+
for (const item of items) {
|
|
2268
|
+
// The step's own requests are also in `item.requests` on a modern trace, but an older reader
|
|
2269
|
+
// put them only on the action: count each method+path once per trace, not once per list.
|
|
2270
|
+
const seen = new Set();
|
|
2271
|
+
for (const request of [...item.requests, ...(item.lastAction?.requests ?? [])]) {
|
|
2272
|
+
if (!unansweredRequest(request))
|
|
2273
|
+
continue;
|
|
2274
|
+
const path = sameOriginPath(request);
|
|
2275
|
+
if (path === null || !isApiPath(path, apiPrefixes))
|
|
2276
|
+
continue;
|
|
2277
|
+
const key = `${request.method} ${path}`;
|
|
2278
|
+
if (seen.has(key))
|
|
2279
|
+
continue;
|
|
2280
|
+
seen.add(key);
|
|
2281
|
+
const known = groups.get(key);
|
|
2282
|
+
if (known === undefined)
|
|
2283
|
+
groups.set(key, { method: request.method, path, count: 1 });
|
|
2284
|
+
else
|
|
2285
|
+
known.count += 1;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
return [...groups.values()];
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Whether the runner reported this traced test's failure as a navigation that ran out of budget.
|
|
2292
|
+
*
|
|
2293
|
+
* The runner's own error names the call (`page.goto`, `page.reload`), and that report is the only
|
|
2294
|
+
* signal used: m153 showed a `Frame.goto` that never finished on a trace whose failure was a 15 s
|
|
2295
|
+
* `page.waitForSelector` timeout (the test was cut off mid-navigation), so an unfinished frame
|
|
2296
|
+
* action alone would call a selector failure a page-load failure.
|
|
2297
|
+
*
|
|
2298
|
+
* @param item - One traced failure.
|
|
2299
|
+
* @param failures - The runner's report, one entry per failing test.
|
|
2300
|
+
* @returns True when this failure is a navigation that never finished.
|
|
2301
|
+
*/
|
|
2302
|
+
function navigationTimedOut(item, failures) {
|
|
2303
|
+
const failure = runFailureOf(item, failures);
|
|
2304
|
+
return failure !== undefined && navigationMethod(failure.operation) !== null;
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* The reading a run gets when the server stopped answering: the API requests with no response and
|
|
2308
|
+
* the page loads that never finished, named once so both the lead and the closing sentence carry
|
|
2309
|
+
* the same fact.
|
|
2310
|
+
*
|
|
2311
|
+
* @param requests - The API requests the traces recorded with no response.
|
|
2312
|
+
* @param navigations - How many traced failures were a navigation that never finished.
|
|
2313
|
+
* @param capital - True to start a sentence, false to continue one.
|
|
2314
|
+
* @returns The sentence, without a trailing period run or a next step.
|
|
2315
|
+
*/
|
|
2316
|
+
function serverSilentReading(requests, navigations, capital) {
|
|
2317
|
+
const shown = requests
|
|
2318
|
+
.slice(0, 3)
|
|
2319
|
+
.map((request) => `${request.method} ${request.path}${request.count > 1 ? ` ×${request.count}` : ''}`);
|
|
2320
|
+
const more = requests.length > shown.length ? ` and ${requests.length - shown.length} more` : '';
|
|
2321
|
+
const asked = requests.length === 0
|
|
2322
|
+
? ''
|
|
2323
|
+
: `${requests.length} API ${requests.length === 1 ? 'request' : 'requests'} got no response` +
|
|
2324
|
+
(shown.length === 0 ? '' : ` (${shown.join(', ')}${more})`);
|
|
2325
|
+
const loads = navigations === 0
|
|
2326
|
+
? ''
|
|
2327
|
+
: `${navigations} page ${navigations === 1 ? 'load' : 'loads'} never finished`;
|
|
2328
|
+
const what = [asked, loads].filter((part) => part !== '').join(' and ');
|
|
2329
|
+
return (`${capital ? 'The' : 'the'} server did not answer: ${what}. That is the app — a restart, a ` +
|
|
2330
|
+
'cold start, or a request the server never finishes — being unavailable while these tests ' +
|
|
2331
|
+
'ran, not page state.');
|
|
2332
|
+
}
|
|
1971
2333
|
/**
|
|
1972
2334
|
* The path segments a request ASKS FOR a credential through — a login, a registration, a token
|
|
1973
2335
|
* endpoint — as opposed to a protected resource that presents one.
|
|
@@ -2049,15 +2411,109 @@ function isCredentialExchange(request, authEndpoints) {
|
|
|
2049
2411
|
return true;
|
|
2050
2412
|
return credentialExchangeBody(request.sent);
|
|
2051
2413
|
}
|
|
2414
|
+
/** Whether two request records from one trace are the same entry (the action's copy and its own). */
|
|
2415
|
+
function sameRequestRecord(left, right) {
|
|
2416
|
+
return (left.method === right.method &&
|
|
2417
|
+
left.path === right.path &&
|
|
2418
|
+
left.status === right.status &&
|
|
2419
|
+
left.startedAt === right.startedAt);
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* The positive sign-in evidence a trace holds for THIS refused request's browser context, or null.
|
|
2423
|
+
*
|
|
2424
|
+
* Two readings, strongest first in the sentence but either is proof a token existed: a credential
|
|
2425
|
+
* exchange the trace answered 2xx (the test's own registration/login), or any request the client
|
|
2426
|
+
* attached an `Authorization` header to. A request from another browser context is not evidence
|
|
2427
|
+
* about this one (m160), and a candidate with a clock is only prior when it started before the
|
|
2428
|
+
* refusal. On a trace without clocks (an older Playwright, a synthetic fixture) the trace-level
|
|
2429
|
+
* fact still stands, and the sentence says what the trace recorded rather than "before".
|
|
2430
|
+
*
|
|
2431
|
+
* @param item - The failing test's trace.
|
|
2432
|
+
* @param request - The refused request.
|
|
2433
|
+
* @param authEndpoints - The endpoints the app's auth config names.
|
|
2434
|
+
* @returns The evidence, or null when the trace proves nothing about a session.
|
|
2435
|
+
*/
|
|
2436
|
+
function signInEvidenceFor(item, request, authEndpoints) {
|
|
2437
|
+
// The action's own request list and the artifact's list overlap; dedupe by record identity.
|
|
2438
|
+
const candidates = [...(item.lastAction?.requests ?? []), ...item.requests];
|
|
2439
|
+
for (const candidate of candidates) {
|
|
2440
|
+
if (sameRequestRecord(candidate, request))
|
|
2441
|
+
continue;
|
|
2442
|
+
if (request.context !== undefined &&
|
|
2443
|
+
candidate.context !== undefined &&
|
|
2444
|
+
candidate.context !== request.context)
|
|
2445
|
+
continue;
|
|
2446
|
+
if (typeof candidate.startedAt === 'number' && typeof request.startedAt === 'number') {
|
|
2447
|
+
if (candidate.startedAt >= request.startedAt)
|
|
2448
|
+
continue;
|
|
2449
|
+
if (candidate.sentAuthorization === true)
|
|
2450
|
+
return { request: candidate, kind: 'authorization', prior: true };
|
|
2451
|
+
if (candidate.status >= 200 &&
|
|
2452
|
+
candidate.status < 300 &&
|
|
2453
|
+
isCredentialExchange(candidate, authEndpoints))
|
|
2454
|
+
return { request: candidate, kind: 'exchange', prior: true };
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
// No clock to order by: the trace still proves the context had a session, never that it was
|
|
2458
|
+
// before this request.
|
|
2459
|
+
if (candidate.sentAuthorization === true)
|
|
2460
|
+
return { request: candidate, kind: 'authorization', prior: false };
|
|
2461
|
+
if (candidate.status >= 200 &&
|
|
2462
|
+
candidate.status < 300 &&
|
|
2463
|
+
isCredentialExchange(candidate, authEndpoints))
|
|
2464
|
+
return { request: candidate, kind: 'exchange', prior: false };
|
|
2465
|
+
}
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* How the signed-in sentence names its evidence: what the trace sent and got, and what that proves.
|
|
2470
|
+
*
|
|
2471
|
+
* @param evidence - The evidence {@link signInEvidenceFor} found.
|
|
2472
|
+
* @returns The clause the sentence embeds.
|
|
2473
|
+
*/
|
|
2474
|
+
function signInEvidenceLine(evidence) {
|
|
2475
|
+
const status = evidence.request.status > 0 ? ` → ${evidence.request.status}` : '';
|
|
2476
|
+
const when = evidence.prior ? ' before this request' : '';
|
|
2477
|
+
return evidence.kind === 'exchange'
|
|
2478
|
+
? `\`${requestLabel(evidence.request)}${status}\` is a sign-in this browser context completed${when}`
|
|
2479
|
+
: `the client attached an \`Authorization\` header to \`${requestLabel(evidence.request)}\` in this browser context${when}`;
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* The route-guard reading for a refused request with no session evidence behind it.
|
|
2483
|
+
*
|
|
2484
|
+
* xr-molecule-flash-m221's whole detour: the test navigates to a protected URL while signed out and
|
|
2485
|
+
* asserts the redirect its route owes. Nothing about the client is wrong; the trace already shows
|
|
2486
|
+
* the URL transition, so the sentence names it and the guard the test's assertion measures.
|
|
2487
|
+
*
|
|
2488
|
+
* @param context - Where the refused request sits ({@link AuthRefusalContext}).
|
|
2489
|
+
* @returns The clause, or '' when the last action was not a navigation.
|
|
2490
|
+
*/
|
|
2491
|
+
function routeGuardSentence(context) {
|
|
2492
|
+
if (context.pageAfter === undefined)
|
|
2493
|
+
return '';
|
|
2494
|
+
if (context.lastAction === undefined)
|
|
2495
|
+
return '';
|
|
2496
|
+
if (!/\.(?:goto|reload|goBack|goForward)$/.test(context.lastAction))
|
|
2497
|
+
return '';
|
|
2498
|
+
const where = context.where === null ? ' The test’s last action' : ` In \`${context.where}\` the last action`;
|
|
2499
|
+
const moved = context.pageBefore !== undefined && context.pageBefore !== context.pageAfter
|
|
2500
|
+
? `moved from ${context.pageBefore} to ${context.pageAfter}`
|
|
2501
|
+
: `stayed at ${context.pageAfter}`;
|
|
2502
|
+
return (`${where} was \`${context.lastAction}\` and the page ${moved}. If the assertion waits for a ` +
|
|
2503
|
+
'redirect (a `toHaveURL` after visiting a protected route), the missing step is that route’s ' +
|
|
2504
|
+
'guard: read where the page or router redirects a visitor with no stored session for that ' +
|
|
2505
|
+
'path, not the HTTP client.');
|
|
2506
|
+
}
|
|
2052
2507
|
/**
|
|
2053
2508
|
* The sentence a 401/403 on the app's own API gets: which half of the wire refused the request.
|
|
2054
2509
|
*
|
|
2055
2510
|
* A fixed "read the handler this route reaches" line is the wrong half for a credential refusal.
|
|
2056
2511
|
* The request's own `Authorization` header — recorded by the trace reader as its scheme WORD, never
|
|
2057
|
-
* its value — decides:
|
|
2058
|
-
*
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2512
|
+
* its value — decides half of it: present means the SERVER refused a token it was sent (scheme or
|
|
2513
|
+
* issuer). Absent means no token reached the server, and the CAUSE is read from the trace's
|
|
2514
|
+
* session evidence ({@link signInEvidenceFor}): only a context that had signed in can have lost a
|
|
2515
|
+
* token on the client, and only then may the note prescribe HTTP-client rewiring. A 403 is a
|
|
2516
|
+
* permission answer, not a credential one, and says so.
|
|
2061
2517
|
*
|
|
2062
2518
|
* xr-molecule-flash-m148 (+29:18): `POST /api/articles/…/favorite → 401 {"errors":{"token":["is
|
|
2063
2519
|
* missing"]}}` was answered with the handler sentence for six local walks, while the trace proved
|
|
@@ -2079,6 +2535,17 @@ function isCredentialExchange(request, authEndpoints) {
|
|
|
2079
2535
|
* categorical handler sentence is gone from the no-header branch too, because a route that
|
|
2080
2536
|
* requires a token where the app allows anonymous access is the handler's own mistake.
|
|
2081
2537
|
*
|
|
2538
|
+
* xr-molecule-flash-m221 (+20:13): `url-navigation.spec.ts` navigates to `/?feed=following` while
|
|
2539
|
+
* SIGNED OUT and asserts the `/login` redirect. Its `GET /api/articles/feed → 401` carried no
|
|
2540
|
+
* header because the test never signed in, and the old categorical sentence — "the client did not
|
|
2541
|
+
* attach the token" — sent the model into replacing a working HTTP client; the replacement lost the
|
|
2542
|
+
* session token for ~28 minutes. So the no-header sentence is OBSERVATIONAL unless the trace holds
|
|
2543
|
+
* positive evidence that this context had a session ({@link signInEvidenceFor}); only then may it
|
|
2544
|
+
* prescribe the client wiring, and it names that evidence and the failing test. Without evidence it
|
|
2545
|
+
* says the refusal is expected for a signed-out test, and for a navigation failure names the URL
|
|
2546
|
+
* transition the trace recorded and the route guard the assertion measures
|
|
2547
|
+
* ({@link routeGuardSentence}).
|
|
2548
|
+
*
|
|
2082
2549
|
* The trace cannot name the file that made the call: a real HAR entry carries the request, response,
|
|
2083
2550
|
* `_resourceType`, `_monotonicTime` and a frame ref, but no initiator stack. So the sentence names
|
|
2084
2551
|
* the shared client and the auth bond instead of guessing a file.
|
|
@@ -2088,10 +2555,14 @@ function isCredentialExchange(request, authEndpoints) {
|
|
|
2088
2555
|
* request that carried no `Authorization` header.
|
|
2089
2556
|
* @param authEndpoints - The endpoints the app's auth config names, so a credential exchange is
|
|
2090
2557
|
* recognised even under a path the segment list does not.
|
|
2558
|
+
* @param context - The failing test's identity, the positive session evidence the trace holds, and
|
|
2559
|
+
* where its last action left the page ({@link AuthRefusalContext}).
|
|
2091
2560
|
* @returns The sentence the note appends.
|
|
2092
2561
|
*/
|
|
2093
|
-
function authRefusalSentence(request, unbondedClient = false, authEndpoints = []) {
|
|
2562
|
+
function authRefusalSentence(request, unbondedClient = false, authEndpoints = [], context = { where: null, signIn: null }) {
|
|
2094
2563
|
const label = requestLabel(request);
|
|
2564
|
+
const where = context.where === null ? '' : `In \`${context.where}\`, `;
|
|
2565
|
+
const evidence = context.signIn;
|
|
2095
2566
|
// The header's scheme, when it parsed; presence alone is still the server's half of the wire.
|
|
2096
2567
|
const carried = request.authorization !== undefined
|
|
2097
2568
|
? `with \`Authorization: ${request.authorization} …\``
|
|
@@ -2120,16 +2591,25 @@ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []
|
|
|
2120
2591
|
'and read the client per request instead of capturing it at setup.'
|
|
2121
2592
|
: '';
|
|
2122
2593
|
if (request.status === 403) {
|
|
2123
|
-
|
|
2124
|
-
|
|
2594
|
+
if (request.sentAuthorization === true)
|
|
2595
|
+
return (`The browser sent \`${label}\` ${carried} and the API answered 403 — the token was ` +
|
|
2125
2596
|
'accepted but this user may not do it: read the route’s role/permission check in your ' +
|
|
2126
|
-
'own source, and the account the session belongs to.'
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
'
|
|
2131
|
-
'
|
|
2132
|
-
|
|
2597
|
+
'own source, and the account the session belongs to.');
|
|
2598
|
+
if (evidence !== null)
|
|
2599
|
+
return (`${where}the browser sent \`${label}\` with no \`Authorization\` header and this context ` +
|
|
2600
|
+
`had already signed in — ${signInEvidenceLine(evidence)} — so a token existed and this ` +
|
|
2601
|
+
'request did not carry it: check that the request goes through the app’s HTTP client ' +
|
|
2602
|
+
'that the auth bond configures, and that the token is stored where the client reads it. ' +
|
|
2603
|
+
'If the request did carry one, the route refuses this user: read the route’s ' +
|
|
2604
|
+
'role/permission check in your own source.' +
|
|
2605
|
+
unbonded);
|
|
2606
|
+
return (`${where}the browser sent \`${label}\` and the API answered 403 — the request carried no ` +
|
|
2607
|
+
'`Authorization` header, so the route refused it without a viewer. If this test is signed ' +
|
|
2608
|
+
"out, that refusal is expected: inspect the page's redirect/guard instead. Diagnose token " +
|
|
2609
|
+
'attachment only if this same page/context had completed sign-in or held a restored session ' +
|
|
2610
|
+
'before this request; otherwise read the route’s role/permission check in your own source.' +
|
|
2611
|
+
routeGuardSentence(context) +
|
|
2612
|
+
unbonded);
|
|
2133
2613
|
}
|
|
2134
2614
|
if (request.sentAuthorization === true) {
|
|
2135
2615
|
return (`The browser sent \`${label}\` ${carried} and the API refused it: the token was sent and ` +
|
|
@@ -2137,14 +2617,34 @@ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []
|
|
|
2137
2617
|
'token’s issuer differs from what the server verifies; read the verify middleware this ' +
|
|
2138
2618
|
'route reaches.');
|
|
2139
2619
|
}
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2620
|
+
// The trace holds positive proof this context had a session: the token exists, this request
|
|
2621
|
+
// did not carry it, and the fix is the client's connection to the app's own token source. The
|
|
2622
|
+
// wording names the API the run needs (m221 +56:20: registering `setAuthTokenSource` is what
|
|
2623
|
+
// finally made the induced regression's tests pass).
|
|
2624
|
+
if (evidence !== null) {
|
|
2625
|
+
return (`${where}the browser sent \`${label}\` with no \`Authorization\` header, and this context ` +
|
|
2626
|
+
`had already signed in — ${signInEvidenceLine(evidence)} — so the token exists and this ` +
|
|
2627
|
+
'request did not carry it. In the HTTP bond, register ' +
|
|
2628
|
+
'`setAuthTokenSource(() => <your existing synchronous in-memory token getter>)` before ' +
|
|
2629
|
+
'requests run, and update that same source on sign-in, session restore and sign-out. Do ' +
|
|
2630
|
+
'not initialize a second auth client to obtain another token: the getter must return the ' +
|
|
2631
|
+
'real token synchronously (`null` when signed out), with the stored session restored before ' +
|
|
2632
|
+
'protected requests.' +
|
|
2633
|
+
(request.sentCookie
|
|
2634
|
+
? ' The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
|
|
2635
|
+
'that is the credential the server did not accept. '
|
|
2636
|
+
: '') +
|
|
2637
|
+
unbonded);
|
|
2638
|
+
}
|
|
2639
|
+
// No session evidence: the refusal may be exactly what the test set up. Observational, and the
|
|
2640
|
+
// route guard is the next read when the failing action was a navigation (m221's whole detour).
|
|
2641
|
+
return (`This request carried no \`Authorization\` header and the API answered ${request.status}. ` +
|
|
2642
|
+
"If this test is signed out, that refusal is expected: inspect the page's redirect/guard " +
|
|
2643
|
+
'instead. Diagnose token attachment only if this same page/context had completed sign-in or ' +
|
|
2644
|
+
'held a restored session before this request.' +
|
|
2645
|
+
routeGuardSentence(context) +
|
|
2146
2646
|
(request.sentCookie
|
|
2147
|
-
? 'The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
|
|
2647
|
+
? ' The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
|
|
2148
2648
|
'that is the credential the server did not accept. '
|
|
2149
2649
|
: '') +
|
|
2150
2650
|
unbonded);
|
|
@@ -2152,18 +2652,21 @@ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []
|
|
|
2152
2652
|
/**
|
|
2153
2653
|
* The note's credential-refusal advice, one sentence per distinct shape — at most two.
|
|
2154
2654
|
*
|
|
2155
|
-
* The shapes are `(status, Authorization scheme, cookie present, credential exchange
|
|
2156
|
-
* walks repeated one shape (`401`, no header) on every failing test, and
|
|
2157
|
-
* row would bury the fact rather than state it. A login and a protected
|
|
2158
|
-
* header are DIFFERENT shapes — one is the credentials being checked,
|
|
2159
|
-
*
|
|
2655
|
+
* The shapes are `(status, Authorization scheme, cookie present, credential exchange, session
|
|
2656
|
+
* evidence)`: m148's ten walks repeated one shape (`401`, no header) on every failing test, and
|
|
2657
|
+
* repeating its sentence per row would bury the fact rather than state it. A login and a protected
|
|
2658
|
+
* request that both carry no header are DIFFERENT shapes — one is the credentials being checked,
|
|
2659
|
+
* the other is a request that presented no token (m165). A protected request WITH session evidence
|
|
2660
|
+
* and one without are different shapes too: only the first can say a token was lost on the client
|
|
2661
|
+
* (m221), and collapsing them would prescribe the HTTP-client rewiring for a signed-out route test.
|
|
2160
2662
|
*
|
|
2161
2663
|
* @param items - The traces the note is about.
|
|
2162
2664
|
* @param authEndpoints - The endpoints the app's auth config names.
|
|
2665
|
+
* @param failures - The runner's report, so the sentence names the failing test it is about.
|
|
2163
2666
|
* @returns The sentences to append (each led by a space), or '' when no same-origin 401/403 was
|
|
2164
2667
|
* traced.
|
|
2165
2668
|
*/
|
|
2166
|
-
function authRefusalAdvice(items, authEndpoints = []) {
|
|
2669
|
+
function authRefusalAdvice(items, authEndpoints = [], failures = []) {
|
|
2167
2670
|
const sentences = [];
|
|
2168
2671
|
const seen = new Set();
|
|
2169
2672
|
// A page that logged `no client is bonded` explains every request it sent without a
|
|
@@ -2181,11 +2684,22 @@ function authRefusalAdvice(items, authEndpoints = []) {
|
|
|
2181
2684
|
if (isLogoutRequest(request))
|
|
2182
2685
|
continue;
|
|
2183
2686
|
const exchange = isCredentialExchange(request, authEndpoints);
|
|
2184
|
-
const
|
|
2687
|
+
const signIn = exchange
|
|
2688
|
+
? null
|
|
2689
|
+
: request.sentAuthorization === true
|
|
2690
|
+
? null
|
|
2691
|
+
: signInEvidenceFor(item, request, authEndpoints);
|
|
2692
|
+
const key = `${request.status} ${exchange ? 'exchange' : request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''} ${signIn === null ? 'unverified' : `signed-in:${signIn.kind}`}`;
|
|
2185
2693
|
if (seen.has(key))
|
|
2186
2694
|
continue;
|
|
2187
2695
|
seen.add(key);
|
|
2188
|
-
sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints
|
|
2696
|
+
sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints, {
|
|
2697
|
+
where: item.spec === null ? null : failureWhereOf(item, failures),
|
|
2698
|
+
signIn,
|
|
2699
|
+
pageBefore: item.lastAction?.pageBefore,
|
|
2700
|
+
pageAfter: item.lastAction?.pageAfter,
|
|
2701
|
+
lastAction: item.lastAction?.api,
|
|
2702
|
+
}));
|
|
2189
2703
|
if (sentences.length === 2)
|
|
2190
2704
|
return ` ${sentences.join(' ')}`;
|
|
2191
2705
|
}
|
|
@@ -2773,12 +3287,16 @@ export function hiddenBoxNote(items) {
|
|
|
2773
3287
|
* config names; the traces add what their own requests show) and never assumed to be `/api`.
|
|
2774
3288
|
*
|
|
2775
3289
|
* A 401/403 on the app's own origin is answered from the request's own credential record instead
|
|
2776
|
-
* ({@link authRefusalSentence}):
|
|
2777
|
-
*
|
|
2778
|
-
*
|
|
2779
|
-
*
|
|
2780
|
-
*
|
|
2781
|
-
* xr-molecule-flash-
|
|
3290
|
+
* ({@link authRefusalSentence}): a header the API then refused moves the answer to the server. No
|
|
3291
|
+
* header is read together with the trace's session evidence — the failing test's identity, a
|
|
3292
|
+
* credential exchange it answered 2xx, a request the client already attached a token to — and only
|
|
3293
|
+
* positive evidence may prescribe the client's token wiring; otherwise the sentence is
|
|
3294
|
+
* observational and, for a navigation failure, names the URL transition and the route guard
|
|
3295
|
+
* (xr-molecule-flash-m221: a signed-out redirect test's `GET /api/articles/feed → 401` sent the
|
|
3296
|
+
* model into replacing a working HTTP client, which lost the session token for ~28 minutes). The
|
|
3297
|
+
* handler sentence is kept for the rest — 404/422/500 — and dropped when every failure was a
|
|
3298
|
+
* credential refusal, because a trace that proves the header was never sent cannot be explained by
|
|
3299
|
+
* the handler. xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
|
|
2782
3300
|
* `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
|
|
2783
3301
|
*
|
|
2784
3302
|
* A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
|
|
@@ -2859,7 +3377,7 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
2859
3377
|
// Every recorded failure is the logout endpoint answering 4xx: the row's own sentence explains
|
|
2860
3378
|
// it, and neither the handler lead nor the credential reading applies (m156 +24:02).
|
|
2861
3379
|
const onlyLogoutFailures = failedRequests.length > 0 && failedRequests.every((request) => isLogoutRequest(request));
|
|
2862
|
-
const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? []);
|
|
3380
|
+
const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? [], failures);
|
|
2863
3381
|
// Set-up traffic the trace shows going to an origin other than the app's (m165): the rows it
|
|
2864
3382
|
// creates are not in this app's database, so the note says it before any app-side reading.
|
|
2865
3383
|
const foreignOrigins = foreignOriginApiRequests(traced, apiPrefixes);
|
|
@@ -2879,6 +3397,18 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
2879
3397
|
...new Set(stalls.flatMap((stall) => (stall === null ? [] : [stall.reported]))),
|
|
2880
3398
|
];
|
|
2881
3399
|
const anyStalled = stalls.some((stall) => stall !== null);
|
|
3400
|
+
// The server stopped answering while the tests ran (m215): several API requests with no
|
|
3401
|
+
// response, or a navigation that never finished. A single request still in flight is not this
|
|
3402
|
+
// (m112), and the note must never close with "the cause is in the page" beside it — the run
|
|
3403
|
+
// then rewrites the page while the API was the thing that never answered.
|
|
3404
|
+
const silentApiRequests = unansweredApiRequests(traced, apiPrefixes);
|
|
3405
|
+
const silentNavigations = traced.filter((item) => navigationTimedOut(item, failures)).length;
|
|
3406
|
+
const serverSilent = silentApiRequests.length >= 2 || silentNavigations > 0;
|
|
3407
|
+
// A single unanswered request stays what it always was (m112). Two or more distinct API paths
|
|
3408
|
+
// are a fact about the server, and it leads the note before any row.
|
|
3409
|
+
const serverSilentLead = silentApiRequests.length < 2
|
|
3410
|
+
? ''
|
|
3411
|
+
: `${serverSilentReading(silentApiRequests, silentNavigations, true)}\n\n`;
|
|
2882
3412
|
const rows = [];
|
|
2883
3413
|
// Rows whose failing action sent nothing and left the page on a new route: the page is still
|
|
2884
3414
|
// showing the previous route's data. Counted so the note can say it once for the whole run.
|
|
@@ -2943,11 +3473,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
2943
3473
|
parts.push(`${head}, and it sent no request`);
|
|
2944
3474
|
}
|
|
2945
3475
|
else {
|
|
2946
|
-
const shown = stepRequests.slice(0, room);
|
|
3476
|
+
const shown = collapseRequests(stepRequests, item.repeated).slice(0, room);
|
|
2947
3477
|
room -= shown.length;
|
|
2948
3478
|
// The response body is the point (m148: the register `POST /api/users` answered 201 and
|
|
2949
|
-
// the page rendered signed-out anyway) — printed whatever the status.
|
|
2950
|
-
|
|
3479
|
+
// the page rendered signed-out anyway) — printed whatever the status. A method+path the
|
|
3480
|
+
// page sent many times prints ONCE with its count (m215's four identical `→ 200` lines).
|
|
3481
|
+
parts.push(`${head}, and it sent ${shown.map((entry) => shownRequest(entry, TRACE_NOTE_LEAD_GOT_CHARS)).join('; ')}`);
|
|
2951
3482
|
}
|
|
2952
3483
|
// What the page did next: a navigation shows in the main frame's URL even when the app moved
|
|
2953
3484
|
// client-side and the network log recorded no document request.
|
|
@@ -2971,13 +3502,16 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
2971
3502
|
parts.push(logoutIgnoredSentence(stepLast, step.pageAfter));
|
|
2972
3503
|
}
|
|
2973
3504
|
else if (lead !== undefined) {
|
|
2974
|
-
|
|
3505
|
+
const [leadShown] = collapseRequests([lead], item.repeated);
|
|
3506
|
+
parts.push(leadShown === undefined
|
|
3507
|
+
? leadRequestLine(lead)
|
|
3508
|
+
: `the failing step sent ${shownRequest(leadShown, TRACE_NOTE_LEAD_GOT_CHARS)}`);
|
|
2975
3509
|
room -= 1;
|
|
2976
3510
|
}
|
|
2977
3511
|
if (failed.length > 0) {
|
|
2978
|
-
const shown = failed.slice(0, room);
|
|
3512
|
+
const shown = collapseRequests(failed, item.repeated).slice(0, room);
|
|
2979
3513
|
room -= shown.length;
|
|
2980
|
-
parts.push(`failed: ${shown.map(
|
|
3514
|
+
parts.push(`failed: ${shown.map((entry) => shownRequest(entry)).join('; ')}`);
|
|
2981
3515
|
}
|
|
2982
3516
|
if (stalled.length > 0 && room > 0) {
|
|
2983
3517
|
const shown = stalled.slice(0, room);
|
|
@@ -3004,7 +3538,10 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
3004
3538
|
const label = step !== undefined && !stalledNav && stepRequests.length === 0
|
|
3005
3539
|
? 'also answered (started before the action)'
|
|
3006
3540
|
: 'answered';
|
|
3007
|
-
parts.push(`${label}: ${answered
|
|
3541
|
+
parts.push(`${label}: ${collapseRequests(answered, item.repeated)
|
|
3542
|
+
.slice(0, room)
|
|
3543
|
+
.map((entry) => shownRequest(entry))
|
|
3544
|
+
.join('; ')}`);
|
|
3008
3545
|
}
|
|
3009
3546
|
if (item.console.length > 0)
|
|
3010
3547
|
parts.push(`console: ${item.console
|
|
@@ -3145,7 +3682,20 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
3145
3682
|
.map((reset) => [`${reset.request.method} ${reset.request.path}\u0000${reset.field}`, reset])).values(),
|
|
3146
3683
|
];
|
|
3147
3684
|
const formResetLead = formResets.length === 0 ? '' : `${formResets.slice(0, 2).map(formResetSentence).join(' ')}\n\n`;
|
|
3148
|
-
|
|
3685
|
+
// One method+path the page sent twenty times or more in a single test is a render loop, not a
|
|
3686
|
+
// flaky API, and it LEADS the note (m215: 2,106 GETs printed as four identical `→ 200` lines
|
|
3687
|
+
// sent the run at the wrong component; it found the loop only by unzipping the trace by hand).
|
|
3688
|
+
// The most-repeated group wins when a run has more than one.
|
|
3689
|
+
const loopGroup = traced
|
|
3690
|
+
.flatMap((item) => item.repeated ?? [])
|
|
3691
|
+
.filter((group) => group.count >= TRACE_NOTE_LOOP_REPEATS)
|
|
3692
|
+
.sort((left, right) => right.count - left.count)[0];
|
|
3693
|
+
const loopLead = loopGroup === undefined
|
|
3694
|
+
? ''
|
|
3695
|
+
: `${requestLoopSentence(loopGroup, callSiteOf(loopGroup, report.callSites))}\n\n`;
|
|
3696
|
+
return (serverSilentLead +
|
|
3697
|
+
loopLead +
|
|
3698
|
+
readContradictionLead +
|
|
3149
3699
|
formResetLead +
|
|
3150
3700
|
missingTextLead +
|
|
3151
3701
|
transportLead +
|
|
@@ -3204,13 +3754,20 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
3204
3754
|
? // The trace's own snapshot explains why the element Playwright resolved has no box:
|
|
3205
3755
|
// a fact about the element, so it replaces the "look at the page" advice (m197).
|
|
3206
3756
|
hiddenBox
|
|
3207
|
-
:
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3757
|
+
: serverSilent
|
|
3758
|
+
? // The server did not answer (m215): a page-state reading would send the run at
|
|
3759
|
+
// the markup while nothing answered it. Same fact as the lead, and the one action.
|
|
3760
|
+
'No listed request failed — none got a 4xx/5xx or a network error — but ' +
|
|
3761
|
+
`${serverSilentReading(silentApiRequests, silentNavigations, false)} ` +
|
|
3762
|
+
`Re-run only these tests against ${target} (\`--last-failed\`) before changing ` +
|
|
3763
|
+
'any code; change the app only if they fail again with requests that answered.'
|
|
3764
|
+
: 'No listed request failed — none got a 4xx/5xx or a network error — so the cause is in the ' +
|
|
3765
|
+
"page, not the API. Start with what the test's last action sent (the first line of each " +
|
|
3766
|
+
'row) and what the page did next: a request that answered (2xx included) while the page ' +
|
|
3767
|
+
'still did not show the state the assertion waited on is the fact to read — its status ' +
|
|
3768
|
+
'and response body, not the selector. If the request is not the one the test meant — an ' +
|
|
3769
|
+
'unfollow sent where the test clicked follow — the state the action read was stale ' +
|
|
3770
|
+
'before it.') +
|
|
3214
3771
|
prefixAdvice +
|
|
3215
3772
|
(wrapped
|
|
3216
3773
|
? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +
|