@molecule/api-ai-tools 1.6.5 → 1.7.0

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What a failed Playwright run's own trace files say about the requests its page made — read
3
+ * headlessly, in the sandbox, from the `trace.zip` files the run left behind.
4
+ *
5
+ * X1 xr-molecule-flash-m100 (+51:19 → +53:00, 2026-10-16) ended with three failures against the
6
+ * deployed URL — `social.spec.ts:22` and `:153` waited for a "Unfollow" button that never appeared
7
+ * after the click, and `url-navigation.spec.ts:234` was bounced to `/login` mid-test — and nothing
8
+ * in the run's result said why. The app's own `toggleFollow` swallowed the error of its request
9
+ * with a bare `catch` and a comment, so the page showed nothing; the ONE artifact
10
+ * that held the answer, the failing tests' own traces, was never read. The executor spent the
11
+ * last minutes of the run writing throwaway Chromium probes against localhost — where the same
12
+ * tests passed — and finished chasing an artifact of its own probe
13
+ * (`waitForURL('/')` resolved against a `baseURL` its script did not have).
14
+ *
15
+ * A trace is not a debugging aid that has to be enabled by hand: Playwright writes the request
16
+ * log and the console into `test-results/<test>/trace.zip` whenever a run retains traces, and the
17
+ * evidence a swallowed failure hides is exactly one line of it — `POST /api/articles/x/favorite →
18
+ * 401`. So the platform reads it (bounded, per test) and puts it in the run's result
19
+ * ({@link failureTraceNote}), instead of leaving the executor to build its own browser to find out.
20
+ *
21
+ * Everything here is text in, text out: the sandbox runs {@link traceArtifactsScript} (Node, no
22
+ * dependencies — Playwright's zip reader is not importable from a project) and prints one
23
+ * `TRACE <json>` line per trace; the platform parses those lines ({@link parseTraceArtifacts}) and
24
+ * writes the note ({@link failureTraceNote}). Both halves are covered by a test that runs the
25
+ * script against a `trace.zip` produced by a real Playwright run, in the sandbox's own shape.
26
+ *
27
+ * @module
28
+ */
29
+ /** One request a traced page made, as `*.network` records it. */
30
+ export interface TraceRequest {
31
+ /** The HTTP method (`POST`). */
32
+ method: string;
33
+ /** The request's path (`/api/articles/x/favorite`), or the whole URL when it is cross-origin. */
34
+ path: string;
35
+ /** The response status, or 0 when the request never got a response. */
36
+ status: number;
37
+ }
38
+ /** What one trace says: the test it belongs to, the requests it made, and its console errors. */
39
+ export interface TraceArtifact {
40
+ /** The failure's spec file, relative to the run's directory, when the trace names one. */
41
+ spec: string | null;
42
+ /** The line of the test's last traced step, 0 when the trace names no spec. */
43
+ line: number;
44
+ /** The requests the page made, the interesting ones first ({@link traceArtifactsScript}). */
45
+ requests: TraceRequest[];
46
+ /** The console errors and page errors the page raised. */
47
+ console: string[];
48
+ }
49
+ /** How many traces one read reports, newest first. */
50
+ export declare const MAX_TRACE_ARTIFACTS = 12;
51
+ /** How many requests one test's trace contributes. */
52
+ export declare const MAX_TRACED_REQUESTS = 6;
53
+ /** How many console errors one test's trace contributes. */
54
+ export declare const MAX_TRACED_CONSOLE = 4;
55
+ /** How many tests the note in a run's result names. */
56
+ export declare const MAX_TRACE_NOTE_TESTS = 5;
57
+ /** How many requests the note names per test. */
58
+ export declare const MAX_TRACE_NOTE_REQUESTS = 4;
59
+ /** How long a URL the note prints, before the middle is cut. */
60
+ export declare const TRACE_NOTE_URL_CHARS = 96;
61
+ /**
62
+ * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
63
+ * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
64
+ * them), each with the test it belongs to, the requests its page made and its console errors.
65
+ *
66
+ * It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
67
+ * Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
68
+ * on `unzip` would make the read fail on every sandbox that has none. The zip is read with the
69
+ * central directory and `zlib.inflateRawSync` — about forty lines, and the only thing that has to
70
+ * be true of a `trace.zip` is that it IS one.
71
+ *
72
+ * A request is kept over a static asset when it failed (`status >= 400`) or never answered
73
+ * (`status === 0`), and over anything else when it is the page talking to the app
74
+ * (`fetch`/`xhr`/`document`): a failing test's trace holds the whole page load, and the answer is
75
+ * always at the end of it.
76
+ *
77
+ * @returns The program's source, to write to a file and run.
78
+ */
79
+ export declare const traceArtifactsScript: () => string;
80
+ /**
81
+ * The traces {@link traceArtifactsScript} printed, parsed.
82
+ *
83
+ * A line that is not a complete `TRACE <json>` (a truncated read, a program that died mid-write) is
84
+ * dropped: a partial trace is not evidence about the test it came from.
85
+ *
86
+ * @param stdout - The program's output.
87
+ * @returns One entry per trace it printed.
88
+ */
89
+ export declare function parseTraceArtifacts(stdout: string): TraceArtifact[];
90
+ /**
91
+ * The note a failed suite run's result carries: what each failing test's page actually sent, and
92
+ * what came back.
93
+ *
94
+ * This is the answer to the question a swallowed error hides — a click that changed nothing is
95
+ * usually a request that failed — so the note leads with the failing requests and says what to do
96
+ * with them: read the handler the route reaches, not the selector. It is BOUNDED to
97
+ * {@link MAX_TRACE_NOTE_TESTS} tests with {@link MAX_TRACE_NOTE_REQUESTS} requests each, because it
98
+ * rides in a result the model pays for.
99
+ *
100
+ * @param items - The traces {@link parseTraceArtifacts} read.
101
+ * @param target - Where the run went — the deployed host, as the note names it.
102
+ * @returns The note, or null when no trace held a request or a console error.
103
+ */
104
+ export declare function failureTraceNote(items: readonly TraceArtifact[], target: string): string | null;
105
+ /**
106
+ * Whether a Playwright config records traces for the tests that fail.
107
+ *
108
+ * Only `on` and `retain-on-failure` keep a `trace.zip` for a failure whatever the retries do: the
109
+ * two retry modes need a retry to happen at all, and a diagnosis run is given `--retries=0`
110
+ * (`streamingBackgroundCommand`), so on X1 xr-molecule-flash-m100 the run's own
111
+ * `trace: 'on-first-retry'` recorded nothing — which is what left the failing tests' network log
112
+ * unreadable. A config that sets no `trace:` at all records nothing either (Playwright's default
113
+ * is off); so does one that cannot be read.
114
+ *
115
+ * @param configText - The config's source, or '' when none could be read.
116
+ * @returns True when the config records traces for failures.
117
+ */
118
+ export declare function suiteRecordsTraces(configText: string): boolean;
119
+ /** The `--trace` flag this platform adds, and the only value it adds it with. */
120
+ export declare const DIAGNOSIS_TRACE_FLAG = "--trace=retain-on-failure";
121
+ /** A command with the trace flag this platform decided to add, and the sentence to report it in. */
122
+ export interface DiagnosisTraceRewrite {
123
+ /** The command to run. */
124
+ command: string;
125
+ /** One sentence naming the flag and why, or null when nothing was added. */
126
+ change: string | null;
127
+ }
128
+ /**
129
+ * The command a DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
130
+ * would record none.
131
+ *
132
+ * The failing tests' requests are only readable if the run wrote them down (see the module doc),
133
+ * and the config is where the decision about that lives — the platform does not edit the suite's
134
+ * config, so it passes the flag instead, and only where it can do no harm: on a run narrowed to a
135
+ * part of the suite or capped ({@link isDiagnosisRun}), never on the whole-suite proof run, and
136
+ * never over a `--trace` the executor wrote itself. A capped walk is exactly the run whose
137
+ * failures someone is about to work on; the proof run is graded as it is.
138
+ *
139
+ * @param args - The command, the directory it starts in, and the backend's file read.
140
+ * @returns The command to run and the sentence to report, or the command unchanged.
141
+ */
142
+ export declare function diagnosisTraceCommand(args: {
143
+ command: string;
144
+ cwd: string;
145
+ readFile: (path: string) => Promise<string>;
146
+ }): Promise<DiagnosisTraceRewrite>;
147
+ //# sourceMappingURL=trace-artifacts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAKH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AAuE/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAgBjC"}
@@ -0,0 +1,467 @@
1
+ /**
2
+ * What a failed Playwright run's own trace files say about the requests its page made — read
3
+ * headlessly, in the sandbox, from the `trace.zip` files the run left behind.
4
+ *
5
+ * X1 xr-molecule-flash-m100 (+51:19 → +53:00, 2026-10-16) ended with three failures against the
6
+ * deployed URL — `social.spec.ts:22` and `:153` waited for a "Unfollow" button that never appeared
7
+ * after the click, and `url-navigation.spec.ts:234` was bounced to `/login` mid-test — and nothing
8
+ * in the run's result said why. The app's own `toggleFollow` swallowed the error of its request
9
+ * with a bare `catch` and a comment, so the page showed nothing; the ONE artifact
10
+ * that held the answer, the failing tests' own traces, was never read. The executor spent the
11
+ * last minutes of the run writing throwaway Chromium probes against localhost — where the same
12
+ * tests passed — and finished chasing an artifact of its own probe
13
+ * (`waitForURL('/')` resolved against a `baseURL` its script did not have).
14
+ *
15
+ * A trace is not a debugging aid that has to be enabled by hand: Playwright writes the request
16
+ * log and the console into `test-results/<test>/trace.zip` whenever a run retains traces, and the
17
+ * evidence a swallowed failure hides is exactly one line of it — `POST /api/articles/x/favorite →
18
+ * 401`. So the platform reads it (bounded, per test) and puts it in the run's result
19
+ * ({@link failureTraceNote}), instead of leaving the executor to build its own browser to find out.
20
+ *
21
+ * Everything here is text in, text out: the sandbox runs {@link traceArtifactsScript} (Node, no
22
+ * dependencies — Playwright's zip reader is not importable from a project) and prints one
23
+ * `TRACE <json>` line per trace; the platform parses those lines ({@link parseTraceArtifacts}) and
24
+ * writes the note ({@link failureTraceNote}). Both halves are covered by a test that runs the
25
+ * script against a `trace.zip` produced by a real Playwright run, in the sandbox's own shape.
26
+ *
27
+ * @module
28
+ */
29
+ import { isDiagnosisRun } from './suite-overlap.js';
30
+ import { isPlaywrightRunCommand } from './utilities.js';
31
+ /** How many traces one read reports, newest first. */
32
+ export const MAX_TRACE_ARTIFACTS = 12;
33
+ /** How many requests one test's trace contributes. */
34
+ export const MAX_TRACED_REQUESTS = 6;
35
+ /** How many console errors one test's trace contributes. */
36
+ export const MAX_TRACED_CONSOLE = 4;
37
+ /** How many tests the note in a run's result names. */
38
+ export const MAX_TRACE_NOTE_TESTS = 5;
39
+ /** How many requests the note names per test. */
40
+ export const MAX_TRACE_NOTE_REQUESTS = 4;
41
+ /** How long a URL the note prints, before the middle is cut. */
42
+ export const TRACE_NOTE_URL_CHARS = 96;
43
+ /**
44
+ * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
45
+ * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
46
+ * them), each with the test it belongs to, the requests its page made and its console errors.
47
+ *
48
+ * It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
49
+ * Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
50
+ * on `unzip` would make the read fail on every sandbox that has none. The zip is read with the
51
+ * central directory and `zlib.inflateRawSync` — about forty lines, and the only thing that has to
52
+ * be true of a `trace.zip` is that it IS one.
53
+ *
54
+ * A request is kept over a static asset when it failed (`status >= 400`) or never answered
55
+ * (`status === 0`), and over anything else when it is the page talking to the app
56
+ * (`fetch`/`xhr`/`document`): a failing test's trace holds the whole page load, and the answer is
57
+ * always at the end of it.
58
+ *
59
+ * @returns The program's source, to write to a file and run.
60
+ */
61
+ export const traceArtifactsScript = () => `
62
+ const fs = require('node:fs')
63
+ const path = require('node:path')
64
+ const zlib = require('node:zlib')
65
+
66
+ const ROOT = process.argv[2] || '/workspace'
67
+ const MAX_TRACES = ${MAX_TRACE_ARTIFACTS}
68
+ const MAX_REQUESTS = ${MAX_TRACED_REQUESTS}
69
+ const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
70
+ const MAX_URL = 200
71
+
72
+ function walk(dir, depth, out) {
73
+ if (depth < 0) return
74
+ let entries
75
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return }
76
+ for (const entry of entries) {
77
+ if (entry.name === 'node_modules' || entry.name === '.git') continue
78
+ const full = path.join(dir, entry.name)
79
+ if (entry.isDirectory()) { walk(full, depth - 1, out); continue }
80
+ if (entry.name !== 'trace.zip') continue
81
+ let mtime = 0
82
+ try { mtime = fs.statSync(full).mtimeMs } catch (e) { mtime = 0 }
83
+ out.push({ file: full, mtime: mtime })
84
+ }
85
+ }
86
+
87
+ function zipEntries(buf) {
88
+ let eocd = -1
89
+ const floor = Math.max(0, buf.length - 66000)
90
+ for (let i = buf.length - 22; i >= floor; i--) {
91
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
92
+ }
93
+ if (eocd === -1) return []
94
+ const count = buf.readUInt16LE(eocd + 10)
95
+ let at = buf.readUInt32LE(eocd + 16)
96
+ const out = []
97
+ for (let n = 0; n < count; n++) {
98
+ if (at + 46 > buf.length || buf.readUInt32LE(at) !== 0x02014b50) break
99
+ const nameLen = buf.readUInt16LE(at + 28)
100
+ const extraLen = buf.readUInt16LE(at + 30)
101
+ const commentLen = buf.readUInt16LE(at + 32)
102
+ out.push({
103
+ name: buf.toString('utf8', at + 46, at + 46 + nameLen),
104
+ method: buf.readUInt16LE(at + 10),
105
+ size: buf.readUInt32LE(at + 20),
106
+ at: buf.readUInt32LE(at + 42),
107
+ })
108
+ at += 46 + nameLen + extraLen + commentLen
109
+ }
110
+ return out
111
+ }
112
+
113
+ function zipData(buf, entry) {
114
+ const at = entry.at
115
+ if (at + 30 > buf.length || buf.readUInt32LE(at) !== 0x04034b50) return null
116
+ const start = at + 30 + buf.readUInt16LE(at + 26) + buf.readUInt16LE(at + 28)
117
+ const raw = buf.subarray(start, start + entry.size)
118
+ if (entry.method === 0) return raw
119
+ if (entry.method === 8) { try { return zlib.inflateRawSync(raw) } catch (e) { return null } }
120
+ return null
121
+ }
122
+
123
+ function ndjson(buf) {
124
+ const out = []
125
+ if (!buf) return out
126
+ for (const line of buf.toString('utf8').split('\\n')) {
127
+ if (!line) continue
128
+ try { out.push(JSON.parse(line)) } catch (e) { /* a partial final line */ }
129
+ }
130
+ return out
131
+ }
132
+
133
+ function messageOf(value) {
134
+ let at = value
135
+ for (let i = 0; i < 4 && at && typeof at === 'object'; i++) {
136
+ if (typeof at.message === 'string') return at.message
137
+ at = at.error
138
+ }
139
+ return null
140
+ }
141
+
142
+ let origin = ''
143
+ function pathOf(url) {
144
+ try {
145
+ const parsed = new URL(url)
146
+ return parsed.origin === origin ? parsed.pathname + parsed.search : url
147
+ } catch (e) { return url }
148
+ }
149
+ function originOf(url) {
150
+ try { return new URL(url).origin } catch (e) { return '' }
151
+ }
152
+ const SUITE_DIRS = ['e2e', 'specs', 'tests', 'test', '__tests__']
153
+ function specPathOf(file) {
154
+ const parts = file.split('/')
155
+ for (let i = 0; i < parts.length - 1; i++) {
156
+ if (SUITE_DIRS.indexOf(parts[i]) !== -1) return parts.slice(i).join('/')
157
+ }
158
+ const rel = path.relative(ROOT, file)
159
+ return rel && rel.slice(0, 2) !== '..' ? rel : file
160
+ }
161
+
162
+ const found = []
163
+ walk(ROOT, 5, found)
164
+ found.sort(function (a, b) { return b.mtime - a.mtime })
165
+
166
+ for (const one of found.slice(0, MAX_TRACES)) {
167
+ let buf
168
+ try { buf = fs.readFileSync(one.file) } catch (e) { continue }
169
+ let entries
170
+ try { entries = zipEntries(buf) } catch (e) { continue }
171
+ const network = []
172
+ const trace = []
173
+ for (const entry of entries) {
174
+ if (/\\.network$/.test(entry.name)) network.push.apply(network, ndjson(zipData(buf, entry)))
175
+ else if (/\\.trace$/.test(entry.name)) trace.push.apply(trace, ndjson(zipData(buf, entry)))
176
+ }
177
+ const requests = []
178
+ for (const line of network) {
179
+ if (!line || line.type !== 'resource-snapshot') continue
180
+ const snapshot = line.snapshot || {}
181
+ const request = snapshot.request || {}
182
+ if (!request.url || !request.method) continue
183
+ const response = snapshot.response || {}
184
+ const type = String(snapshot._resourceType || '')
185
+ if (!origin) origin = type === 'document' ? originOf(String(request.url)) : ''
186
+ requests.push({
187
+ method: String(request.method).toUpperCase(),
188
+ path: String(request.url).slice(0, MAX_URL),
189
+ status: Number(response.status) || 0,
190
+ type: type,
191
+ })
192
+ }
193
+ if (!origin) {
194
+ const first = requests.find(function (r) { return r.type === 'document' }) || requests[0]
195
+ origin = first ? originOf(first.path) : ''
196
+ }
197
+ for (const r of requests) r.path = pathOf(r.path).slice(0, MAX_URL)
198
+ const failing = []
199
+ const api = []
200
+ const rest = []
201
+ for (let i = 0; i < requests.length; i++) {
202
+ const r = requests[i]
203
+ if (r.status === 0 || r.status >= 400) failing.push(i)
204
+ else if (r.type === 'fetch' || r.type === 'xhr' || r.type === 'document') api.push(i)
205
+ else rest.push(i)
206
+ }
207
+ const keep = failing.slice(-MAX_REQUESTS).concat(api.slice(-MAX_REQUESTS)).concat(rest.slice(-2))
208
+ const chosen = []
209
+ const seen = new Set()
210
+ for (const i of keep.sort(function (a, b) {
211
+ const wrong = requests[a].status === 0 || requests[a].status >= 400 ? 0 : 1
212
+ const other = requests[b].status === 0 || requests[b].status >= 400 ? 0 : 1
213
+ return wrong === other ? a - b : wrong - other
214
+ })) {
215
+ const r = requests[i]
216
+ const key = r.method + ' ' + r.path + ' ' + r.status
217
+ if (seen.has(key)) continue
218
+ seen.add(key)
219
+ chosen.push({ method: r.method, path: r.path, status: r.status })
220
+ }
221
+ const consoleErrors = []
222
+ let spec = null
223
+ let line = 0
224
+ for (const entry of trace) {
225
+ if (!entry) continue
226
+ if (entry.type === 'console' && entry.messageType === 'error' && typeof entry.text === 'string')
227
+ consoleErrors.push(entry.text)
228
+ else if (entry.type === 'event' && entry.method === 'pageError') {
229
+ const message = messageOf(entry.params)
230
+ if (message) consoleErrors.push(message)
231
+ } else if (entry.type === 'before' && Array.isArray(entry.stack) && entry.stack.length) {
232
+ const frame = entry.stack[0] || {}
233
+ if (typeof frame.file === 'string' && /\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) {
234
+ spec = specPathOf(frame.file)
235
+ line = Number(frame.line) || 0
236
+ }
237
+ }
238
+ }
239
+ // Chromium reports every failed request as a console error too; where the page logged something
240
+ // of its own about it, that is the line worth reading.
241
+ const specific = consoleErrors.filter(function (text) { return !/^Failed to load resource/.test(text) })
242
+ const keptConsole = specific.length > 0 ? specific : consoleErrors
243
+ if (chosen.length === 0 && keptConsole.length === 0) continue
244
+ process.stdout.write('TRACE ' + JSON.stringify({
245
+ dir: path.relative(ROOT, path.dirname(one.file)),
246
+ spec: spec,
247
+ line: line,
248
+ requests: chosen.slice(-MAX_REQUESTS),
249
+ console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
250
+ }) + '\\n')
251
+ }
252
+ `;
253
+ /**
254
+ * The traces {@link traceArtifactsScript} printed, parsed.
255
+ *
256
+ * A line that is not a complete `TRACE <json>` (a truncated read, a program that died mid-write) is
257
+ * dropped: a partial trace is not evidence about the test it came from.
258
+ *
259
+ * @param stdout - The program's output.
260
+ * @returns One entry per trace it printed.
261
+ */
262
+ export function parseTraceArtifacts(stdout) {
263
+ const out = [];
264
+ for (const line of stdout.split('\n')) {
265
+ if (!line.startsWith('TRACE '))
266
+ continue;
267
+ try {
268
+ const raw = JSON.parse(line.slice('TRACE '.length));
269
+ const requests = Array.isArray(raw.requests) ? raw.requests : [];
270
+ const consoleMessages = Array.isArray(raw.console) ? raw.console : [];
271
+ out.push({
272
+ spec: typeof raw.spec === 'string' ? raw.spec : null,
273
+ line: typeof raw.line === 'number' ? raw.line : 0,
274
+ requests: requests
275
+ .filter((r) => typeof r === 'object' && r !== null)
276
+ .map((r) => ({
277
+ method: String(r.method ?? ''),
278
+ path: String(r.path ?? ''),
279
+ status: Number(r.status ?? 0) || 0,
280
+ })),
281
+ console: consoleMessages.map(String),
282
+ });
283
+ }
284
+ catch (_error) {
285
+ continue;
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ /** Where a trace's test is: the spec and line it names, else the folder Playwright wrote it in. */
291
+ function whereOf(item) {
292
+ if (!item.spec)
293
+ return 'a failing test';
294
+ return item.line > 0 ? `${item.spec}:${item.line}` : item.spec;
295
+ }
296
+ /** One request as the note writes it: `POST /api/articles/x/favorite → 401`. */
297
+ function requestLine(request) {
298
+ const url = request.path.length > TRACE_NOTE_URL_CHARS
299
+ ? `${request.path.slice(0, TRACE_NOTE_URL_CHARS)}…`
300
+ : request.path;
301
+ return `${request.method} ${url} → ${request.status === 0 ? 'no response' : request.status}`;
302
+ }
303
+ /**
304
+ * The note a failed suite run's result carries: what each failing test's page actually sent, and
305
+ * what came back.
306
+ *
307
+ * This is the answer to the question a swallowed error hides — a click that changed nothing is
308
+ * usually a request that failed — so the note leads with the failing requests and says what to do
309
+ * with them: read the handler the route reaches, not the selector. It is BOUNDED to
310
+ * {@link MAX_TRACE_NOTE_TESTS} tests with {@link MAX_TRACE_NOTE_REQUESTS} requests each, because it
311
+ * rides in a result the model pays for.
312
+ *
313
+ * @param items - The traces {@link parseTraceArtifacts} read.
314
+ * @param target - Where the run went — the deployed host, as the note names it.
315
+ * @returns The note, or null when no trace held a request or a console error.
316
+ */
317
+ export function failureTraceNote(items, target) {
318
+ const withEvidence = items.filter((item) => item.requests.length > 0 || item.console.length > 0);
319
+ if (withEvidence.length === 0)
320
+ return null;
321
+ const rows = [];
322
+ for (const [index, item] of withEvidence.slice(0, MAX_TRACE_NOTE_TESTS).entries()) {
323
+ const requests = item.requests.slice(-MAX_TRACE_NOTE_REQUESTS).map(requestLine);
324
+ const parts = [];
325
+ if (requests.length > 0)
326
+ parts.push(requests.join('; '));
327
+ if (item.console.length > 0)
328
+ parts.push(`console: ${item.console
329
+ .slice(0, 2)
330
+ .map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 160))
331
+ .join(' | ')}`);
332
+ rows.push(` ${index + 1}. ${whereOf(item)} — ${parts.join(' — ')}`);
333
+ }
334
+ const more = withEvidence.length > MAX_TRACE_NOTE_TESTS
335
+ ? `\n … and ${withEvidence.length - MAX_TRACE_NOTE_TESTS} more traced failures`
336
+ : '';
337
+ return (`The platform read the failing tests' own Playwright traces from the run against ${target} ` +
338
+ `(the last requests each page made and what came back):\n${rows.join('\n')}${more}\n` +
339
+ 'A click that changed nothing is usually one of these requests failing, not the selector: ' +
340
+ 'read the handler this route reaches in your own source, and stop `catch`-ing the request of a ' +
341
+ 'user action silently — log it.');
342
+ }
343
+ /** The Playwright trace modes that leave a zip behind for a FAILING test, whatever the retries. */
344
+ const RECORDING_TRACE_MODES = ['on', 'retain-on-failure'];
345
+ /**
346
+ * A `trace:` setting and its value, however the config spells it (`trace: 'on'`,
347
+ * `trace: "on-first-retry"`, `trace: { mode: 'retain-on-failure', screenshots: true }`). The value
348
+ * is captured from the object form's `mode` or from the plain one.
349
+ */
350
+ const TRACE_SETTING = /trace:\s*(?:\{[^{}]*?mode:\s*)?['"]?(on-first-retry|on-all-retries|retain-on-failure|on|off)(?![\w-])/g;
351
+ /**
352
+ * Whether a Playwright config records traces for the tests that fail.
353
+ *
354
+ * Only `on` and `retain-on-failure` keep a `trace.zip` for a failure whatever the retries do: the
355
+ * two retry modes need a retry to happen at all, and a diagnosis run is given `--retries=0`
356
+ * (`streamingBackgroundCommand`), so on X1 xr-molecule-flash-m100 the run's own
357
+ * `trace: 'on-first-retry'` recorded nothing — which is what left the failing tests' network log
358
+ * unreadable. A config that sets no `trace:` at all records nothing either (Playwright's default
359
+ * is off); so does one that cannot be read.
360
+ *
361
+ * @param configText - The config's source, or '' when none could be read.
362
+ * @returns True when the config records traces for failures.
363
+ */
364
+ export function suiteRecordsTraces(configText) {
365
+ const text = configText.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
366
+ return [...text.matchAll(TRACE_SETTING)].some((m) => RECORDING_TRACE_MODES.includes(m[1] ?? ''));
367
+ }
368
+ /** The `--trace` flag this platform adds, and the only value it adds it with. */
369
+ export const DIAGNOSIS_TRACE_FLAG = '--trace=retain-on-failure';
370
+ /**
371
+ * Whether a command already decides its own tracing: `--trace=on`, `--trace retain-on-failure`.
372
+ * A run that names one is left exactly as the executor wrote it.
373
+ */
374
+ function namesTraceMode(command) {
375
+ return /(?:^|[\s;&|(])--trace(?:=|\s)/.test(command);
376
+ }
377
+ /** The config paths a candidate directory could hold, in the order Playwright itself looks. */
378
+ const CONFIG_NAMES = [
379
+ 'playwright.config.ts',
380
+ 'playwright.config.mts',
381
+ 'playwright.config.js',
382
+ 'playwright.config.mjs',
383
+ 'playwright.config.cjs',
384
+ ];
385
+ /** The directories a command changes into, in order, as it writes them. */
386
+ function cdDirectories(command) {
387
+ const out = [];
388
+ for (const m of command.matchAll(/(?:^|[\s;&|(])cd\s+(?:'([^']+)'|"([^"]+)"|([^\s;&|)]+))/g)) {
389
+ const dir = m[1] ?? m[2] ?? m[3];
390
+ if (dir && !out.includes(dir))
391
+ out.push(dir);
392
+ }
393
+ return out;
394
+ }
395
+ /** A path joined under a base, unless it is already absolute. */
396
+ function under(base, path) {
397
+ if (path.startsWith('/'))
398
+ return path;
399
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`;
400
+ }
401
+ /**
402
+ * The config a Playwright command reads, read from the backend — the file its own `--config`
403
+ * names (resolved against the directory the command starts in, then against each `cd` it does
404
+ * first), else the first `playwright.config.*` in those directories.
405
+ *
406
+ * Best-effort and bounded: at most four candidate files are read, and a read that fails is a
407
+ * config that records nothing — the caller's `--trace` flag is what makes a missing trace
408
+ * impossible either way.
409
+ *
410
+ * @param command - The shell command.
411
+ * @param cwd - The directory the command starts in.
412
+ * @param readFile - The backend's file read.
413
+ * @returns The config's text, or '' when none could be read.
414
+ */
415
+ async function readRunConfig(command, cwd, readFile) {
416
+ const bases = [cwd, ...cdDirectories(command).map((dir) => under(cwd, dir))].slice(0, 2);
417
+ const config = /(?:^|[\s;&|(])--config(?:=|\s+)(?:'([^']+)'|"([^"]+)"|([^\s;&|)]+))/.exec(command);
418
+ const explicit = config?.[1] ?? config?.[2] ?? config?.[3] ?? null;
419
+ const candidates = explicit
420
+ ? bases.map((base) => under(base, explicit))
421
+ : bases.flatMap((base) => CONFIG_NAMES.map((name) => `${base.replace(/\/+$/, '')}/${name}`));
422
+ for (const candidate of candidates.slice(0, 4)) {
423
+ try {
424
+ const text = await readFile(candidate);
425
+ if (text)
426
+ return text;
427
+ }
428
+ catch (_error) {
429
+ continue;
430
+ }
431
+ }
432
+ return '';
433
+ }
434
+ /**
435
+ * The command a DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
436
+ * would record none.
437
+ *
438
+ * The failing tests' requests are only readable if the run wrote them down (see the module doc),
439
+ * and the config is where the decision about that lives — the platform does not edit the suite's
440
+ * config, so it passes the flag instead, and only where it can do no harm: on a run narrowed to a
441
+ * part of the suite or capped ({@link isDiagnosisRun}), never on the whole-suite proof run, and
442
+ * never over a `--trace` the executor wrote itself. A capped walk is exactly the run whose
443
+ * failures someone is about to work on; the proof run is graded as it is.
444
+ *
445
+ * @param args - The command, the directory it starts in, and the backend's file read.
446
+ * @returns The command to run and the sentence to report, or the command unchanged.
447
+ */
448
+ export async function diagnosisTraceCommand(args) {
449
+ const unchanged = { command: args.command, change: null };
450
+ // The proof run is the one run this must never touch: its flags are graded.
451
+ if (!isDiagnosisRun(args.command))
452
+ return unchanged;
453
+ if (namesTraceMode(args.command))
454
+ return unchanged;
455
+ // Only a Playwright run leaves a trace to read.
456
+ if (!isPlaywrightRunCommand(args.command))
457
+ return unchanged;
458
+ const config = await readRunConfig(args.command, args.cwd, args.readFile);
459
+ if (suiteRecordsTraces(config))
460
+ return unchanged;
461
+ return {
462
+ command: `${args.command.trimEnd()} ${DIAGNOSIS_TRACE_FLAG}`,
463
+ change: `Added \`${DIAGNOSIS_TRACE_FLAG}\`: the suite's config records no trace for a failure` +
464
+ `${/trace:/.test(config) ? '' : ' (it sets none)'}, and this diagnosis run's failures are ` +
465
+ 'what the platform reads the network log from. The proof run keeps the suite’s own settings.',
466
+ };
467
+ }