@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.
@@ -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: entry.class + '.' + entry.method,
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 walks repeated one
1583
- * shape (`401`, no header) on every failing test, and repeating its sentence per row would bury
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 key = `${request.status} ${request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
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 prefixes = new Set([...apiPrefixes, ...tracedApiPrefixes(items)]
1691
- .map((prefix) => `/${String(prefix).replace(/^\/+|\/+$/g, '')}`)
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
- return (transportLead +
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 DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
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, and only where it can do no harm: on a run narrowed to a
2298
- * part of the suite or capped ({@link isDiagnosisRun}), never on the whole-suite proof run, and
2299
- * never over a `--trace` the executor wrote itself. A capped walk is exactly the run whose
2300
- * failures someone is about to work on; the proof run is graded as it is.
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 this diagnosis run's failures are ` +
2334
- 'what the platform reads the network log from. The proof run keeps the suite’s own settings.',
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
  }