@molecule/api-ai-tools 1.9.16 → 1.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,27 @@ function messageOf(value) {
198
197
  return null
199
198
  }
200
199
 
200
+ // The text a failing assertion waited for, named by its own error: a \`has-text(…)\` CSS
201
+ // pseudo-class, a \`getByText(…)\` locator, or a \`toContainText(…)\` expectation. A strict-mode
202
+ // violation is not this shape — two elements DID match the text — and neither is a wait on a
203
+ // selector with no text argument. The ANSI colouring Playwright leaves in the message is dropped
204
+ // before the read, or the quotes it wraps around a locator would never parse.
205
+ function waitedTextOf(message) {
206
+ if (!message) return null
207
+ const text = String(message).replace(/\\u001b\\[[0-9;]*m/g, '')
208
+ if (text.indexOf('strict mode violation') !== -1) return null
209
+ const quoted = function (pattern) {
210
+ const m = pattern.exec(text)
211
+ return m === null ? null : m[2]
212
+ }
213
+ const hasText = quoted(/has-text\\(\\s*(['"])((?:(?!\\1).)*)\\1/)
214
+ if (hasText !== null) return hasText
215
+ const byText = quoted(/getByText\\(\\s*(['"])((?:(?!\\1).)*)\\1/)
216
+ if (byText !== null) return byText
217
+ if (text.indexOf('toContainText') === -1) return null
218
+ return quoted(/Expected(?: string)?:\\s*(["'])((?:(?!\\1).)*)\\1/)
219
+ }
220
+
201
221
  // A request/response body exactly as Playwright stored it: inline on the value, or in a resource
202
222
  // entry the value names. Bounded, and binary is dropped — a JSON body is what this is for, and a
203
223
  // multipart upload would print bytes.
@@ -459,6 +479,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
459
479
  let failedStepId = null
460
480
  let failedStep = null
461
481
  let failedEnd = null
482
+ let failedError = ''
462
483
  let failureSpec = null
463
484
  let failureLine = 0
464
485
  let lastSpec = null
@@ -468,6 +489,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
468
489
  if (entry.type === 'after' && entry.error && typeof entry.callId === 'string' && failedStepId === null) {
469
490
  failedStepId = entry.callId
470
491
  failedEnd = typeof entry.endTime === 'number' ? entry.endTime : null
492
+ failedError = messageOf(entry.error) || ''
471
493
  }
472
494
  }
473
495
  // The fallback location — the last step that names a spec — is read from every member, runner
@@ -710,6 +732,26 @@ for (const one of found.slice(0, MAX_TRACES)) {
710
732
  if (sent) r.sent = sent
711
733
  if (got) r.got = got
712
734
  }
735
+ // The text the failing assertion waited for, and a response the SAME page received that carried
736
+ // it (m169). The assertion's own error names the text; the network log says whether it ever
737
+ // reached the browser. Only an answered fetch/xhr can say the API returned it — a 4xx body is an
738
+ // error message, not data. The most focused response wins (smallest body): an articles list also
739
+ // carries tag names in its \`tagList\`, while the tags response is the collection whose membership
740
+ // the assertion tests.
741
+ const waitedText = waitedTextOf(failedError)
742
+ let missingText = null
743
+ if (waitedText !== null && !unattributed) {
744
+ let best = null
745
+ for (const r of requests) {
746
+ if (r.type !== 'fetch' && r.type !== 'xhr') continue
747
+ if (r.status < 200 || r.status >= 400) continue
748
+ const got = gotText(buf, entries, r)
749
+ if (!got || got.indexOf(waitedText) === -1) continue
750
+ if (best === null || got.length < best.got.length) best = { request: r, got: got }
751
+ }
752
+ if (best !== null)
753
+ missingText = { text: waitedText, method: best.request.method, path: best.request.path }
754
+ }
713
755
  function wire(r) {
714
756
  return {
715
757
  method: r.method,
@@ -881,6 +923,7 @@ for (const one of found.slice(0, MAX_TRACES)) {
881
923
  // tail here dropped every failed request as soon as a page made more than MAX_REQUESTS of them
882
924
  // plus the answered ones (xr-molecule-flash-m112's note listed only -1s and 200s).
883
925
  requests: chosen.slice(0, MAX_REQUESTS),
926
+ ...(missingText === null ? {} : { missingText: missingText }),
884
927
  console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
885
928
  actions: actions.slice(-MAX_ACTIONS),
886
929
  ...(unattributed ? { unattributed: true } : {}),
@@ -917,6 +960,24 @@ function parseTraceRequest(value) {
917
960
  ...(typeof r.pageId === 'string' && r.pageId ? { pageId: r.pageId } : {}),
918
961
  };
919
962
  }
963
+ /**
964
+ * One `missingText` out of a `TRACE` line's json, or null when the value is not one.
965
+ *
966
+ * @param value - The line's `missingText`.
967
+ * @returns The awaited text and the response that carried it, or null.
968
+ */
969
+ function parseMissingText(value) {
970
+ if (typeof value !== 'object' || value === null)
971
+ return null;
972
+ const raw = value;
973
+ if (typeof raw.text !== 'string' || raw.text === '')
974
+ return null;
975
+ if (typeof raw.method !== 'string' || raw.method === '')
976
+ return null;
977
+ if (typeof raw.path !== 'string' || raw.path === '')
978
+ return null;
979
+ return { text: raw.text, method: raw.method, path: raw.path };
980
+ }
920
981
  /**
921
982
  * One `lastAction` out of a `TRACE` line's json, or null when the value is not one.
922
983
  *
@@ -962,6 +1023,7 @@ export function parseTraceArtifacts(stdout) {
962
1023
  const actions = Array.isArray(raw.actions) ? raw.actions : [];
963
1024
  const lead = parseTraceRequest(raw.lead);
964
1025
  const lastAction = parseLastAction(raw.lastAction);
1026
+ const missingText = parseMissingText(raw.missingText);
965
1027
  const otherContexts = Array.isArray(raw.otherContexts)
966
1028
  ? raw.otherContexts
967
1029
  .filter((c) => typeof c === 'object' && c !== null)
@@ -995,6 +1057,7 @@ export function parseTraceArtifacts(stdout) {
995
1057
  })),
996
1058
  ...(otherContexts.length === 0 ? {} : { otherContexts }),
997
1059
  ...(raw.unattributed === true ? { unattributed: true } : {}),
1060
+ ...(missingText === null ? {} : { missingText }),
998
1061
  });
999
1062
  }
1000
1063
  catch (_error) {
@@ -1500,6 +1563,68 @@ function sameOriginPath(request) {
1500
1563
  return null;
1501
1564
  return raw.replace(/\/+$/, '');
1502
1565
  }
1566
+ /**
1567
+ * The path segments a request ASKS FOR a credential through — a login, a registration, a token
1568
+ * endpoint — as opposed to a protected resource that presents one.
1569
+ */
1570
+ const CREDENTIAL_EXCHANGE_PATH = /(?:^|\/)(?:login|log-in|signin|sign-in|register|signup|sign-up|token)(?:\/|$)/i;
1571
+ /** The field a credential payload carries: a password, a grant, or a refresh token. */
1572
+ const CREDENTIAL_FIELD = /"(?:password|credentials|grant_type|refresh_token)"\s*:/i;
1573
+ /** The field that pairs with a password in a login/registration body. */
1574
+ const CREDENTIAL_IDENTITY = /"(?:email|username)"\s*:/i;
1575
+ /**
1576
+ * Whether a request's recorded body is a credential payload — the shape a login, a registration
1577
+ * or a token exchange sends, never a protected write (an article, a comment, a profile).
1578
+ *
1579
+ * @param sent - What the request sent, as the trace recorded it (bounded).
1580
+ * @returns True for a body carrying credentials.
1581
+ */
1582
+ function credentialExchangeBody(sent) {
1583
+ if (!sent)
1584
+ return false;
1585
+ const text = sent.slice(0, MAX_TRACED_BODY_CHARS);
1586
+ // A password alone is a profile write as often as a login; the exchange is a password beside
1587
+ // an identity, or a token grant.
1588
+ if (!CREDENTIAL_FIELD.test(text))
1589
+ return false;
1590
+ return (CREDENTIAL_IDENTITY.test(text) || /"(?:credentials|grant_type|refresh_token)"\s*:/i.test(text));
1591
+ }
1592
+ /**
1593
+ * Whether a refused request is a CREDENTIAL EXCHANGE: the app was asked to mint a token (a login,
1594
+ * a registration, a token endpoint), so it never needed one attached.
1595
+ *
1596
+ * Three readings, weakest last: the app's own auth config names the endpoint (a project may mount
1597
+ * `/session` or `/auth/signin`), the path's own last segments name it (`/users/login`), or the
1598
+ * body is a credential payload (`{"user":{"email":…,"password":…}}` sends a registration).
1599
+ *
1600
+ * xr-molecule-flash-m165 (+10:22): `POST /api/users/login → 401
1601
+ * {"errors":{"credentials":["invalid"]}}` was answered with the missing-token sentence — "the
1602
+ * client did not attach the token … The API handler is not the problem" — for a request whose
1603
+ * whole purpose is to obtain a token. The model read helpers and configs for 40 minutes instead of
1604
+ * the real cause: the suite's setup had seeded `https://api.realworld.show`, not this app.
1605
+ *
1606
+ * @param request - The refused request (401/403).
1607
+ * @param authEndpoints - The endpoints the app's auth config names (`/users/login`).
1608
+ * @returns True when the request is a credential exchange.
1609
+ */
1610
+ function isCredentialExchange(request, authEndpoints) {
1611
+ if (request.method !== 'POST')
1612
+ return false;
1613
+ const path = sameOriginPath(request);
1614
+ if (path === null)
1615
+ return false;
1616
+ // The app's own config names it: matched as a path suffix, because an auth config writes the
1617
+ // endpoint relative to its API base (`/users/login`) while the trace records the mounted path
1618
+ // (`/api/users/login`).
1619
+ if (authEndpoints.some((endpoint) => {
1620
+ const named = `/${String(endpoint).replace(/^\/+|\/+$/g, '')}`;
1621
+ return named.length > 1 && (path === named || path.endsWith(named));
1622
+ }))
1623
+ return true;
1624
+ if (CREDENTIAL_EXCHANGE_PATH.test(path))
1625
+ return true;
1626
+ return credentialExchangeBody(request.sent);
1627
+ }
1503
1628
  /**
1504
1629
  * The sentence a 401/403 on the app's own API gets: which half of the wire refused the request.
1505
1630
  *
@@ -1521,6 +1646,15 @@ function sameOriginPath(request) {
1521
1646
  * sent through. When the trace carries that line, the no-header sentence names the ordering fix
1522
1647
  * instead of leaving the run to re-derive it.
1523
1648
  *
1649
+ * xr-molecule-flash-m165 (+10:22): a `POST /api/users/login → 401
1650
+ * {"errors":{"credentials":["invalid"]}}` is a CREDENTIAL EXCHANGE — the request is how a token is
1651
+ * obtained, so an absent `Authorization` header is the request being what it is, not a client bug.
1652
+ * The note said "the client did not attach the token … The API handler is not the problem" and the
1653
+ * run went looking through the app instead of at the credentials or the setup's API origin. The
1654
+ * exchange branch names the credentials, the account's origin, and the response body; the
1655
+ * categorical handler sentence is gone from the no-header branch too, because a route that
1656
+ * requires a token where the app allows anonymous access is the handler's own mistake.
1657
+ *
1524
1658
  * The trace cannot name the file that made the call: a real HAR entry carries the request, response,
1525
1659
  * `_resourceType`, `_monotonicTime` and a frame ref, but no initiator stack. So the sentence names
1526
1660
  * the shared client and the auth bond instead of guessing a file.
@@ -1528,14 +1662,30 @@ function sameOriginPath(request) {
1528
1662
  * @param request - The refused request, a 401 or 403 on the app's own origin.
1529
1663
  * @param unbondedClient - Whether the traced page logged `no client is bonded`, which explains a
1530
1664
  * request that carried no `Authorization` header.
1665
+ * @param authEndpoints - The endpoints the app's auth config names, so a credential exchange is
1666
+ * recognised even under a path the segment list does not.
1531
1667
  * @returns The sentence the note appends.
1532
1668
  */
1533
- function authRefusalSentence(request, unbondedClient = false) {
1669
+ function authRefusalSentence(request, unbondedClient = false, authEndpoints = []) {
1534
1670
  const label = requestLabel(request);
1535
1671
  // The header's scheme, when it parsed; presence alone is still the server's half of the wire.
1536
1672
  const carried = request.authorization !== undefined
1537
1673
  ? `with \`Authorization: ${request.authorization} …\``
1538
1674
  : 'with an `Authorization` header';
1675
+ // A credential exchange asks the app to MINT a token: no token is expected to be attached, so a
1676
+ // refusal here is about the credentials themselves — never the client's omission (m165).
1677
+ if (isCredentialExchange(request, authEndpoints)) {
1678
+ const got = request.got
1679
+ ? ` got ${bodyLine(redactSecrets(request.got), TRACE_NOTE_GOT_CHARS)}`
1680
+ : '';
1681
+ return (`The browser sent \`${label}\`${got} and the API answered ${request.status} — this is a ` +
1682
+ 'credential exchange (a login/registration/token endpoint), so the client is not expected ' +
1683
+ 'to attach a token: the credentials were rejected. Check that the account the test logs in ' +
1684
+ 'with was created on THIS app — the same database and origin this run drives; a user seeded ' +
1685
+ 'somewhere else is unknown here — and read the response body’s `errors` (a login answers 401 ' +
1686
+ '`{"errors":{"credentials":["invalid"]}}` for a wrong email/password or a user that does not ' +
1687
+ 'exist here).');
1688
+ }
1539
1689
  // The console line names the mechanism the no-header branch otherwise has to guess at:
1540
1690
  // `getClient()` ran before `setupHttpDefault()` and the token was mirrored into the
1541
1691
  // client that later setup replaced (xr-molecule-flash-m156).
@@ -1573,21 +1723,23 @@ function authRefusalSentence(request, unbondedClient = false) {
1573
1723
  ? 'The request did carry a `Cookie` header, so if this app signs in with a session cookie ' +
1574
1724
  'that is the credential the server did not accept. '
1575
1725
  : '') +
1576
- 'The API handler is not the problem.' +
1577
1726
  unbonded);
1578
1727
  }
1579
1728
  /**
1580
1729
  * The note's credential-refusal advice, one sentence per distinct shape — at most two.
1581
1730
  *
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.
1731
+ * The shapes are `(status, Authorization scheme, cookie present, credential exchange)`: m148's ten
1732
+ * walks repeated one shape (`401`, no header) on every failing test, and repeating its sentence per
1733
+ * row would bury the fact rather than state it. A login and a protected request that both carry no
1734
+ * header are DIFFERENT shapes — one is the credentials being checked, the other is a token that was
1735
+ * never attached (m165).
1585
1736
  *
1586
1737
  * @param items - The traces the note is about.
1738
+ * @param authEndpoints - The endpoints the app's auth config names.
1587
1739
  * @returns The sentences to append (each led by a space), or '' when no same-origin 401/403 was
1588
1740
  * traced.
1589
1741
  */
1590
- function authRefusalAdvice(items) {
1742
+ function authRefusalAdvice(items, authEndpoints = []) {
1591
1743
  const sentences = [];
1592
1744
  const seen = new Set();
1593
1745
  // A page that logged `no client is bonded` explains every request it sent without a
@@ -1604,11 +1756,12 @@ function authRefusalAdvice(items) {
1604
1756
  // ignores it and clears the session, and the page owes the navigation (m156).
1605
1757
  if (isLogoutRequest(request))
1606
1758
  continue;
1607
- const key = `${request.status} ${request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
1759
+ const exchange = isCredentialExchange(request, authEndpoints);
1760
+ const key = `${request.status} ${exchange ? 'exchange' : request.sentAuthorization ? (request.authorization ?? 'header') : 'none'} ${request.sentCookie ? 'cookie' : ''}`;
1608
1761
  if (seen.has(key))
1609
1762
  continue;
1610
1763
  seen.add(key);
1611
- sentences.push(authRefusalSentence(request, unbondedClient));
1764
+ sentences.push(authRefusalSentence(request, unbondedClient, authEndpoints));
1612
1765
  if (sentences.length === 2)
1613
1766
  return ` ${sentences.join(' ')}`;
1614
1767
  }
@@ -1674,6 +1827,117 @@ function tracedApiPrefixes(items) {
1674
1827
  }
1675
1828
  return [...prefixes].sort();
1676
1829
  }
1830
+ /**
1831
+ * Every API path prefix the traces support: the one the app's client config names and the ones the
1832
+ * traces' own same-origin requests show ({@link tracedApiPrefixes}).
1833
+ *
1834
+ * @param items - The traces the note is about.
1835
+ * @param apiPrefixes - Prefixes the app's client config names (`['/api']`), never a literal.
1836
+ * @returns The one-segment prefixes, without duplicates.
1837
+ */
1838
+ function knownApiPrefixes(items, apiPrefixes) {
1839
+ const prefixes = new Set([...apiPrefixes, ...tracedApiPrefixes(items)]
1840
+ .map((prefix) => `/${String(prefix).replace(/^\/+|\/+$/g, '')}`)
1841
+ .filter((prefix) => prefix !== '/'));
1842
+ return [...prefixes];
1843
+ }
1844
+ /** A URL written in full (`https://api.realworld.show/api/articles`). */
1845
+ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
1846
+ /**
1847
+ * The API requests a trace made to an origin other than the page's own — set-up traffic that ran
1848
+ * against someone else's server.
1849
+ *
1850
+ * The trace reader relativises every request whose origin is the page's (`pathOf`), so a request
1851
+ * recorded with an absolute URL went to ANOTHER origin by construction. It counts as set-up/API
1852
+ * traffic when its path carries a prefix the app's own API is known to use ({@link
1853
+ * knownApiPrefixes}) — a CDN font or a third-party script has no `/api` under it, and with no
1854
+ * known prefix nothing is claimed.
1855
+ *
1856
+ * xr-molecule-flash-m165 (+10:22): the suite's helper defaulted `API_BASE` to
1857
+ * `https://api.realworld.show/api` and its set-up seeded rows there, while the browser drove this
1858
+ * app — whose own `/api` answers a login for none of those users. The trace's absolute-URL rows
1859
+ * were the evidence, and the note said nothing about them.
1860
+ *
1861
+ * @param items - The traces the note is about.
1862
+ * @param apiPrefixes - Prefixes the app's client config names (`['/api']`).
1863
+ * @returns One entry per distinct (origin, prefix), in trace order.
1864
+ */
1865
+ function foreignOriginApiRequests(items, apiPrefixes) {
1866
+ const prefixes = knownApiPrefixes(items, apiPrefixes);
1867
+ if (prefixes.length === 0)
1868
+ return [];
1869
+ const out = [];
1870
+ const seen = new Set();
1871
+ for (const item of items) {
1872
+ for (const request of item.requests) {
1873
+ const raw = String(request.path ?? '');
1874
+ if (!ABSOLUTE_URL.test(raw))
1875
+ continue;
1876
+ let parsed;
1877
+ try {
1878
+ parsed = new URL(raw);
1879
+ }
1880
+ catch (_error) {
1881
+ // Not a URL this can parse: it is not evidence of another origin.
1882
+ continue;
1883
+ }
1884
+ const prefix = prefixes.find((one) => parsed.pathname === one || parsed.pathname.startsWith(`${one}/`));
1885
+ if (prefix === undefined)
1886
+ continue;
1887
+ const key = `${parsed.origin}${prefix}`;
1888
+ if (seen.has(key))
1889
+ continue;
1890
+ seen.add(key);
1891
+ out.push({ origin: parsed.origin, prefix, request });
1892
+ }
1893
+ }
1894
+ return out;
1895
+ }
1896
+ /**
1897
+ * The origin a run's target names, when it names one: a URL (`http://localhost:4173`), or the
1898
+ * deployed host the note prints (`loopcheck-….apps.mlcl.dev`). Null for the note's descriptive
1899
+ * targets ("the deployed app", "the local app").
1900
+ *
1901
+ * @param target - Where the run went, as {@link failureTraceNote} names it.
1902
+ * @returns The origin, or null.
1903
+ */
1904
+ function appOriginOfTarget(target) {
1905
+ const text = target.trim();
1906
+ try {
1907
+ if (ABSOLUTE_URL.test(text))
1908
+ return new URL(text).origin;
1909
+ if (/^[a-z0-9.-]+(?::\d+)?$/i.test(text))
1910
+ return new URL(`https://${text}`).origin;
1911
+ }
1912
+ catch (_error) {
1913
+ // A target that is not a URL names no origin.
1914
+ }
1915
+ return null;
1916
+ }
1917
+ /**
1918
+ * The sentence naming the foreign origin a run's set-up traffic went to, and the setting that
1919
+ * points it at this app instead.
1920
+ *
1921
+ * It leads the note because it is the cheapest possible wrong result: every row the suite creates
1922
+ * lands in a database this app never reads, so the failure says nothing about the app's code.
1923
+ * xr-molecule-flash-m165 spent the walk after its failed login reading helpers and configs for a
1924
+ * missing token, when the command's `BASE_URL`/`API_BASE` disagreement was the cause.
1925
+ *
1926
+ * @param found - The foreign-origin request the trace holds.
1927
+ * @param target - Where the run went, for the app's own origin when the note knows it.
1928
+ * @returns The sentence, led by nothing and closed by a blank line.
1929
+ */
1930
+ function foreignOriginSentence(found, target) {
1931
+ const appOrigin = appOriginOfTarget(target);
1932
+ const where = appOrigin ?? 'this app’s origin';
1933
+ const apiBase = `${appOrigin ?? '<this app’s origin>'}${found.prefix}`;
1934
+ return (`The suite’s setup ran against ${found.origin}, not this app ` +
1935
+ `(\`${requestLabel(found.request)}\`) — its set-up/API traffic went to another origin while ` +
1936
+ `this app serves its own API at \`${found.prefix}\` on ${where}. Set ` +
1937
+ `\`API_BASE=${apiBase}\` on the run — the supplied suite reads it (verify the variable name ` +
1938
+ `in the suite’s own helpers before writing it) — so the rows it creates are in the database ` +
1939
+ `this app reads.\n\n`);
1940
+ }
1677
1941
  /**
1678
1942
  * The failed 404s whose path carries none of the known API prefixes.
1679
1943
  *
@@ -1687,12 +1951,9 @@ function tracedApiPrefixes(items) {
1687
1951
  * @returns One entry per distinct failed 404, in trace order.
1688
1952
  */
1689
1953
  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)
1954
+ const known = knownApiPrefixes(items, apiPrefixes);
1955
+ if (known.length === 0)
1694
1956
  return [];
1695
- const known = [...prefixes];
1696
1957
  const out = [];
1697
1958
  const seen = new Set();
1698
1959
  for (const item of items) {
@@ -1726,6 +1987,22 @@ function missingApiPrefixSentence(request) {
1726
1987
  `without the \`${request.prefix}\` prefix — the HTTP client's base URL is not set: is ` +
1727
1988
  '`setupProviders()` (which runs the HTTP bond setup) called in `main.tsx`?');
1728
1989
  }
1990
+ /**
1991
+ * The sentence for a failing assertion whose awaited text a same-page API response carried.
1992
+ *
1993
+ * It names the response and the fact the trace proves — the value reached the browser and the page
1994
+ * did not render it — and stops there. Which cut drops it (a slice, a filter, a limit) is in the
1995
+ * component that maps the response, and the note cannot see that component; naming the mapping
1996
+ * step is the whole instruction (xr-molecule-flash-m169).
1997
+ *
1998
+ * @param missing - The awaited text and the response that carried it.
1999
+ * @returns The sentence the note leads with.
2000
+ */
2001
+ function missingTextSentence(missing) {
2002
+ return (`The API returned "${missing.text}" (in ${missing.method} ${missing.path}), but the page did ` +
2003
+ `not render it — the page drops it before rendering (a slice/filter/limit in the component ` +
2004
+ `that renders that list); find where that response is mapped.`);
2005
+ }
1729
2006
  /**
1730
2007
  * The note a failed suite run's result carries: what each failing test's page actually sent, and
1731
2008
  * what came back.
@@ -1803,6 +2080,13 @@ function missingApiPrefixSentence(request) {
1803
2080
  * xr-molecule-flash-m148 (+29:18) hit this exactly: six local walks read
1804
2081
  * `POST …/favorite → 401 {"errors":{"token":["is missing"]}}` as a handler bug.
1805
2082
  *
2083
+ * A credential EXCHANGE (a login/registration/token endpoint, read from the app's auth config, the
2084
+ * path or the body) is never that shape: the request's purpose is to obtain a token, so an absent
2085
+ * header is not a client omission ({@link isCredentialExchange}). xr-molecule-flash-m165's login
2086
+ * 401 was answered as one for 40 minutes; the trace also showed the suite's SET-UP traffic going
2087
+ * to `https://api.realworld.show/api` instead of this app, which is what the note leads with
2088
+ * ({@link foreignOriginApiRequests}).
2089
+ *
1806
2090
  * @param items - The traces {@link parseTraceArtifacts} read.
1807
2091
  * @param target - Where the run went — the deployed host, as the note names it.
1808
2092
  * @param earlierFailures - The failing `file:line`s earlier walks of this suite left, so a repeat
@@ -1870,7 +2154,12 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
1870
2154
  // Every recorded failure is the logout endpoint answering 4xx: the row's own sentence explains
1871
2155
  // it, and neither the handler lead nor the credential reading applies (m156 +24:02).
1872
2156
  const onlyLogoutFailures = failedRequests.length > 0 && failedRequests.every((request) => isLogoutRequest(request));
1873
- const authAdvice = authRefusalAdvice(traced);
2157
+ const authAdvice = authRefusalAdvice(traced, report.authEndpoints ?? []);
2158
+ // Set-up traffic the trace shows going to an origin other than the app's (m165): the rows it
2159
+ // creates are not in this app's database, so the note says it before any app-side reading.
2160
+ const foreignOrigins = foreignOriginApiRequests(traced, apiPrefixes);
2161
+ const [firstForeign] = foreignOrigins;
2162
+ const setupOriginLead = firstForeign === undefined ? '' : foreignOriginSentence(firstForeign, target);
1874
2163
  // The stalled-navigation reading, per traced test: non-null only when the RUNNER says this
1875
2164
  // test's failing operation is a navigation and a document subresource still pending at the end
1876
2165
  // started inside that wait ({@link stalledNavigation}).
@@ -2098,7 +2387,24 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
2098
2387
  `does not define is always undefined, and a missing effect dependency on the route means ` +
2099
2388
  `the fetch never re-runs — before touching the markup or the CSS.\n\n`
2100
2389
  : '';
2101
- return (transportLead +
2390
+ // An assertion whose awaited text a same-page API response carried: the fact the run needs
2391
+ // first, before any row, is that the value arrived and the page dropped it (m169's missing
2392
+ // "trending" came back from GET /api/tags while the sidebar rendered a cut list). Each distinct
2393
+ // response is named once; the note makes no claim about which cut did it.
2394
+ const missingTexts = [
2395
+ ...new Map(traced
2396
+ .filter((item) => item.missingText !== undefined)
2397
+ .map((item) => {
2398
+ const missing = item.missingText;
2399
+ return [`${missing.method} ${missing.path}\u0000${missing.text}`, missing];
2400
+ })).values(),
2401
+ ];
2402
+ const missingTextLead = missingTexts.length === 0
2403
+ ? ''
2404
+ : `${missingTexts.slice(0, 2).map(missingTextSentence).join(' ')}\n\n`;
2405
+ return (missingTextLead +
2406
+ transportLead +
2407
+ setupOriginLead +
2102
2408
  stalledLead +
2103
2409
  staleRouteLead +
2104
2410
  `The platform read the failing tests' own Playwright traces from the run against ${target} ` +
@@ -2289,24 +2595,27 @@ async function readRunConfig(command, cwd, readFile) {
2289
2595
  return '';
2290
2596
  }
2291
2597
  /**
2292
- * The command a DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
2598
+ * The command a Playwright run needs to leave a readable trace behind, when the suite's config
2293
2599
  * would record none.
2294
2600
  *
2295
2601
  * The failing tests' requests are only readable if the run wrote them down (see the module doc),
2296
2602
  * 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.
2603
+ * config, so it passes the flag instead, on every Playwright run that starts tests and does not
2604
+ * already decide its own `--trace`. That includes the whole-suite proof run, which an earlier
2605
+ * version of this function skipped on the reasoning that its flags are graded: the flag changes
2606
+ * what is RETAINED, not what passes. `retain-on-failure` keeps the trace only of a test that
2607
+ * fails — Playwright records it and deletes it when the test passes (playwright's
2608
+ * `_shouldAbandonTrace` unlinks the temporary trace files of a passing test) — so a green proof
2609
+ * run keeps nothing extra. A failing one keeps the network log its failures need.
2610
+ * xr-molecule-flash-m163 (+24:58) is why the proof run is no longer skipped: its one deployed
2611
+ * failure left only an `error-context.md`, the executor spent the next calls hunting a `trace.zip`
2612
+ * that could not exist, and then shipped a guessed fix.
2301
2613
  *
2302
2614
  * @param args - The command, the directory it starts in, and the backend's file read.
2303
2615
  * @returns The command to run and the sentence to report, or the command unchanged.
2304
2616
  */
2305
2617
  export async function diagnosisTraceCommand(args) {
2306
2618
  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
2619
  if (namesTraceMode(args.command))
2311
2620
  return unchanged;
2312
2621
  // Only a Playwright run leaves a trace to read.
@@ -2330,7 +2639,8 @@ export async function diagnosisTraceCommand(args) {
2330
2639
  return {
2331
2640
  command: `${args.command.slice(0, at)} ${DIAGNOSIS_TRACE_FLAG}${args.command.slice(at)}`,
2332
2641
  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.',
2642
+ `${/trace:/.test(config) ? '' : ' (it sets none)'}, and a failing test's own network log is ` +
2643
+ 'what the platform reads the cause from. It keeps a trace only for a test that fails, so a ' +
2644
+ 'green run retains nothing.',
2335
2645
  };
2336
2646
  }
package/dist/types.d.ts CHANGED
@@ -49,6 +49,29 @@ export interface ExecutionBackend {
49
49
  exitCode: number;
50
50
  }>;
51
51
  }
52
+ /**
53
+ * The supplied suite's own settings, put back on a run whose runner config changes them.
54
+ *
55
+ * A graded suite is walked by the grader under its own per-test budget, worker count and
56
+ * timeouts. An executor's run that reads a config setting different numbers measures a run the
57
+ * grader never sees: a shorter budget fails tests the graded walk passes, a longer one passes
58
+ * tests the graded walk times out on. The platform that knows the suite supplies these values
59
+ * before the run ({@link ToolBuildConfig.suiteBudget}); the shared tool puts the flags on the
60
+ * runner's own command line, where Playwright lets them win over every config file.
61
+ */
62
+ export interface SuiteBudgetPin {
63
+ /**
64
+ * The Playwright flags that carry the suite's own settings onto the command
65
+ * (`--timeout=15000`, `--workers=1`), as the consumer's own scan reads them. Empty when the
66
+ * difference is in settings no flag carries — the pin is then the sentence alone.
67
+ */
68
+ flags: string[];
69
+ /**
70
+ * One sentence for the run's result: this run uses the suite's own budget, and which settings
71
+ * a config keeps because Playwright has no flag for them.
72
+ */
73
+ note: string;
74
+ }
52
75
  /**
53
76
  * Configuration for building the tool set.
54
77
  * Allows consumers to customize security, callbacks, and tool selection.
@@ -141,6 +164,22 @@ export interface ToolBuildConfig {
141
164
  * @returns The quoted titles the request carves out.
142
165
  */
143
166
  excludedTestTitles?: () => readonly string[];
167
+ /**
168
+ * The supplied suite's own budget, when the Playwright config a command would load sets
169
+ * different settings, asked BEFORE the run executes. The consumer knows where the suite and its
170
+ * base config live; this shared package does not, so the reading is the consumer's and the
171
+ * mechanics — putting the flags on the runner's own arguments, before the pipeline and outside
172
+ * any redirect or group closer — are this tool's.
173
+ *
174
+ * Called for a Playwright suite command only, and only the LAST suite step is touched. A run
175
+ * that already keeps the suite's settings, or a sandbox with no supplied suite, answers `null`.
176
+ * Unset (or a hook that throws) changes nothing: the command runs as the model wrote it.
177
+ *
178
+ * @param command - The shell command, as the model sent it.
179
+ * @param cwd - The resolved working directory it will run in.
180
+ * @returns The flags and the sentence, or null when nothing is put back.
181
+ */
182
+ suiteBudget?: (command: string, cwd: string) => Promise<SuiteBudgetPin | null | undefined>;
144
183
  /** Post-write hook (e.g. auto-format via Prettier/ESLint). Called after every write_file/edit_file. */
145
184
  onAfterWrite?: (path: string) => Promise<void>;
146
185
  /** Diff tracking callback. Called before writes with old/new content. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}