@molecule/api-ai-tools 1.6.6 → 1.8.1

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;AAMH,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;AAuG/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,CAyBjC"}
@@ -0,0 +1,511 @@
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 { commandSegments } from './command-position.js';
30
+ import { isDiagnosisRun } from './suite-overlap.js';
31
+ import { isPlaywrightRunCommand } from './utilities.js';
32
+ /** How many traces one read reports, newest first. */
33
+ export const MAX_TRACE_ARTIFACTS = 12;
34
+ /** How many requests one test's trace contributes. */
35
+ export const MAX_TRACED_REQUESTS = 6;
36
+ /** How many console errors one test's trace contributes. */
37
+ export const MAX_TRACED_CONSOLE = 4;
38
+ /** How many tests the note in a run's result names. */
39
+ export const MAX_TRACE_NOTE_TESTS = 5;
40
+ /** How many requests the note names per test. */
41
+ export const MAX_TRACE_NOTE_REQUESTS = 4;
42
+ /** How long a URL the note prints, before the middle is cut. */
43
+ export const TRACE_NOTE_URL_CHARS = 96;
44
+ /**
45
+ * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
46
+ * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
47
+ * them), each with the test it belongs to, the requests its page made and its console errors.
48
+ *
49
+ * It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
50
+ * Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
51
+ * on `unzip` would make the read fail on every sandbox that has none. The zip is read with the
52
+ * central directory and `zlib.inflateRawSync` — about forty lines, and the only thing that has to
53
+ * be true of a `trace.zip` is that it IS one.
54
+ *
55
+ * A request is kept over a static asset when it failed (`status >= 400`) or never answered
56
+ * (`status === 0`), and over anything else when it is the page talking to the app
57
+ * (`fetch`/`xhr`/`document`): a failing test's trace holds the whole page load, and the answer is
58
+ * always at the end of it.
59
+ *
60
+ * @returns The program's source, to write to a file and run.
61
+ */
62
+ export const traceArtifactsScript = () => `
63
+ const fs = require('node:fs')
64
+ const path = require('node:path')
65
+ const zlib = require('node:zlib')
66
+
67
+ const ROOT = process.argv[2] || '/workspace'
68
+ const MAX_TRACES = ${MAX_TRACE_ARTIFACTS}
69
+ const MAX_REQUESTS = ${MAX_TRACED_REQUESTS}
70
+ const MAX_CONSOLE = ${MAX_TRACED_CONSOLE}
71
+ const MAX_URL = 200
72
+
73
+ function walk(dir, depth, out) {
74
+ if (depth < 0) return
75
+ let entries
76
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return }
77
+ for (const entry of entries) {
78
+ if (entry.name === 'node_modules' || entry.name === '.git') continue
79
+ const full = path.join(dir, entry.name)
80
+ if (entry.isDirectory()) { walk(full, depth - 1, out); continue }
81
+ if (entry.name !== 'trace.zip') continue
82
+ let mtime = 0
83
+ try { mtime = fs.statSync(full).mtimeMs } catch (e) { mtime = 0 }
84
+ out.push({ file: full, mtime: mtime })
85
+ }
86
+ }
87
+
88
+ function zipEntries(buf) {
89
+ let eocd = -1
90
+ const floor = Math.max(0, buf.length - 66000)
91
+ for (let i = buf.length - 22; i >= floor; i--) {
92
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
93
+ }
94
+ if (eocd === -1) return []
95
+ const count = buf.readUInt16LE(eocd + 10)
96
+ let at = buf.readUInt32LE(eocd + 16)
97
+ const out = []
98
+ for (let n = 0; n < count; n++) {
99
+ if (at + 46 > buf.length || buf.readUInt32LE(at) !== 0x02014b50) break
100
+ const nameLen = buf.readUInt16LE(at + 28)
101
+ const extraLen = buf.readUInt16LE(at + 30)
102
+ const commentLen = buf.readUInt16LE(at + 32)
103
+ out.push({
104
+ name: buf.toString('utf8', at + 46, at + 46 + nameLen),
105
+ method: buf.readUInt16LE(at + 10),
106
+ size: buf.readUInt32LE(at + 20),
107
+ at: buf.readUInt32LE(at + 42),
108
+ })
109
+ at += 46 + nameLen + extraLen + commentLen
110
+ }
111
+ return out
112
+ }
113
+
114
+ function zipData(buf, entry) {
115
+ const at = entry.at
116
+ if (at + 30 > buf.length || buf.readUInt32LE(at) !== 0x04034b50) return null
117
+ const start = at + 30 + buf.readUInt16LE(at + 26) + buf.readUInt16LE(at + 28)
118
+ const raw = buf.subarray(start, start + entry.size)
119
+ if (entry.method === 0) return raw
120
+ if (entry.method === 8) { try { return zlib.inflateRawSync(raw) } catch (e) { return null } }
121
+ return null
122
+ }
123
+
124
+ function ndjson(buf) {
125
+ const out = []
126
+ if (!buf) return out
127
+ for (const line of buf.toString('utf8').split('\\n')) {
128
+ if (!line) continue
129
+ try { out.push(JSON.parse(line)) } catch (e) { /* a partial final line */ }
130
+ }
131
+ return out
132
+ }
133
+
134
+ function messageOf(value) {
135
+ let at = value
136
+ for (let i = 0; i < 4 && at && typeof at === 'object'; i++) {
137
+ if (typeof at.message === 'string') return at.message
138
+ at = at.error
139
+ }
140
+ return null
141
+ }
142
+
143
+ let origin = ''
144
+ function pathOf(url) {
145
+ try {
146
+ const parsed = new URL(url)
147
+ return parsed.origin === origin ? parsed.pathname + parsed.search : url
148
+ } catch (e) { return url }
149
+ }
150
+ function originOf(url) {
151
+ try { return new URL(url).origin } catch (e) { return '' }
152
+ }
153
+ const SUITE_DIRS = ['e2e', 'specs', 'tests', 'test', '__tests__']
154
+ function specPathOf(file) {
155
+ const parts = file.split('/')
156
+ for (let i = 0; i < parts.length - 1; i++) {
157
+ if (SUITE_DIRS.indexOf(parts[i]) !== -1) return parts.slice(i).join('/')
158
+ }
159
+ const rel = path.relative(ROOT, file)
160
+ return rel && rel.slice(0, 2) !== '..' ? rel : file
161
+ }
162
+
163
+ const found = []
164
+ walk(ROOT, 5, found)
165
+ found.sort(function (a, b) { return b.mtime - a.mtime })
166
+
167
+ for (const one of found.slice(0, MAX_TRACES)) {
168
+ let buf
169
+ try { buf = fs.readFileSync(one.file) } catch (e) { continue }
170
+ let entries
171
+ try { entries = zipEntries(buf) } catch (e) { continue }
172
+ const network = []
173
+ const trace = []
174
+ for (const entry of entries) {
175
+ if (/\\.network$/.test(entry.name)) network.push.apply(network, ndjson(zipData(buf, entry)))
176
+ else if (/\\.trace$/.test(entry.name)) trace.push.apply(trace, ndjson(zipData(buf, entry)))
177
+ }
178
+ const requests = []
179
+ for (const line of network) {
180
+ if (!line || line.type !== 'resource-snapshot') continue
181
+ const snapshot = line.snapshot || {}
182
+ const request = snapshot.request || {}
183
+ if (!request.url || !request.method) continue
184
+ const response = snapshot.response || {}
185
+ const type = String(snapshot._resourceType || '')
186
+ if (!origin) origin = type === 'document' ? originOf(String(request.url)) : ''
187
+ requests.push({
188
+ method: String(request.method).toUpperCase(),
189
+ path: String(request.url).slice(0, MAX_URL),
190
+ status: Number(response.status) || 0,
191
+ type: type,
192
+ })
193
+ }
194
+ if (!origin) {
195
+ const first = requests.find(function (r) { return r.type === 'document' }) || requests[0]
196
+ origin = first ? originOf(first.path) : ''
197
+ }
198
+ for (const r of requests) r.path = pathOf(r.path).slice(0, MAX_URL)
199
+ const failing = []
200
+ const api = []
201
+ const rest = []
202
+ for (let i = 0; i < requests.length; i++) {
203
+ const r = requests[i]
204
+ if (r.status === 0 || r.status >= 400) failing.push(i)
205
+ else if (r.type === 'fetch' || r.type === 'xhr' || r.type === 'document') api.push(i)
206
+ else rest.push(i)
207
+ }
208
+ const keep = failing.slice(-MAX_REQUESTS).concat(api.slice(-MAX_REQUESTS)).concat(rest.slice(-2))
209
+ const chosen = []
210
+ const seen = new Set()
211
+ for (const i of keep.sort(function (a, b) {
212
+ const wrong = requests[a].status === 0 || requests[a].status >= 400 ? 0 : 1
213
+ const other = requests[b].status === 0 || requests[b].status >= 400 ? 0 : 1
214
+ return wrong === other ? a - b : wrong - other
215
+ })) {
216
+ const r = requests[i]
217
+ const key = r.method + ' ' + r.path + ' ' + r.status
218
+ if (seen.has(key)) continue
219
+ seen.add(key)
220
+ chosen.push({ method: r.method, path: r.path, status: r.status })
221
+ }
222
+ const consoleErrors = []
223
+ let spec = null
224
+ let line = 0
225
+ for (const entry of trace) {
226
+ if (!entry) continue
227
+ if (entry.type === 'console' && entry.messageType === 'error' && typeof entry.text === 'string')
228
+ consoleErrors.push(entry.text)
229
+ else if (entry.type === 'event' && entry.method === 'pageError') {
230
+ const message = messageOf(entry.params)
231
+ if (message) consoleErrors.push(message)
232
+ } else if (entry.type === 'before' && Array.isArray(entry.stack) && entry.stack.length) {
233
+ const frame = entry.stack[0] || {}
234
+ if (typeof frame.file === 'string' && /\\.(?:spec|test)\\.[cm]?[jt]sx?$/.test(frame.file)) {
235
+ spec = specPathOf(frame.file)
236
+ line = Number(frame.line) || 0
237
+ }
238
+ }
239
+ }
240
+ // Chromium reports every failed request as a console error too; where the page logged something
241
+ // of its own about it, that is the line worth reading.
242
+ const specific = consoleErrors.filter(function (text) { return !/^Failed to load resource/.test(text) })
243
+ const keptConsole = specific.length > 0 ? specific : consoleErrors
244
+ if (chosen.length === 0 && keptConsole.length === 0) continue
245
+ process.stdout.write('TRACE ' + JSON.stringify({
246
+ dir: path.relative(ROOT, path.dirname(one.file)),
247
+ spec: spec,
248
+ line: line,
249
+ requests: chosen.slice(-MAX_REQUESTS),
250
+ console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
251
+ }) + '\\n')
252
+ }
253
+ `;
254
+ /**
255
+ * The traces {@link traceArtifactsScript} printed, parsed.
256
+ *
257
+ * A line that is not a complete `TRACE <json>` (a truncated read, a program that died mid-write) is
258
+ * dropped: a partial trace is not evidence about the test it came from.
259
+ *
260
+ * @param stdout - The program's output.
261
+ * @returns One entry per trace it printed.
262
+ */
263
+ export function parseTraceArtifacts(stdout) {
264
+ const out = [];
265
+ for (const line of stdout.split('\n')) {
266
+ if (!line.startsWith('TRACE '))
267
+ continue;
268
+ try {
269
+ const raw = JSON.parse(line.slice('TRACE '.length));
270
+ const requests = Array.isArray(raw.requests) ? raw.requests : [];
271
+ const consoleMessages = Array.isArray(raw.console) ? raw.console : [];
272
+ out.push({
273
+ spec: typeof raw.spec === 'string' ? raw.spec : null,
274
+ line: typeof raw.line === 'number' ? raw.line : 0,
275
+ requests: requests
276
+ .filter((r) => typeof r === 'object' && r !== null)
277
+ .map((r) => ({
278
+ method: String(r.method ?? ''),
279
+ path: String(r.path ?? ''),
280
+ status: Number(r.status ?? 0) || 0,
281
+ })),
282
+ console: consoleMessages.map(String),
283
+ });
284
+ }
285
+ catch (_error) {
286
+ continue;
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+ /** Where a trace's test is: the spec and line it names, else the folder Playwright wrote it in. */
292
+ function whereOf(item) {
293
+ if (!item.spec)
294
+ return 'a failing test';
295
+ return item.line > 0 ? `${item.spec}:${item.line}` : item.spec;
296
+ }
297
+ /** One request as the note writes it: `POST /api/articles/x/favorite → 401`. */
298
+ function requestLine(request) {
299
+ const url = request.path.length > TRACE_NOTE_URL_CHARS
300
+ ? `${request.path.slice(0, TRACE_NOTE_URL_CHARS)}…`
301
+ : request.path;
302
+ return `${request.method} ${url} → ${request.status === 0 ? 'no response' : request.status}`;
303
+ }
304
+ /**
305
+ * The note a failed suite run's result carries: what each failing test's page actually sent, and
306
+ * what came back.
307
+ *
308
+ * This is the answer to the question a swallowed error hides — a click that changed nothing is
309
+ * usually a request that failed — so the note leads with the failing requests and says what to do
310
+ * with them: read the handler the route reaches, not the selector. It is BOUNDED to
311
+ * {@link MAX_TRACE_NOTE_TESTS} tests with {@link MAX_TRACE_NOTE_REQUESTS} requests each, because it
312
+ * rides in a result the model pays for.
313
+ *
314
+ * @param items - The traces {@link parseTraceArtifacts} read.
315
+ * @param target - Where the run went — the deployed host, as the note names it.
316
+ * @returns The note, or null when no trace held a request or a console error.
317
+ */
318
+ export function failureTraceNote(items, target) {
319
+ const withEvidence = items.filter((item) => item.requests.length > 0 || item.console.length > 0);
320
+ if (withEvidence.length === 0)
321
+ return null;
322
+ const rows = [];
323
+ for (const [index, item] of withEvidence.slice(0, MAX_TRACE_NOTE_TESTS).entries()) {
324
+ const requests = item.requests.slice(-MAX_TRACE_NOTE_REQUESTS).map(requestLine);
325
+ const parts = [];
326
+ if (requests.length > 0)
327
+ parts.push(requests.join('; '));
328
+ if (item.console.length > 0)
329
+ parts.push(`console: ${item.console
330
+ .slice(0, 2)
331
+ .map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 160))
332
+ .join(' | ')}`);
333
+ rows.push(` ${index + 1}. ${whereOf(item)} — ${parts.join(' — ')}`);
334
+ }
335
+ const more = withEvidence.length > MAX_TRACE_NOTE_TESTS
336
+ ? `\n … and ${withEvidence.length - MAX_TRACE_NOTE_TESTS} more traced failures`
337
+ : '';
338
+ return (`The platform read the failing tests' own Playwright traces from the run against ${target} ` +
339
+ `(the last requests each page made and what came back):\n${rows.join('\n')}${more}\n` +
340
+ 'A click that changed nothing is usually one of these requests failing, not the selector: ' +
341
+ 'read the handler this route reaches in your own source, and stop `catch`-ing the request of a ' +
342
+ 'user action silently — log it.');
343
+ }
344
+ /** The Playwright trace modes that leave a zip behind for a FAILING test, whatever the retries. */
345
+ const RECORDING_TRACE_MODES = ['on', 'retain-on-failure'];
346
+ /**
347
+ * A `trace:` setting and its value, however the config spells it (`trace: 'on'`,
348
+ * `trace: "on-first-retry"`, `trace: { mode: 'retain-on-failure', screenshots: true }`). The value
349
+ * is captured from the object form's `mode` or from the plain one.
350
+ */
351
+ const TRACE_SETTING = /trace:\s*(?:\{[^{}]*?mode:\s*)?['"]?(on-first-retry|on-all-retries|retain-on-failure|on|off)(?![\w-])/g;
352
+ /**
353
+ * Whether a Playwright config records traces for the tests that fail.
354
+ *
355
+ * Only `on` and `retain-on-failure` keep a `trace.zip` for a failure whatever the retries do: the
356
+ * two retry modes need a retry to happen at all, and a diagnosis run is given `--retries=0`
357
+ * (`streamingBackgroundCommand`), so on X1 xr-molecule-flash-m100 the run's own
358
+ * `trace: 'on-first-retry'` recorded nothing — which is what left the failing tests' network log
359
+ * unreadable. A config that sets no `trace:` at all records nothing either (Playwright's default
360
+ * is off); so does one that cannot be read.
361
+ *
362
+ * @param configText - The config's source, or '' when none could be read.
363
+ * @returns True when the config records traces for failures.
364
+ */
365
+ export function suiteRecordsTraces(configText) {
366
+ const text = configText.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
367
+ return [...text.matchAll(TRACE_SETTING)].some((m) => RECORDING_TRACE_MODES.includes(m[1] ?? ''));
368
+ }
369
+ /** The `--trace` flag this platform adds, and the only value it adds it with. */
370
+ export const DIAGNOSIS_TRACE_FLAG = '--trace=retain-on-failure';
371
+ /**
372
+ * Whether a command already decides its own tracing: `--trace=on`, `--trace retain-on-failure`.
373
+ * A run that names one is left exactly as the executor wrote it.
374
+ */
375
+ function namesTraceMode(command) {
376
+ return /(?:^|[\s;&|(])--trace(?:=|\s)/.test(command);
377
+ }
378
+ /**
379
+ * Where the `test` word of the command's Playwright run ends, or null when it has none that the
380
+ * shell parse can place.
381
+ *
382
+ * The flag has to land INSIDE the Playwright step, not at the end of the whole command: a run
383
+ * Flash sends almost every time ends `… 2>&1 | tail -60` or `… > log 2>&1; echo "exit=$?";
384
+ * grep -c "" log`, and a flag appended after those is an operand of `tail`/`grep` — GNU `tail`
385
+ * refuses it outright (`tail: option used in invalid context -- 6`) and Playwright never sees it
386
+ * (xr-molecule-flash-m102 +07:40 → +07:53).
387
+ *
388
+ * The step is found by the package's own shell parse ({@link commandSegments}), the one every
389
+ * runner classifier reads: it answers which SEGMENT runs `playwright test` in command position,
390
+ * so a `playwright test` named in an argument (`grep -n "playwright test" e2e/README.md`) is not
391
+ * a run and is left alone. Each segment's text is its program and arguments re-joined with single
392
+ * spaces, so its offset in the command is found by locating that text, walking the segments in
393
+ * order. A segment whose text cannot be located verbatim (irregular whitespace) ends the walk —
394
+ * null, and the caller adds nothing rather than splicing into a guess.
395
+ *
396
+ * @param command - The shell command.
397
+ * @returns The offset just after `test`, or null.
398
+ */
399
+ function playwrightTestWordEnd(command) {
400
+ let cursor = 0;
401
+ for (const segment of commandSegments(command)) {
402
+ const at = command.indexOf(segment.text, cursor);
403
+ if (at < 0)
404
+ return null;
405
+ cursor = at + segment.text.length;
406
+ if (/^playwright\s+test\b/.test(segment.text))
407
+ return at + 'playwright test'.length;
408
+ }
409
+ return null;
410
+ }
411
+ /** The config paths a candidate directory could hold, in the order Playwright itself looks. */
412
+ const CONFIG_NAMES = [
413
+ 'playwright.config.ts',
414
+ 'playwright.config.mts',
415
+ 'playwright.config.js',
416
+ 'playwright.config.mjs',
417
+ 'playwright.config.cjs',
418
+ ];
419
+ /** The directories a command changes into, in order, as it writes them. */
420
+ function cdDirectories(command) {
421
+ const out = [];
422
+ for (const m of command.matchAll(/(?:^|[\s;&|(])cd\s+(?:'([^']+)'|"([^"]+)"|([^\s;&|)]+))/g)) {
423
+ const dir = m[1] ?? m[2] ?? m[3];
424
+ if (dir && !out.includes(dir))
425
+ out.push(dir);
426
+ }
427
+ return out;
428
+ }
429
+ /** A path joined under a base, unless it is already absolute. */
430
+ function under(base, path) {
431
+ if (path.startsWith('/'))
432
+ return path;
433
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`;
434
+ }
435
+ /**
436
+ * The config a Playwright command reads, read from the backend — the file its own `--config`
437
+ * names (resolved against the directory the command starts in, then against each `cd` it does
438
+ * first), else the first `playwright.config.*` in those directories.
439
+ *
440
+ * Best-effort and bounded: at most four candidate files are read, and a read that fails is a
441
+ * config that records nothing — the caller's `--trace` flag is what makes a missing trace
442
+ * impossible either way.
443
+ *
444
+ * @param command - The shell command.
445
+ * @param cwd - The directory the command starts in.
446
+ * @param readFile - The backend's file read.
447
+ * @returns The config's text, or '' when none could be read.
448
+ */
449
+ async function readRunConfig(command, cwd, readFile) {
450
+ const bases = [cwd, ...cdDirectories(command).map((dir) => under(cwd, dir))].slice(0, 2);
451
+ const config = /(?:^|[\s;&|(])--config(?:=|\s+)(?:'([^']+)'|"([^"]+)"|([^\s;&|)]+))/.exec(command);
452
+ const explicit = config?.[1] ?? config?.[2] ?? config?.[3] ?? null;
453
+ const candidates = explicit
454
+ ? bases.map((base) => under(base, explicit))
455
+ : bases.flatMap((base) => CONFIG_NAMES.map((name) => `${base.replace(/\/+$/, '')}/${name}`));
456
+ for (const candidate of candidates.slice(0, 4)) {
457
+ try {
458
+ const text = await readFile(candidate);
459
+ if (text)
460
+ return text;
461
+ }
462
+ catch (_error) {
463
+ continue;
464
+ }
465
+ }
466
+ return '';
467
+ }
468
+ /**
469
+ * The command a DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
470
+ * would record none.
471
+ *
472
+ * The failing tests' requests are only readable if the run wrote them down (see the module doc),
473
+ * and the config is where the decision about that lives — the platform does not edit the suite's
474
+ * config, so it passes the flag instead, and only where it can do no harm: on a run narrowed to a
475
+ * part of the suite or capped ({@link isDiagnosisRun}), never on the whole-suite proof run, and
476
+ * never over a `--trace` the executor wrote itself. A capped walk is exactly the run whose
477
+ * failures someone is about to work on; the proof run is graded as it is.
478
+ *
479
+ * @param args - The command, the directory it starts in, and the backend's file read.
480
+ * @returns The command to run and the sentence to report, or the command unchanged.
481
+ */
482
+ export async function diagnosisTraceCommand(args) {
483
+ const unchanged = { command: args.command, change: null };
484
+ // The proof run is the one run this must never touch: its flags are graded.
485
+ if (!isDiagnosisRun(args.command))
486
+ return unchanged;
487
+ if (namesTraceMode(args.command))
488
+ return unchanged;
489
+ // Only a Playwright run leaves a trace to read.
490
+ if (!isPlaywrightRunCommand(args.command))
491
+ return unchanged;
492
+ const config = await readRunConfig(args.command, args.cwd, args.readFile);
493
+ if (suiteRecordsTraces(config))
494
+ return unchanged;
495
+ // The flag goes into the Playwright step, right after its `test` word — an option of the run
496
+ // itself, whatever the command opens with and wherever it pipes or redirects its output. Appended
497
+ // at the end of the whole command it belonged to the LAST pipeline stage instead (`tail`, `grep`),
498
+ // which either refused it or silently ran without it (xr-molecule-flash-m102 +07:40 → +07:53). The
499
+ // step's shape is untouched otherwise, so the rewrites that run on this command afterwards still
500
+ // see what they expect: a trailing `| tail -60` is still trailing and is still dropped
501
+ // (`streamingBackgroundCommand`).
502
+ const at = playwrightTestWordEnd(args.command);
503
+ if (at === null)
504
+ return unchanged;
505
+ return {
506
+ command: `${args.command.slice(0, at)} ${DIAGNOSIS_TRACE_FLAG}${args.command.slice(at)}`,
507
+ change: `Added \`${DIAGNOSIS_TRACE_FLAG}\`: the suite's config records no trace for a failure` +
508
+ `${/trace:/.test(config) ? '' : ' (it sets none)'}, and this diagnosis run's failures are ` +
509
+ 'what the platform reads the network log from. The proof run keeps the suite’s own settings.',
510
+ };
511
+ }