@molecule/api-ai-tools 1.9.16 → 1.9.19
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 +336 -27
- package/dist/command-position.d.ts +3 -1
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +95 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/output-filter.d.ts +18 -7
- package/dist/output-filter.d.ts.map +1 -1
- package/dist/output-filter.js +46 -18
- package/dist/server-command.d.ts +73 -0
- package/dist/server-command.d.ts.map +1 -0
- package/dist/server-command.js +180 -0
- package/dist/suite-overlap.d.ts +24 -1
- package/dist/suite-overlap.d.ts.map +1 -1
- package/dist/suite-overlap.js +154 -36
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +136 -17
- package/dist/trace-artifacts.d.ts +90 -5
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +513 -27
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utilities.d.ts +75 -9
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +237 -51
- package/package.json +1 -1
package/dist/trace-artifacts.js
CHANGED
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* @module
|
|
28
28
|
*/
|
|
29
29
|
import { locatedCommandSegments, statementArgumentEnd } from './command-position.js';
|
|
30
|
-
import { isDiagnosisRun } from './suite-overlap.js';
|
|
31
30
|
import { isPlaywrightRunCommand, redactSecrets } from './utilities.js';
|
|
32
31
|
/** How many traces one read reports, newest first. */
|
|
33
32
|
export const MAX_TRACE_ARTIFACTS = 12;
|
|
@@ -198,6 +197,32 @@ function messageOf(value) {
|
|
|
198
197
|
return null
|
|
199
198
|
}
|
|
200
199
|
|
|
200
|
+
// A fill/type/selectOption records what the test typed; the note compares it with the body the
|
|
201
|
+
// page submitted (m173: the Settings form sent bio:"" although the test had just typed a value).
|
|
202
|
+
// Only those actions keep it, so a click's selector does not ride every row.
|
|
203
|
+
const FILLING_ACTION = /\\.(?:fill|type|pressSequentially|selectOption)$/
|
|
204
|
+
|
|
205
|
+
// The text a failing assertion waited for, named by its own error: a \`has-text(…)\` CSS
|
|
206
|
+
// pseudo-class, a \`getByText(…)\` locator, or a \`toContainText(…)\` expectation. A strict-mode
|
|
207
|
+
// violation is not this shape — two elements DID match the text — and neither is a wait on a
|
|
208
|
+
// selector with no text argument. The ANSI colouring Playwright leaves in the message is dropped
|
|
209
|
+
// before the read, or the quotes it wraps around a locator would never parse.
|
|
210
|
+
function waitedTextOf(message) {
|
|
211
|
+
if (!message) return null
|
|
212
|
+
const text = String(message).replace(/\\u001b\\[[0-9;]*m/g, '')
|
|
213
|
+
if (text.indexOf('strict mode violation') !== -1) return null
|
|
214
|
+
const quoted = function (pattern) {
|
|
215
|
+
const m = pattern.exec(text)
|
|
216
|
+
return m === null ? null : m[2]
|
|
217
|
+
}
|
|
218
|
+
const hasText = quoted(/has-text\\(\\s*(['"])((?:(?!\\1).)*)\\1/)
|
|
219
|
+
if (hasText !== null) return hasText
|
|
220
|
+
const byText = quoted(/getByText\\(\\s*(['"])((?:(?!\\1).)*)\\1/)
|
|
221
|
+
if (byText !== null) return byText
|
|
222
|
+
if (text.indexOf('toContainText') === -1) return null
|
|
223
|
+
return quoted(/Expected(?: string)?:\\s*(["'])((?:(?!\\1).)*)\\1/)
|
|
224
|
+
}
|
|
225
|
+
|
|
201
226
|
// A request/response body exactly as Playwright stored it: inline on the value, or in a resource
|
|
202
227
|
// entry the value names. Bounded, and binary is dropped — a JSON body is what this is for, and a
|
|
203
228
|
// multipart upload would print bytes.
|
|
@@ -426,14 +451,19 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
426
451
|
if (!entry || entry.type !== 'before') continue
|
|
427
452
|
if (typeof entry.class !== 'string' || typeof entry.method !== 'string') continue
|
|
428
453
|
if (entry.class === 'Test' || typeof entry.startTime !== 'number') continue
|
|
454
|
+
const api = entry.class + '.' + entry.method
|
|
455
|
+
const params = entry.params && typeof entry.params === 'object' ? entry.params : {}
|
|
456
|
+
const filling = FILLING_ACTION.test(api)
|
|
429
457
|
const action = {
|
|
430
|
-
api:
|
|
458
|
+
api: api,
|
|
431
459
|
start: entry.startTime,
|
|
432
460
|
end: typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null,
|
|
433
461
|
context: ctx.id,
|
|
434
462
|
pageId: typeof entry.callId === 'string' ? pageByCall[entry.callId] : undefined,
|
|
435
463
|
frameId: undefined,
|
|
436
464
|
stepId: typeof entry.stepId === 'string' ? entry.stepId : undefined,
|
|
465
|
+
...(filling && typeof params.selector === 'string' ? { selector: params.selector } : {}),
|
|
466
|
+
...(filling && typeof params.value === 'string' ? { value: params.value } : {}),
|
|
437
467
|
}
|
|
438
468
|
if (action.pageId !== undefined) {
|
|
439
469
|
for (const frame of ctx.frames) {
|
|
@@ -459,6 +489,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
459
489
|
let failedStepId = null
|
|
460
490
|
let failedStep = null
|
|
461
491
|
let failedEnd = null
|
|
492
|
+
let failedError = ''
|
|
462
493
|
let failureSpec = null
|
|
463
494
|
let failureLine = 0
|
|
464
495
|
let lastSpec = null
|
|
@@ -468,6 +499,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
468
499
|
if (entry.type === 'after' && entry.error && typeof entry.callId === 'string' && failedStepId === null) {
|
|
469
500
|
failedStepId = entry.callId
|
|
470
501
|
failedEnd = typeof entry.endTime === 'number' ? entry.endTime : null
|
|
502
|
+
failedError = messageOf(entry.error) || ''
|
|
471
503
|
}
|
|
472
504
|
}
|
|
473
505
|
// The fallback location — the last step that names a spec — is read from every member, runner
|
|
@@ -710,6 +742,26 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
710
742
|
if (sent) r.sent = sent
|
|
711
743
|
if (got) r.got = got
|
|
712
744
|
}
|
|
745
|
+
// The text the failing assertion waited for, and a response the SAME page received that carried
|
|
746
|
+
// it (m169). The assertion's own error names the text; the network log says whether it ever
|
|
747
|
+
// reached the browser. Only an answered fetch/xhr can say the API returned it — a 4xx body is an
|
|
748
|
+
// error message, not data. The most focused response wins (smallest body): an articles list also
|
|
749
|
+
// carries tag names in its \`tagList\`, while the tags response is the collection whose membership
|
|
750
|
+
// the assertion tests.
|
|
751
|
+
const waitedText = waitedTextOf(failedError)
|
|
752
|
+
let missingText = null
|
|
753
|
+
if (waitedText !== null && !unattributed) {
|
|
754
|
+
let best = null
|
|
755
|
+
for (const r of requests) {
|
|
756
|
+
if (r.type !== 'fetch' && r.type !== 'xhr') continue
|
|
757
|
+
if (r.status < 200 || r.status >= 400) continue
|
|
758
|
+
const got = gotText(buf, entries, r)
|
|
759
|
+
if (!got || got.indexOf(waitedText) === -1) continue
|
|
760
|
+
if (best === null || got.length < best.got.length) best = { request: r, got: got }
|
|
761
|
+
}
|
|
762
|
+
if (best !== null)
|
|
763
|
+
missingText = { text: waitedText, method: best.request.method, path: best.request.path }
|
|
764
|
+
}
|
|
713
765
|
function wire(r) {
|
|
714
766
|
return {
|
|
715
767
|
method: r.method,
|
|
@@ -881,6 +933,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
881
933
|
// tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
|
|
882
934
|
// plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
|
|
883
935
|
requests: chosen.slice(0, MAX_REQUESTS),
|
|
936
|
+
...(missingText === null ? {} : { missingText: missingText }),
|
|
884
937
|
console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
|
|
885
938
|
actions: actions.slice(-MAX_ACTIONS),
|
|
886
939
|
...(unattributed ? { unattributed: true } : {}),
|
|
@@ -917,6 +970,24 @@ function parseTraceRequest(value) {
|
|
|
917
970
|
...(typeof r.pageId === 'string' && r.pageId ? { pageId: r.pageId } : {}),
|
|
918
971
|
};
|
|
919
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* One `missingText` out of a `TRACE` line's json, or null when the value is not one.
|
|
975
|
+
*
|
|
976
|
+
* @param value - The line's `missingText`.
|
|
977
|
+
* @returns The awaited text and the response that carried it, or null.
|
|
978
|
+
*/
|
|
979
|
+
function parseMissingText(value) {
|
|
980
|
+
if (typeof value !== 'object' || value === null)
|
|
981
|
+
return null;
|
|
982
|
+
const raw = value;
|
|
983
|
+
if (typeof raw.text !== 'string' || raw.text === '')
|
|
984
|
+
return null;
|
|
985
|
+
if (typeof raw.method !== 'string' || raw.method === '')
|
|
986
|
+
return null;
|
|
987
|
+
if (typeof raw.path !== 'string' || raw.path === '')
|
|
988
|
+
return null;
|
|
989
|
+
return { text: raw.text, method: raw.method, path: raw.path };
|
|
990
|
+
}
|
|
920
991
|
/**
|
|
921
992
|
* One `lastAction` out of a `TRACE` line's json, or null when the value is not one.
|
|
922
993
|
*
|
|
@@ -962,6 +1033,7 @@ export function parseTraceArtifacts(stdout) {
|
|
|
962
1033
|
const actions = Array.isArray(raw.actions) ? raw.actions : [];
|
|
963
1034
|
const lead = parseTraceRequest(raw.lead);
|
|
964
1035
|
const lastAction = parseLastAction(raw.lastAction);
|
|
1036
|
+
const missingText = parseMissingText(raw.missingText);
|
|
965
1037
|
const otherContexts = Array.isArray(raw.otherContexts)
|
|
966
1038
|
? raw.otherContexts
|
|
967
1039
|
.filter((c) => typeof c === 'object' && c !== null)
|
|
@@ -992,9 +1064,12 @@ export function parseTraceArtifacts(stdout) {
|
|
|
992
1064
|
...(typeof a.context === 'string' ? { context: a.context } : {}),
|
|
993
1065
|
...(typeof a.pageId === 'string' ? { pageId: a.pageId } : {}),
|
|
994
1066
|
...(typeof a.frameId === 'string' ? { frameId: a.frameId } : {}),
|
|
1067
|
+
...(typeof a.selector === 'string' && a.selector ? { selector: a.selector } : {}),
|
|
1068
|
+
...(typeof a.value === 'string' ? { value: a.value } : {}),
|
|
995
1069
|
})),
|
|
996
1070
|
...(otherContexts.length === 0 ? {} : { otherContexts }),
|
|
997
1071
|
...(raw.unattributed === true ? { unattributed: true } : {}),
|
|
1072
|
+
...(missingText === null ? {} : { missingText }),
|
|
998
1073
|
});
|
|
999
1074
|
}
|
|
1000
1075
|
catch (_error) {
|
|
@@ -1500,6 +1575,87 @@ function sameOriginPath(request) {
|
|
|
1500
1575
|
return null;
|
|
1501
1576
|
return raw.replace(/\/+$/, '');
|
|
1502
1577
|
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The path segments a request ASKS FOR a credential through — a login, a registration, a token
|
|
1580
|
+
* endpoint — as opposed to a protected resource that presents one.
|
|
1581
|
+
*/
|
|
1582
|
+
const CREDENTIAL_EXCHANGE_PATH = /(?:^|\/)(?:login|log-in|signin|sign-in|register|signup|sign-up|token)(?:\/|$)/i;
|
|
1583
|
+
/** The field a credential payload carries: a password, a grant, or a refresh token. */
|
|
1584
|
+
const CREDENTIAL_FIELD = /"(?:password|credentials|grant_type|refresh_token)"\s*:/i;
|
|
1585
|
+
/** The field that pairs with a password in a login/registration body. */
|
|
1586
|
+
const CREDENTIAL_IDENTITY = /"(?:email|username)"\s*:/i;
|
|
1587
|
+
/**
|
|
1588
|
+
* Whether a request's recorded body is a credential payload — the shape a login, a registration
|
|
1589
|
+
* or a token exchange sends, never a protected write (an article, a comment, a profile).
|
|
1590
|
+
*
|
|
1591
|
+
* @param sent - What the request sent, as the trace recorded it (bounded).
|
|
1592
|
+
* @returns True for a body carrying credentials.
|
|
1593
|
+
*/
|
|
1594
|
+
function credentialExchangeBody(sent) {
|
|
1595
|
+
if (!sent)
|
|
1596
|
+
return false;
|
|
1597
|
+
const text = sent.slice(0, MAX_TRACED_BODY_CHARS);
|
|
1598
|
+
// A password alone is a profile write as often as a login; the exchange is a password beside
|
|
1599
|
+
// an identity, or a token grant.
|
|
1600
|
+
if (!CREDENTIAL_FIELD.test(text))
|
|
1601
|
+
return false;
|
|
1602
|
+
return (CREDENTIAL_IDENTITY.test(text) || /"(?:credentials|grant_type|refresh_token)"\s*:/i.test(text));
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Whether a request PATH asks the app to mint a credential — a login, a registration, a token
|
|
1606
|
+
* endpoint — as opposed to a protected resource that presents one.
|
|
1607
|
+
*
|
|
1608
|
+
* This is the path half of {@link isCredentialExchange}, exported so a caller holding only a
|
|
1609
|
+
* `METHOD path` reads the same classification: a Hurl failing entry says
|
|
1610
|
+
* `POST /api/users/login` and nothing about the body, and the whole-suite note must not explain
|
|
1611
|
+
* a wrong-password login that answered 200 as a cookie-jar effect (xr-molecule-flash-m176).
|
|
1612
|
+
*
|
|
1613
|
+
* @param path - The request path (`/api/users/login`); a query or fragment is ignored.
|
|
1614
|
+
* @param authEndpoints - The endpoints the app's auth config names (`/users/login`).
|
|
1615
|
+
* @returns True when the path names a credential exchange.
|
|
1616
|
+
*/
|
|
1617
|
+
export function isCredentialExchangePath(path, authEndpoints = []) {
|
|
1618
|
+
const clean = (path.split(/[?#]/)[0] ?? '').replace(/\/+$/, '');
|
|
1619
|
+
if (clean === '')
|
|
1620
|
+
return false;
|
|
1621
|
+
// The app's own config names it: matched as a path suffix, because an auth config writes the
|
|
1622
|
+
// endpoint relative to its API base (`/users/login`) while the trace records the mounted path
|
|
1623
|
+
// (`/api/users/login`).
|
|
1624
|
+
if (authEndpoints.some((endpoint) => {
|
|
1625
|
+
const named = `/${String(endpoint).replace(/^\/+|\/+$/g, '')}`;
|
|
1626
|
+
return named.length > 1 && (clean === named || clean.endsWith(named));
|
|
1627
|
+
}))
|
|
1628
|
+
return true;
|
|
1629
|
+
return CREDENTIAL_EXCHANGE_PATH.test(clean);
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Whether a refused request is a CREDENTIAL EXCHANGE: the app was asked to mint a token (a login,
|
|
1633
|
+
* a registration, a token endpoint), so it never needed one attached.
|
|
1634
|
+
*
|
|
1635
|
+
* Three readings, weakest last: the app's own auth config names the endpoint (a project may mount
|
|
1636
|
+
* `/session` or `/auth/signin`), the path's own last segments name it (`/users/login`), or the
|
|
1637
|
+
* body is a credential payload (`{"user":{"email":…,"password":…}}` sends a registration).
|
|
1638
|
+
*
|
|
1639
|
+
* xr-molecule-flash-m165 (+10:22): `POST /api/users/login → 401
|
|
1640
|
+
* {"errors":{"credentials":["invalid"]}}` was answered with the missing-token sentence — "the
|
|
1641
|
+
* client did not attach the token … The API handler is not the problem" — for a request whose
|
|
1642
|
+
* whole purpose is to obtain a token. The model read helpers and configs for 40 minutes instead of
|
|
1643
|
+
* the real cause: the suite's setup had seeded `https://api.realworld.show`, not this app.
|
|
1644
|
+
*
|
|
1645
|
+
* @param request - The refused request (401/403).
|
|
1646
|
+
* @param authEndpoints - The endpoints the app's auth config names (`/users/login`).
|
|
1647
|
+
* @returns True when the request is a credential exchange.
|
|
1648
|
+
*/
|
|
1649
|
+
function isCredentialExchange(request, authEndpoints) {
|
|
1650
|
+
if (request.method !== 'POST')
|
|
1651
|
+
return false;
|
|
1652
|
+
const path = sameOriginPath(request);
|
|
1653
|
+
if (path === null)
|
|
1654
|
+
return false;
|
|
1655
|
+
if (isCredentialExchangePath(path, authEndpoints))
|
|
1656
|
+
return true;
|
|
1657
|
+
return credentialExchangeBody(request.sent);
|
|
1658
|
+
}
|
|
1503
1659
|
/**
|
|
1504
1660
|
* The sentence a 401/403 on the app's own API gets: which half of the wire refused the request.
|
|
1505
1661
|
*
|
|
@@ -1521,6 +1677,15 @@ function sameOriginPath(request) {
|
|
|
1521
1677
|
* sent through. When the trace carries that line, the no-header sentence names the ordering fix
|
|
1522
1678
|
* instead of leaving the run to re-derive it.
|
|
1523
1679
|
*
|
|
1680
|
+
* xr-molecule-flash-m165 (+10:22): a `POST /api/users/login → 401
|
|
1681
|
+
* {"errors":{"credentials":["invalid"]}}` is a CREDENTIAL EXCHANGE — the request is how a token is
|
|
1682
|
+
* obtained, so an absent `Authorization` header is the request being what it is, not a client bug.
|
|
1683
|
+
* The note said "the client did not attach the token … The API handler is not the problem" and the
|
|
1684
|
+
* run went looking through the app instead of at the credentials or the setup's API origin. The
|
|
1685
|
+
* exchange branch names the credentials, the account's origin, and the response body; the
|
|
1686
|
+
* categorical handler sentence is gone from the no-header branch too, because a route that
|
|
1687
|
+
* requires a token where the app allows anonymous access is the handler's own mistake.
|
|
1688
|
+
*
|
|
1524
1689
|
* The trace cannot name the file that made the call: a real HAR entry carries the request, response,
|
|
1525
1690
|
* `_resourceType`, `_monotonicTime` and a frame ref, but no initiator stack. So the sentence names
|
|
1526
1691
|
* the shared client and the auth bond instead of guessing a file.
|
|
@@ -1528,14 +1693,30 @@ function sameOriginPath(request) {
|
|
|
1528
1693
|
* @param request - The refused request, a 401 or 403 on the app's own origin.
|
|
1529
1694
|
* @param unbondedClient - Whether the traced page logged `no client is bonded`, which explains a
|
|
1530
1695
|
* request that carried no `Authorization` header.
|
|
1696
|
+
* @param authEndpoints - The endpoints the app's auth config names, so a credential exchange is
|
|
1697
|
+
* recognised even under a path the segment list does not.
|
|
1531
1698
|
* @returns The sentence the note appends.
|
|
1532
1699
|
*/
|
|
1533
|
-
function authRefusalSentence(request, unbondedClient = false) {
|
|
1700
|
+
function authRefusalSentence(request, unbondedClient = false, authEndpoints = []) {
|
|
1534
1701
|
const label = requestLabel(request);
|
|
1535
1702
|
// The header's scheme, when it parsed; presence alone is still the server's half of the wire.
|
|
1536
1703
|
const carried = request.authorization !== undefined
|
|
1537
1704
|
? `with \`Authorization: ${request.authorization} …\``
|
|
1538
1705
|
: 'with an `Authorization` header';
|
|
1706
|
+
// A credential exchange asks the app to MINT a token: no token is expected to be attached, so a
|
|
1707
|
+
// refusal here is about the credentials themselves — never the client's omission (m165).
|
|
1708
|
+
if (isCredentialExchange(request, authEndpoints)) {
|
|
1709
|
+
const got = request.got
|
|
1710
|
+
? ` got ${bodyLine(redactSecrets(request.got), TRACE_NOTE_GOT_CHARS)}`
|
|
1711
|
+
: '';
|
|
1712
|
+
return (`The browser sent \`${label}\`${got} and the API answered ${request.status} — this is a ` +
|
|
1713
|
+
'credential exchange (a login/registration/token endpoint), so the client is not expected ' +
|
|
1714
|
+
'to attach a token: the credentials were rejected. Check that the account the test logs in ' +
|
|
1715
|
+
'with was created on THIS app — the same database and origin this run drives; a user seeded ' +
|
|
1716
|
+
'somewhere else is unknown here — and read the response body’s `errors` (a login answers 401 ' +
|
|
1717
|
+
'`{"errors":{"credentials":["invalid"]}}` for a wrong email/password or a user that does not ' +
|
|
1718
|
+
'exist here).');
|
|
1719
|
+
}
|
|
1539
1720
|
// The console line names the mechanism the no-header branch otherwise has to guess at:
|
|
1540
1721
|
// `getClient()` ran before `setupHttpDefault()` and the token was mirrored into the
|
|
1541
1722
|
// client that later setup replaced (xr-molecule-flash-m156).
|
|
@@ -1573,21 +1754,23 @@ function authRefusalSentence(request, unbondedClient = false) {
|
|
|
1573
1754
|
? 'The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
|
|
1574
1755
|
'that is the credential the server did not accept. '
|
|
1575
1756
|
: '') +
|
|
1576
|
-
'The API handler is not the problem.' +
|
|
1577
1757
|
unbonded);
|
|
1578
1758
|
}
|
|
1579
1759
|
/**
|
|
1580
1760
|
* The note's credential-refusal advice, one sentence per distinct shape — at most two.
|
|
1581
1761
|
*
|
|
1582
|
-
* The shapes are `(status, Authorization scheme, cookie present)`: m148's ten
|
|
1583
|
-
* shape (`401`, no header) on every failing test, and repeating its sentence per
|
|
1584
|
-
* the fact rather than state it.
|
|
1762
|
+
* The shapes are `(status, Authorization scheme, cookie present, credential exchange)`: m148's ten
|
|
1763
|
+
* walks repeated one shape (`401`, no header) on every failing test, and repeating its sentence per
|
|
1764
|
+
* row would bury the fact rather than state it. A login and a protected request that both carry no
|
|
1765
|
+
* header are DIFFERENT shapes — one is the credentials being checked, the other is a token that was
|
|
1766
|
+
* never attached (m165).
|
|
1585
1767
|
*
|
|
1586
1768
|
* @param items - The traces the note is about.
|
|
1769
|
+
* @param authEndpoints - The endpoints the app's auth config names.
|
|
1587
1770
|
* @returns The sentences to append (each led by a space), or '' when no same-origin 401/403 was
|
|
1588
1771
|
* traced.
|
|
1589
1772
|
*/
|
|
1590
|
-
function authRefusalAdvice(items) {
|
|
1773
|
+
function authRefusalAdvice(items, authEndpoints = []) {
|
|
1591
1774
|
const sentences = [];
|
|
1592
1775
|
const seen = new Set();
|
|
1593
1776
|
// A page that logged `no client is bonded` explains every request it sent without a
|
|
@@ -1604,11 +1787,12 @@ function authRefusalAdvice(items) {
|
|
|
1604
1787
|
// ignores it and clears the session, and the page owes the navigation (m156).
|
|
1605
1788
|
if (isLogoutRequest(request))
|
|
1606
1789
|
continue;
|
|
1607
|
-
const
|
|
1790
|
+
const exchange = isCredentialExchange(request, authEndpoints);
|
|
1791
|
+
const key = `${request.status} ${exchange ? 'exchange' : request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
|
|
1608
1792
|
if (seen.has(key))
|
|
1609
1793
|
continue;
|
|
1610
1794
|
seen.add(key);
|
|
1611
|
-
sentences.push(authRefusalSentence(request, unbondedClient));
|
|
1795
|
+
sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints));
|
|
1612
1796
|
if (sentences.length === 2)
|
|
1613
1797
|
return ` ${sentences.join(' ')}`;
|
|
1614
1798
|
}
|
|
@@ -1674,6 +1858,117 @@ function tracedApiPrefixes(items) {
|
|
|
1674
1858
|
}
|
|
1675
1859
|
return [...prefixes].sort();
|
|
1676
1860
|
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Every API path prefix the traces support: the one the app's client config names and the ones the
|
|
1863
|
+
* traces' own same-origin requests show ({@link tracedApiPrefixes}).
|
|
1864
|
+
*
|
|
1865
|
+
* @param items - The traces the note is about.
|
|
1866
|
+
* @param apiPrefixes - Prefixes the app's client config names (`['/api']`), never a literal.
|
|
1867
|
+
* @returns The one-segment prefixes, without duplicates.
|
|
1868
|
+
*/
|
|
1869
|
+
function knownApiPrefixes(items, apiPrefixes) {
|
|
1870
|
+
const prefixes = new Set([...apiPrefixes, ...tracedApiPrefixes(items)]
|
|
1871
|
+
.map((prefix) => `/${String(prefix).replace(/^\/+|\/+$/g, '')}`)
|
|
1872
|
+
.filter((prefix) => prefix !== '/'));
|
|
1873
|
+
return [...prefixes];
|
|
1874
|
+
}
|
|
1875
|
+
/** A URL written in full (`https://api.realworld.show/api/articles`). */
|
|
1876
|
+
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
1877
|
+
/**
|
|
1878
|
+
* The API requests a trace made to an origin other than the page's own — set-up traffic that ran
|
|
1879
|
+
* against someone else's server.
|
|
1880
|
+
*
|
|
1881
|
+
* The trace reader relativises every request whose origin is the page's (`pathOf`), so a request
|
|
1882
|
+
* recorded with an absolute URL went to ANOTHER origin by construction. It counts as set-up/API
|
|
1883
|
+
* traffic when its path carries a prefix the app's own API is known to use ({@link
|
|
1884
|
+
* knownApiPrefixes}) — a CDN font or a third-party script has no `/api` under it, and with no
|
|
1885
|
+
* known prefix nothing is claimed.
|
|
1886
|
+
*
|
|
1887
|
+
* xr-molecule-flash-m165 (+10:22): the suite's helper defaulted `API_BASE` to
|
|
1888
|
+
* `https://api.realworld.show/api` and its set-up seeded rows there, while the browser drove this
|
|
1889
|
+
* app — whose own `/api` answers a login for none of those users. The trace's absolute-URL rows
|
|
1890
|
+
* were the evidence, and the note said nothing about them.
|
|
1891
|
+
*
|
|
1892
|
+
* @param items - The traces the note is about.
|
|
1893
|
+
* @param apiPrefixes - Prefixes the app's client config names (`['/api']`).
|
|
1894
|
+
* @returns One entry per distinct (origin, prefix), in trace order.
|
|
1895
|
+
*/
|
|
1896
|
+
function foreignOriginApiRequests(items, apiPrefixes) {
|
|
1897
|
+
const prefixes = knownApiPrefixes(items, apiPrefixes);
|
|
1898
|
+
if (prefixes.length === 0)
|
|
1899
|
+
return [];
|
|
1900
|
+
const out = [];
|
|
1901
|
+
const seen = new Set();
|
|
1902
|
+
for (const item of items) {
|
|
1903
|
+
for (const request of item.requests) {
|
|
1904
|
+
const raw = String(request.path ?? '');
|
|
1905
|
+
if (!ABSOLUTE_URL.test(raw))
|
|
1906
|
+
continue;
|
|
1907
|
+
let parsed;
|
|
1908
|
+
try {
|
|
1909
|
+
parsed = new URL(raw);
|
|
1910
|
+
}
|
|
1911
|
+
catch (_error) {
|
|
1912
|
+
// Not a URL this can parse: it is not evidence of another origin.
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
const prefix = prefixes.find((one) => parsed.pathname === one || parsed.pathname.startsWith(`${one}/`));
|
|
1916
|
+
if (prefix === undefined)
|
|
1917
|
+
continue;
|
|
1918
|
+
const key = `${parsed.origin}${prefix}`;
|
|
1919
|
+
if (seen.has(key))
|
|
1920
|
+
continue;
|
|
1921
|
+
seen.add(key);
|
|
1922
|
+
out.push({ origin: parsed.origin, prefix, request });
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return out;
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* The origin a run's target names, when it names one: a URL (`http://localhost:4173`), or the
|
|
1929
|
+
* deployed host the note prints (`loopcheck-….apps.mlcl.dev`). Null for the note's descriptive
|
|
1930
|
+
* targets ("the deployed app", "the local app").
|
|
1931
|
+
*
|
|
1932
|
+
* @param target - Where the run went, as {@link failureTraceNote} names it.
|
|
1933
|
+
* @returns The origin, or null.
|
|
1934
|
+
*/
|
|
1935
|
+
function appOriginOfTarget(target) {
|
|
1936
|
+
const text = target.trim();
|
|
1937
|
+
try {
|
|
1938
|
+
if (ABSOLUTE_URL.test(text))
|
|
1939
|
+
return new URL(text).origin;
|
|
1940
|
+
if (/^[a-z0-9.-]+(?::\d+)?$/i.test(text))
|
|
1941
|
+
return new URL(`https://${text}`).origin;
|
|
1942
|
+
}
|
|
1943
|
+
catch (_error) {
|
|
1944
|
+
// A target that is not a URL names no origin.
|
|
1945
|
+
}
|
|
1946
|
+
return null;
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* The sentence naming the foreign origin a run's set-up traffic went to, and the setting that
|
|
1950
|
+
* points it at this app instead.
|
|
1951
|
+
*
|
|
1952
|
+
* It leads the note because it is the cheapest possible wrong result: every row the suite creates
|
|
1953
|
+
* lands in a database this app never reads, so the failure says nothing about the app's code.
|
|
1954
|
+
* xr-molecule-flash-m165 spent the walk after its failed login reading helpers and configs for a
|
|
1955
|
+
* missing token, when the command's `BASE_URL`/`API_BASE` disagreement was the cause.
|
|
1956
|
+
*
|
|
1957
|
+
* @param found - The foreign-origin request the trace holds.
|
|
1958
|
+
* @param target - Where the run went, for the app's own origin when the note knows it.
|
|
1959
|
+
* @returns The sentence, led by nothing and closed by a blank line.
|
|
1960
|
+
*/
|
|
1961
|
+
function foreignOriginSentence(found, target) {
|
|
1962
|
+
const appOrigin = appOriginOfTarget(target);
|
|
1963
|
+
const where = appOrigin ?? 'this app’s origin';
|
|
1964
|
+
const apiBase = `${appOrigin ?? '<this app’s origin>'}${found.prefix}`;
|
|
1965
|
+
return (`The suite’s setup ran against ${found.origin}, not this app ` +
|
|
1966
|
+
`(\`${requestLabel(found.request)}\`) — its set-up/API traffic went to another origin while ` +
|
|
1967
|
+
`this app serves its own API at \`${found.prefix}\` on ${where}. Set ` +
|
|
1968
|
+
`\`API_BASE=${apiBase}\` on the run — the supplied suite reads it (verify the variable name ` +
|
|
1969
|
+
`in the suite’s own helpers before writing it) — so the rows it creates are in the database ` +
|
|
1970
|
+
`this app reads.\n\n`);
|
|
1971
|
+
}
|
|
1677
1972
|
/**
|
|
1678
1973
|
* The failed 404s whose path carries none of the known API prefixes.
|
|
1679
1974
|
*
|
|
@@ -1687,12 +1982,9 @@ function tracedApiPrefixes(items) {
|
|
|
1687
1982
|
* @returns One entry per distinct failed 404, in trace order.
|
|
1688
1983
|
*/
|
|
1689
1984
|
function missingApiPrefixRequests(items, apiPrefixes) {
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
.filter((prefix) => prefix !== '/'));
|
|
1693
|
-
if (prefixes.size === 0)
|
|
1985
|
+
const known = knownApiPrefixes(items, apiPrefixes);
|
|
1986
|
+
if (known.length === 0)
|
|
1694
1987
|
return [];
|
|
1695
|
-
const known = [...prefixes];
|
|
1696
1988
|
const out = [];
|
|
1697
1989
|
const seen = new Set();
|
|
1698
1990
|
for (const item of items) {
|
|
@@ -1726,6 +2018,147 @@ function missingApiPrefixSentence(request) {
|
|
|
1726
2018
|
`without the \`${request.prefix}\` prefix — the HTTP client's base URL is not set: is ` +
|
|
1727
2019
|
'`setupProviders()` (which runs the HTTP bond setup) called in `main.tsx`?');
|
|
1728
2020
|
}
|
|
2021
|
+
/**
|
|
2022
|
+
* The sentence for a failing assertion whose awaited text a same-page API response carried.
|
|
2023
|
+
*
|
|
2024
|
+
* It names the response and the fact the trace proves — the value reached the browser and the page
|
|
2025
|
+
* did not render it — and stops there. Which cut drops it (a slice, a filter, a limit) is in the
|
|
2026
|
+
* component that maps the response, and the note cannot see that component; naming the mapping
|
|
2027
|
+
* step is the whole instruction (xr-molecule-flash-m169).
|
|
2028
|
+
*
|
|
2029
|
+
* @param missing - The awaited text and the response that carried it.
|
|
2030
|
+
* @returns The sentence the note leads with.
|
|
2031
|
+
*/
|
|
2032
|
+
function missingTextSentence(missing) {
|
|
2033
|
+
return (`The API returned "${missing.text}" (in ${missing.method} ${missing.path}), but the page did ` +
|
|
2034
|
+
`not render it — the page drops it before rendering (a slice/filter/limit in the component ` +
|
|
2035
|
+
`that renders that list); find where that response is mapped.`);
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* The `name` attribute a locator's selector reads (`textarea[name="bio"]` → `bio`), or null when
|
|
2039
|
+
* the selector names no field.
|
|
2040
|
+
*
|
|
2041
|
+
* Matching the form field to the request body's key is the whole point: a body that carries
|
|
2042
|
+
* `"bio": ""` says nothing unless the trace also says the test typed a bio.
|
|
2043
|
+
*
|
|
2044
|
+
* @param selector - The selector a `fill`/`type`/`selectOption` action carried.
|
|
2045
|
+
* @returns The field name, or null.
|
|
2046
|
+
*/
|
|
2047
|
+
function fieldNameOf(selector) {
|
|
2048
|
+
const quoted = /\[\s*name\s*=\s*(["'])((?:(?!\1).)*)\1\s*\]/.exec(selector);
|
|
2049
|
+
if (quoted !== null)
|
|
2050
|
+
return quoted[2] === '' ? null : quoted[2];
|
|
2051
|
+
const bare = /\[\s*name\s*=\s*([^\s\]]+)\s*\]/.exec(selector);
|
|
2052
|
+
return bare === null ? null : (bare[1] ?? null);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Whether a JSON request body carries `key` with an EMPTY value (`""` or null), looking inside the
|
|
2056
|
+
* wrapper object an API nests its entity under (`user`, `data`, `props`).
|
|
2057
|
+
*
|
|
2058
|
+
* A body that does not parse, or carries the key with a real value, is not evidence: the note only
|
|
2059
|
+
* claims the page sent a field empty when it did.
|
|
2060
|
+
*
|
|
2061
|
+
* @param sent - The body the page sent, as the trace recorded it.
|
|
2062
|
+
* @param key - The field name from the filled input's `name` attribute.
|
|
2063
|
+
* @returns True when the body carries that key empty.
|
|
2064
|
+
*/
|
|
2065
|
+
function bodyCarriesEmptyField(sent, key) {
|
|
2066
|
+
let parsed;
|
|
2067
|
+
try {
|
|
2068
|
+
parsed = JSON.parse(sent);
|
|
2069
|
+
}
|
|
2070
|
+
catch (_error) {
|
|
2071
|
+
// A truncated body (the trace read bounds what it keeps) names no field confidently.
|
|
2072
|
+
return false;
|
|
2073
|
+
}
|
|
2074
|
+
const read = (value, level) => {
|
|
2075
|
+
if (level > 4 || value === null || typeof value !== 'object')
|
|
2076
|
+
return null;
|
|
2077
|
+
if (Array.isArray(value)) {
|
|
2078
|
+
for (const item of value) {
|
|
2079
|
+
const found = read(item, level + 1);
|
|
2080
|
+
if (found !== null)
|
|
2081
|
+
return found;
|
|
2082
|
+
}
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
const record = value;
|
|
2086
|
+
if (Object.prototype.hasOwnProperty.call(record, key)) {
|
|
2087
|
+
return record[key] === '' || record[key] === null;
|
|
2088
|
+
}
|
|
2089
|
+
for (const nested of Object.values(record)) {
|
|
2090
|
+
const found = read(nested, level + 1);
|
|
2091
|
+
if (found !== null)
|
|
2092
|
+
return found;
|
|
2093
|
+
}
|
|
2094
|
+
return null;
|
|
2095
|
+
};
|
|
2096
|
+
return read(parsed, 0) === true;
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* The field a test typed into that the same page submitted empty, when the trace recorded both.
|
|
2100
|
+
*
|
|
2101
|
+
* This is the Settings race of xr-molecule-flash-m173: `page.fill('textarea[name="bio"]', newBio)`
|
|
2102
|
+
* then `button[type=submit]`, and the `PUT /api/user` carried `"bio": ""`. The form's seeding
|
|
2103
|
+
* effect re-ran between the fill and the submit — on the deployed URL the background session
|
|
2104
|
+
* verification lands later than it does locally, and it handed the form a new user object. The
|
|
2105
|
+
* reading only pairs facts the trace holds: the filled value (its `selector`/`value`), the field
|
|
2106
|
+
* name the selector names, and the next call to the app's API on that page whose body carries
|
|
2107
|
+
* that key empty.
|
|
2108
|
+
*
|
|
2109
|
+
* @param item - One traced failure.
|
|
2110
|
+
* @returns The evidence, or null when no filled field was submitted empty.
|
|
2111
|
+
*/
|
|
2112
|
+
function formResetOf(item) {
|
|
2113
|
+
const requests = [...(item.lastAction?.requests ?? []), ...item.requests];
|
|
2114
|
+
// A trace read before `actions` existed (or a hand-built one) carries none: no claim.
|
|
2115
|
+
for (const action of item.actions ?? []) {
|
|
2116
|
+
if (action.selector === undefined || action.value === undefined || action.value === '')
|
|
2117
|
+
continue;
|
|
2118
|
+
const field = fieldNameOf(action.selector);
|
|
2119
|
+
if (field === null)
|
|
2120
|
+
continue;
|
|
2121
|
+
let next = null;
|
|
2122
|
+
for (const request of requests) {
|
|
2123
|
+
if (!/^(?:POST|PUT|PATCH)$/.test(request.method) || request.sent === undefined)
|
|
2124
|
+
continue;
|
|
2125
|
+
// The submit follows the typing; a request that started before it cannot have sent it.
|
|
2126
|
+
if (typeof request.startedAt === 'number' && request.startedAt < action.start)
|
|
2127
|
+
continue;
|
|
2128
|
+
// Same page only: another context's set-up call is not this form's submit.
|
|
2129
|
+
if (action.pageId !== undefined &&
|
|
2130
|
+
request.pageId !== undefined &&
|
|
2131
|
+
action.pageId !== request.pageId)
|
|
2132
|
+
continue;
|
|
2133
|
+
if (!bodyCarriesEmptyField(request.sent, field))
|
|
2134
|
+
continue;
|
|
2135
|
+
const earlier = next === null ||
|
|
2136
|
+
(typeof request.startedAt === 'number' &&
|
|
2137
|
+
(typeof next.startedAt !== 'number' || request.startedAt < next.startedAt));
|
|
2138
|
+
if (earlier)
|
|
2139
|
+
next = request;
|
|
2140
|
+
}
|
|
2141
|
+
if (next !== null)
|
|
2142
|
+
return { value: action.value, selector: action.selector, field, request: next };
|
|
2143
|
+
}
|
|
2144
|
+
return null;
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* The sentence for a submitted form field the test had just typed into, sent empty.
|
|
2148
|
+
*
|
|
2149
|
+
* It states the two recorded facts and the one inference they support — the form re-seeded after
|
|
2150
|
+
* the typing — then the fix at the source: copy the loaded data into the form once, on its first
|
|
2151
|
+
* load, instead of on every change of the data it was seeded from.
|
|
2152
|
+
*
|
|
2153
|
+
* @param reset - The filled field and the submit that carried it empty.
|
|
2154
|
+
* @returns The sentence the note leads with.
|
|
2155
|
+
*/
|
|
2156
|
+
function formResetSentence(reset) {
|
|
2157
|
+
return (`The test typed "${reset.value}" into ${reset.selector}, but ` +
|
|
2158
|
+
`${requestLabel(reset.request)} sent "${reset.field}": "" — the form's state was reset after ` +
|
|
2159
|
+
`typing (an effect re-seeding the form from data that changed identity ran again after the ` +
|
|
2160
|
+
`test typed). Seed the form once, not every time that data changes.`);
|
|
2161
|
+
}
|
|
1729
2162
|
/**
|
|
1730
2163
|
* The note a failed suite run's result carries: what each failing test's page actually sent, and
|
|
1731
2164
|
* what came back.
|
|
@@ -1765,6 +2198,14 @@ function missingApiPrefixSentence(request) {
|
|
|
1765
2198
|
* context and the assertion's page cannot be identified, the note lists the requests and makes NO
|
|
1766
2199
|
* causal claim — no "the test's last action", no "the page stayed at …" — instead of merging them.
|
|
1767
2200
|
*
|
|
2201
|
+
* A field the test filled and the page submitted EMPTY ({@link formResetOf}) leads the note: the
|
|
2202
|
+
* trace holds both facts — the value typed into `textarea[name="bio"]` and the `PUT /api/user`
|
|
2203
|
+
* whose body carried `"bio": ""` — so the form's state was reset between the typing and the
|
|
2204
|
+
* submit. The note names the effect that re-seeds a form from data whose identity changed and the
|
|
2205
|
+
* fix at the source (seed the form once). xr-molecule-flash-m173 lost a deploy-and-walk cycle to
|
|
2206
|
+
* this on the deployed URL only, where the background session verification lands later than it
|
|
2207
|
+
* does locally.
|
|
2208
|
+
*
|
|
1768
2209
|
* WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
|
|
1769
2210
|
* needs a recorded response, a real failure or a console line ({@link traceRowEvidence}), rows are
|
|
1770
2211
|
* deduplicated by the test's location, and they are ordered real failure → repeated from an
|
|
@@ -1803,6 +2244,13 @@ function missingApiPrefixSentence(request) {
|
|
|
1803
2244
|
* xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
|
|
1804
2245
|
* `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
|
|
1805
2246
|
*
|
|
2247
|
+
* A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
|
|
2248
|
+
* path or the body) is never that shape: the request's purpose is to obtain a token, so an absent
|
|
2249
|
+
* header is not a client omission ({@link isCredentialExchange}). xr-molecule-flash-m165's login
|
|
2250
|
+
* 401 was answered as one for 40 minutes; the trace also showed the suite's SET-UP traffic going
|
|
2251
|
+
* to `https://api.realworld.show/api` instead of this app, which is what the note leads with
|
|
2252
|
+
* ({@link foreignOriginApiRequests}).
|
|
2253
|
+
*
|
|
1806
2254
|
* @param items - The traces {@link parseTraceArtifacts} read.
|
|
1807
2255
|
* @param target - Where the run went — the deployed host, as the note names it.
|
|
1808
2256
|
* @param earlierFailures - The failing `file:line`s earlier walks of this suite left, so a repeat
|
|
@@ -1870,7 +2318,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
1870
2318
|
// Every recorded failure is the logout endpoint answering 4xx: the row's own sentence explains
|
|
1871
2319
|
// it, and neither the handler lead nor the credential reading applies (m156 +24:02).
|
|
1872
2320
|
const onlyLogoutFailures = failedRequests.length > 0 && failedRequests.every((request) => isLogoutRequest(request));
|
|
1873
|
-
const authAdvice = authRefusalAdvice(traced);
|
|
2321
|
+
const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? []);
|
|
2322
|
+
// Set-up traffic the trace shows going to an origin other than the app's (m165): the rows it
|
|
2323
|
+
// creates are not in this app's database, so the note says it before any app-side reading.
|
|
2324
|
+
const foreignOrigins = foreignOriginApiRequests(traced, apiPrefixes);
|
|
2325
|
+
const [firstForeign] = foreignOrigins;
|
|
2326
|
+
const setupOriginLead = firstForeign === undefined ? '' : foreignOriginSentence(firstForeign, target);
|
|
1874
2327
|
// The stalled-navigation reading, per traced test: non-null only when the RUNNER says this
|
|
1875
2328
|
// test's failing operation is a navigation and a document subresource still pending at the end
|
|
1876
2329
|
// started inside that wait ({@link stalledNavigation}).
|
|
@@ -2098,7 +2551,36 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
2098
2551
|
`does not define is always undefined, and a missing effect dependency on the route means ` +
|
|
2099
2552
|
`the fetch never re-runs — before touching the markup or the CSS.\n\n`
|
|
2100
2553
|
: '';
|
|
2101
|
-
|
|
2554
|
+
// An assertion whose awaited text a same-page API response carried: the fact the run needs
|
|
2555
|
+
// first, before any row, is that the value arrived and the page dropped it (m169's missing
|
|
2556
|
+
// "trending" came back from GET /api/tags while the sidebar rendered a cut list). Each distinct
|
|
2557
|
+
// response is named once; the note makes no claim about which cut did it.
|
|
2558
|
+
const missingTexts = [
|
|
2559
|
+
...new Map(traced
|
|
2560
|
+
.filter((item) => item.missingText !== undefined)
|
|
2561
|
+
.map((item) => {
|
|
2562
|
+
const missing = item.missingText;
|
|
2563
|
+
return [`${missing.method} ${missing.path}\u0000${missing.text}`, missing];
|
|
2564
|
+
})).values(),
|
|
2565
|
+
];
|
|
2566
|
+
const missingTextLead = missingTexts.length === 0
|
|
2567
|
+
? ''
|
|
2568
|
+
: `${missingTexts.slice(0, 2).map(missingTextSentence).join(' ')}\n\n`;
|
|
2569
|
+
// A field the test typed and the page submitted empty: the form re-seeded itself after the
|
|
2570
|
+
// typing (m173's Settings race — the deployed URL lands the background session verification
|
|
2571
|
+
// later than a local run, and its new user object re-ran the form's seeding effect between the
|
|
2572
|
+
// fill and the submit). It is a direct cause of the failing request, so it leads the note.
|
|
2573
|
+
const formResets = [
|
|
2574
|
+
...new Map(traced
|
|
2575
|
+
.map(formResetOf)
|
|
2576
|
+
.filter((reset) => reset !== null)
|
|
2577
|
+
.map((reset) => [`${reset.request.method} ${reset.request.path}\u0000${reset.field}`, reset])).values(),
|
|
2578
|
+
];
|
|
2579
|
+
const formResetLead = formResets.length === 0 ? '' : `${formResets.slice(0, 2).map(formResetSentence).join(' ')}\n\n`;
|
|
2580
|
+
return (formResetLead +
|
|
2581
|
+
missingTextLead +
|
|
2582
|
+
transportLead +
|
|
2583
|
+
setupOriginLead +
|
|
2102
2584
|
stalledLead +
|
|
2103
2585
|
staleRouteLead +
|
|
2104
2586
|
`The platform read the failing tests' own Playwright traces from the run against ${target} ` +
|
|
@@ -2289,24 +2771,27 @@ async function readRunConfig(command, cwd, readFile) {
|
|
|
2289
2771
|
return '';
|
|
2290
2772
|
}
|
|
2291
2773
|
/**
|
|
2292
|
-
* The command a
|
|
2774
|
+
* The command a Playwright run needs to leave a readable trace behind, when the suite's config
|
|
2293
2775
|
* would record none.
|
|
2294
2776
|
*
|
|
2295
2777
|
* The failing tests' requests are only readable if the run wrote them down (see the module doc),
|
|
2296
2778
|
* and the config is where the decision about that lives — the platform does not edit the suite's
|
|
2297
|
-
* config, so it passes the flag instead,
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2779
|
+
* config, so it passes the flag instead, on every Playwright run that starts tests and does not
|
|
2780
|
+
* already decide its own `--trace`. That includes the whole-suite proof run, which an earlier
|
|
2781
|
+
* version of this function skipped on the reasoning that its flags are graded: the flag changes
|
|
2782
|
+
* what is RETAINED, not what passes. `retain-on-failure` keeps the trace only of a test that
|
|
2783
|
+
* fails — Playwright records it and deletes it when the test passes (playwright's
|
|
2784
|
+
* `_shouldAbandonTrace` unlinks the temporary trace files of a passing test) — so a green proof
|
|
2785
|
+
* run keeps nothing extra. A failing one keeps the network log its failures need.
|
|
2786
|
+
* xr-molecule-flash-m163 (+24:58) is why the proof run is no longer skipped: its one deployed
|
|
2787
|
+
* failure left only an `error-context.md`, the executor spent the next calls hunting a `trace.zip`
|
|
2788
|
+
* that could not exist, and then shipped a guessed fix.
|
|
2301
2789
|
*
|
|
2302
2790
|
* @param args - The command, the directory it starts in, and the backend's file read.
|
|
2303
2791
|
* @returns The command to run and the sentence to report, or the command unchanged.
|
|
2304
2792
|
*/
|
|
2305
2793
|
export async function diagnosisTraceCommand(args) {
|
|
2306
2794
|
const unchanged = { command: args.command, change: null };
|
|
2307
|
-
// The proof run is the one run this must never touch: its flags are graded.
|
|
2308
|
-
if (!isDiagnosisRun(args.command))
|
|
2309
|
-
return unchanged;
|
|
2310
2795
|
if (namesTraceMode(args.command))
|
|
2311
2796
|
return unchanged;
|
|
2312
2797
|
// Only a Playwright run leaves a trace to read.
|
|
@@ -2330,7 +2815,8 @@ export async function diagnosisTraceCommand(args) {
|
|
|
2330
2815
|
return {
|
|
2331
2816
|
command: `${args.command.slice(0, at)} ${DIAGNOSIS_TRACE_FLAG}${args.command.slice(at)}`,
|
|
2332
2817
|
change: `Added \`${DIAGNOSIS_TRACE_FLAG}\`: the suite's config records no trace for a failure` +
|
|
2333
|
-
`${/trace:/.test(config) ? '' : ' (it sets none)'}, and
|
|
2334
|
-
'what the platform reads the
|
|
2818
|
+
`${/trace:/.test(config) ? '' : ' (it sets none)'}, and a failing test's own network log is ` +
|
|
2819
|
+
'what the platform reads the cause from. It keeps a trace only for a test that fails, so a ' +
|
|
2820
|
+
'green run retains nothing.',
|
|
2335
2821
|
};
|
|
2336
2822
|
}
|