ptywright 0.1.0 → 0.2.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.
Files changed (68) hide show
  1. package/README.md +459 -116
  2. package/dist/agent.mjs +2 -0
  3. package/dist/bin/ptywright.mjs +6 -0
  4. package/dist/cli-DIUx2w6X.mjs +3587 -0
  5. package/dist/cli.mjs +2 -0
  6. package/{src/index.ts → dist/index.mjs} +7 -9
  7. package/dist/mcp.mjs +2 -0
  8. package/dist/pty-cassette.mjs +24 -0
  9. package/dist/pty_like-Cpkh_O9B.mjs +404 -0
  10. package/dist/runner-DzZlFrt1.mjs +1897 -0
  11. package/dist/runner-zApMYWZx.mjs +3257 -0
  12. package/dist/script.mjs +2 -0
  13. package/dist/server-VHuEWWj_.mjs +3068 -0
  14. package/dist/session.mjs +2 -0
  15. package/dist/terminal_session-DopC7Xg6.mjs +893 -0
  16. package/package.json +28 -21
  17. package/schemas/ptywright-agent-cassette.schema.json +57 -0
  18. package/schemas/ptywright-agent-check.schema.json +122 -0
  19. package/schemas/ptywright-agent-manifest.schema.json +107 -0
  20. package/schemas/ptywright-agent-promote.schema.json +146 -0
  21. package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
  22. package/schemas/ptywright-agent-run.schema.json +126 -0
  23. package/schemas/ptywright-agent.schema.json +182 -0
  24. package/schemas/ptywright-pty-cassette.schema.json +86 -0
  25. package/schemas/ptywright-script-manifest.schema.json +75 -0
  26. package/schemas/ptywright-script-run-summary.schema.json +114 -0
  27. package/schemas/ptywright-script.schema.json +55 -3
  28. package/skills/ptywright-testing/SKILL.md +53 -33
  29. package/bin/ptywright +0 -4
  30. package/src/cli.ts +0 -414
  31. package/src/generator/doc_parser.ts +0 -341
  32. package/src/generator/generate.ts +0 -161
  33. package/src/generator/index.ts +0 -10
  34. package/src/generator/script_generator.ts +0 -209
  35. package/src/generator/step_extractor.ts +0 -397
  36. package/src/mcp/http_server.ts +0 -174
  37. package/src/mcp/script_recording.ts +0 -238
  38. package/src/mcp/server.ts +0 -1348
  39. package/src/pty/bun_pty_adapter.ts +0 -34
  40. package/src/pty/bun_terminal_adapter.ts +0 -149
  41. package/src/pty/pty_adapter.ts +0 -31
  42. package/src/script/dsl.ts +0 -188
  43. package/src/script/module.ts +0 -43
  44. package/src/script/path.ts +0 -151
  45. package/src/script/run.ts +0 -108
  46. package/src/script/run_all.ts +0 -229
  47. package/src/script/runner.ts +0 -983
  48. package/src/script/schema.ts +0 -237
  49. package/src/script/steps/assert_snapshot_equals.ts +0 -21
  50. package/src/script/steps/index.ts +0 -2
  51. package/src/script/suite_report.ts +0 -626
  52. package/src/session/session_manager.ts +0 -145
  53. package/src/session/terminal_session.ts +0 -473
  54. package/src/terminal/ansi.ts +0 -142
  55. package/src/terminal/keys.ts +0 -180
  56. package/src/terminal/mask.ts +0 -70
  57. package/src/terminal/mouse.ts +0 -75
  58. package/src/terminal/snapshot.ts +0 -196
  59. package/src/terminal/style.ts +0 -121
  60. package/src/terminal/view.ts +0 -49
  61. package/src/trace/asciicast.ts +0 -20
  62. package/src/trace/asciinema_player_assets.ts +0 -44
  63. package/src/trace/cast_to_txt.ts +0 -116
  64. package/src/trace/recorder.ts +0 -110
  65. package/src/trace/report.ts +0 -2092
  66. package/src/types.ts +0 -86
  67. package/src/util/hash.ts +0 -8
  68. package/src/util/sleep.ts +0 -5
@@ -0,0 +1,3257 @@
1
+ import { a as applyTextMaskRules, c as encodeSgrMouse, d as isDefaultStyle, f as styleKey, i as fnv1a32, l as extractStyle, n as TraceRecorder, o as snapshotGrid, p as encodeKey, r as sleep, s as snapshotLines, t as TerminalSession, u as findMeaningfulEndCol } from "./terminal_session-DopC7Xg6.mjs";
2
+ import { createRequire } from "node:module";
3
+ import { z } from "zod";
4
+ import { spawn } from "bun-pty";
5
+ import { Terminal } from "@xterm/headless";
6
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ //#region src/pty/bun_pty_adapter.ts
10
+ function toForkOptions(options) {
11
+ return {
12
+ name: options.name,
13
+ cols: options.cols,
14
+ rows: options.rows,
15
+ cwd: options.cwd,
16
+ env: options.env
17
+ };
18
+ }
19
+ function toPtyProcess(pty) {
20
+ return {
21
+ pid: pty.pid,
22
+ cols: pty.cols,
23
+ rows: pty.rows,
24
+ write: (data) => pty.write(data),
25
+ resize: (cols, rows) => pty.resize(cols, rows),
26
+ kill: (signal) => pty.kill(signal),
27
+ onData: (listener) => pty.onData(listener),
28
+ onExit: (listener) => pty.onExit(listener)
29
+ };
30
+ }
31
+ var BunPtyAdapter = class {
32
+ spawn(command, args, options) {
33
+ return toPtyProcess(spawn(command, args, toForkOptions(options)));
34
+ }
35
+ };
36
+ //#endregion
37
+ //#region src/pty/bun_terminal_adapter.ts
38
+ function createDisposable(set, listener) {
39
+ return { dispose: () => {
40
+ set.delete(listener);
41
+ } };
42
+ }
43
+ var BunTerminalAdapter = class {
44
+ spawn(command, args, options) {
45
+ if (process.platform === "win32") throw new Error("Bun.Terminal PTY is only available on POSIX systems (Linux/macOS)");
46
+ let cols = options.cols;
47
+ let rows = options.rows;
48
+ const decoder = new TextDecoder();
49
+ const dataListeners = /* @__PURE__ */ new Set();
50
+ const exitListeners = /* @__PURE__ */ new Set();
51
+ const pendingData = [];
52
+ let pendingExit = null;
53
+ const dispatchData = (chunk) => {
54
+ if (!chunk) return;
55
+ if (dataListeners.size === 0) {
56
+ pendingData.push(chunk);
57
+ if (pendingData.length > 2e3) pendingData.splice(0, pendingData.length - 2e3);
58
+ return;
59
+ }
60
+ for (const listener of dataListeners) listener(chunk);
61
+ };
62
+ const flushPendingDataTo = (listener) => {
63
+ if (pendingData.length === 0) return;
64
+ for (const chunk of pendingData) listener(chunk);
65
+ };
66
+ const dispatchExit = (event) => {
67
+ pendingExit = event;
68
+ for (const listener of exitListeners) listener(event);
69
+ };
70
+ const flushExitTo = (listener) => {
71
+ if (!pendingExit) return;
72
+ listener(pendingExit);
73
+ };
74
+ const terminalOptions = {
75
+ cols,
76
+ rows,
77
+ name: options.name,
78
+ data: (_term, data) => {
79
+ dispatchData(decoder.decode(data, { stream: true }));
80
+ },
81
+ exit: () => {
82
+ dispatchData(decoder.decode());
83
+ }
84
+ };
85
+ let terminal;
86
+ let killed = false;
87
+ const proc = Bun.spawn([command, ...args], {
88
+ cwd: options.cwd,
89
+ env: options.env,
90
+ terminal: terminalOptions,
91
+ onExit(subprocess, exitCode, _signalCode) {
92
+ dispatchData(decoder.decode());
93
+ const signal = subprocess.signalCode ?? void 0;
94
+ dispatchExit({
95
+ exitCode: exitCode ?? (killed ? -1 : 0),
96
+ signal
97
+ });
98
+ }
99
+ });
100
+ terminal = proc.terminal;
101
+ if (!terminal) throw new Error("expected Bun.spawn(..., { terminal }) to attach a PTY terminal");
102
+ terminal.setRawMode(true);
103
+ return {
104
+ pid: proc.pid,
105
+ get cols() {
106
+ return cols;
107
+ },
108
+ get rows() {
109
+ return rows;
110
+ },
111
+ write: (data) => {
112
+ terminal?.write(data);
113
+ },
114
+ resize: (nextCols, nextRows) => {
115
+ cols = nextCols;
116
+ rows = nextRows;
117
+ terminal?.resize(nextCols, nextRows);
118
+ },
119
+ kill: (signal) => {
120
+ killed = true;
121
+ if (signal) proc.kill(signal);
122
+ else proc.kill();
123
+ terminal?.close();
124
+ },
125
+ onData: (listener) => {
126
+ dataListeners.add(listener);
127
+ flushPendingDataTo(listener);
128
+ if (dataListeners.size === 1) queueMicrotask(() => {
129
+ pendingData.length = 0;
130
+ });
131
+ return createDisposable(dataListeners, listener);
132
+ },
133
+ onExit: (listener) => {
134
+ exitListeners.add(listener);
135
+ flushExitTo(listener);
136
+ return createDisposable(exitListeners, listener);
137
+ }
138
+ };
139
+ }
140
+ };
141
+ //#endregion
142
+ //#region src/pty/default_adapter.ts
143
+ function resolvePtyBackend(value) {
144
+ const backend = (value ?? process.env.TUI_TEST_PTY_BACKEND ?? "auto").toLowerCase();
145
+ if (backend === "auto" || backend === "bun-terminal" || backend === "bun-pty") return backend;
146
+ throw new Error(`unknown PTY backend: ${value ?? ""}`);
147
+ }
148
+ function createDefaultPtyAdapter(value) {
149
+ const backend = resolvePtyBackend(value);
150
+ if (backend === "bun-pty") return new BunPtyAdapter();
151
+ if (backend === "bun-terminal") return new BunTerminalAdapter();
152
+ return process.platform === "win32" ? new BunPtyAdapter() : new BunTerminalAdapter();
153
+ }
154
+ //#endregion
155
+ //#region src/session/session_manager.ts
156
+ var SessionManager = class {
157
+ ptyAdapter;
158
+ snapshotRingSize;
159
+ sessions = /* @__PURE__ */ new Map();
160
+ constructor(options) {
161
+ this.ptyAdapter = options?.ptyAdapter ?? createDefaultPtyAdapter();
162
+ this.snapshotRingSize = options?.snapshotRingSize ?? 50;
163
+ }
164
+ listSessionIds() {
165
+ return [...this.sessions.keys()];
166
+ }
167
+ listSessions() {
168
+ return [...this.sessions.values()];
169
+ }
170
+ getSession(sessionId) {
171
+ return this.sessions.get(sessionId);
172
+ }
173
+ launchSession(args) {
174
+ const sessionId = crypto.randomUUID();
175
+ const cols = clampInt$2(args.cols ?? 80, 1, 500);
176
+ const rows = clampInt$2(args.rows ?? 24, 1, 300);
177
+ const cwd = args.cwd ?? process.cwd();
178
+ const term = args.env?.TERM ?? args.name ?? "xterm-256color";
179
+ const env = mergeEnv({
180
+ TERM: term,
181
+ COLORTERM: "truecolor"
182
+ }, args.env);
183
+ const pty = this.ptyAdapter.spawn(args.command, args.args ?? [], {
184
+ cols,
185
+ rows,
186
+ cwd,
187
+ name: term,
188
+ env
189
+ });
190
+ const traceEnv = pickTraceEnv(env);
191
+ const traceCommand = [args.command, ...args.args ?? []].join(" ").trim();
192
+ const session = new TerminalSession({
193
+ id: sessionId,
194
+ pty,
195
+ cols,
196
+ rows,
197
+ snapshotRingSize: this.snapshotRingSize,
198
+ trace: {
199
+ command: traceCommand,
200
+ args: args.args ?? [],
201
+ cwd,
202
+ env: traceEnv
203
+ }
204
+ });
205
+ this.sessions.set(sessionId, session);
206
+ return session;
207
+ }
208
+ closeSession(sessionId) {
209
+ const session = this.sessions.get(sessionId);
210
+ if (!session) return false;
211
+ session.close();
212
+ this.sessions.delete(sessionId);
213
+ return true;
214
+ }
215
+ closeAll() {
216
+ for (const [id, session] of this.sessions) {
217
+ session.close();
218
+ this.sessions.delete(id);
219
+ }
220
+ }
221
+ };
222
+ function clampInt$2(value, min, max) {
223
+ if (!Number.isFinite(value)) return min;
224
+ const int = Math.trunc(value);
225
+ if (int < min) return min;
226
+ if (int > max) return max;
227
+ return int;
228
+ }
229
+ function mergeEnv(base, override) {
230
+ const env = {};
231
+ for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") env[k] = v;
232
+ for (const [k, v] of Object.entries(base)) env[k] = v;
233
+ if (override) for (const [k, v] of Object.entries(override)) env[k] = v;
234
+ return env;
235
+ }
236
+ function pickTraceEnv(env) {
237
+ const picked = {};
238
+ for (const key of [
239
+ "TERM",
240
+ "COLORTERM",
241
+ "LANG",
242
+ "LC_ALL"
243
+ ]) {
244
+ const value = env[key];
245
+ if (value) picked[key] = value;
246
+ }
247
+ return picked;
248
+ }
249
+ //#endregion
250
+ //#region src/terminal/view.ts
251
+ function formatSnapshotView(options) {
252
+ const lineNumbers = options.lineNumbers ?? true;
253
+ const cursorViewportRow = options.meta.baseY + options.meta.cursorY - options.meta.viewportY;
254
+ const cursorViewportCol = options.meta.cursorX;
255
+ const header = [
256
+ `session=${options.sessionId}`,
257
+ `scope=${options.scope}`,
258
+ `size=${options.meta.cols}x${options.meta.rows}`,
259
+ `buffer=${options.meta.bufferType}`,
260
+ `cursor=${cursorViewportCol + 1},${cursorViewportRow + 1}`,
261
+ `hash=${options.hash}`
262
+ ].join(" ");
263
+ const digits = Math.max(2, String(options.lines.length).length);
264
+ const out = [header];
265
+ for (let i = 0; i < options.lines.length; i += 1) {
266
+ const n = i + 1;
267
+ const prefix = lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
268
+ out.push(`${prefix}${options.lines[i] ?? ""}`);
269
+ }
270
+ return out.join("\n");
271
+ }
272
+ //#endregion
273
+ //#region src/trace/asciinema_player_assets.ts
274
+ function ensureAsciinemaPlayerAssets(reportPath) {
275
+ const dir = dirname(reportPath);
276
+ const cssPath = join(dir, "asciinema-player.css");
277
+ const jsPath = join(dir, "asciinema-player.min.js");
278
+ const cssExists = existsSync(cssPath);
279
+ const jsExists = existsSync(jsPath);
280
+ if (cssExists && jsExists) return {
281
+ ok: true,
282
+ copied: false,
283
+ cssPath,
284
+ jsPath
285
+ };
286
+ try {
287
+ mkdirSync(dir, { recursive: true });
288
+ const require = createRequire(import.meta.url);
289
+ const resolvedCss = require.resolve("asciinema-player/dist/bundle/asciinema-player.css");
290
+ const resolvedJs = require.resolve("asciinema-player/dist/bundle/asciinema-player.min.js");
291
+ if (!cssExists) copyFileSync(resolvedCss, cssPath);
292
+ if (!jsExists) copyFileSync(resolvedJs, jsPath);
293
+ return {
294
+ ok: true,
295
+ copied: true,
296
+ cssPath,
297
+ jsPath
298
+ };
299
+ } catch (error) {
300
+ return {
301
+ ok: false,
302
+ copied: false,
303
+ cssPath,
304
+ jsPath,
305
+ error: error.message
306
+ };
307
+ }
308
+ }
309
+ //#endregion
310
+ //#region src/trace/report.ts
311
+ async function generateTraceReportHtml(cast, options) {
312
+ const parsed = parseAsciicast(cast);
313
+ const termInfo = getTermInfo(parsed.header);
314
+ const terminal = new Terminal({
315
+ cols: termInfo.cols,
316
+ rows: termInfo.rows,
317
+ allowProposedApi: true,
318
+ scrollback: 2e3,
319
+ convertEol: true
320
+ });
321
+ const scope = options?.scope ?? "visible";
322
+ const maxFrames = options?.maxFrames ?? 200;
323
+ const scriptName = options?.scriptName?.trim() ? options.scriptName.trim() : "";
324
+ const result = options?.result;
325
+ const artifacts = options?.artifacts;
326
+ const steps = options?.steps;
327
+ let writeChain = Promise.resolve();
328
+ const frames = [];
329
+ let previousRowSignatures = null;
330
+ const capture = (args) => {
331
+ if (frames.length >= maxFrames) return;
332
+ let viewHtml;
333
+ let changedCount;
334
+ if (args.overrideViewText) {
335
+ const parsedView = parseSnapshotViewText(args.overrideViewText.text);
336
+ const headerLine = parsedView.headerLine ?? (args.overrideViewText.hash?.trim() ? `hash=${args.overrideViewText.hash.trim()}` : "snapshot");
337
+ const rowSignatures = parsedView.rows.map((r) => r.text);
338
+ const changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
339
+ previousRowSignatures = rowSignatures;
340
+ changedCount = changedLines.size;
341
+ viewHtml = renderSnapshotViewTextHtml({
342
+ headerLine,
343
+ rows: parsedView.rows,
344
+ changedLines
345
+ });
346
+ } else {
347
+ let lines;
348
+ let hash;
349
+ let changedLines = /* @__PURE__ */ new Set();
350
+ if (scope === "visible") {
351
+ const grid = snapshotGrid(terminal, {
352
+ trimRight: true,
353
+ includeStyles: true
354
+ });
355
+ lines = grid.lines;
356
+ hash = fnv1a32(JSON.stringify(grid));
357
+ const rowSignatures = lines.map((line, idx) => {
358
+ const runs = grid.styleRuns?.[idx] ?? [];
359
+ if (line === "" && runs.length === 0) return "";
360
+ return `${line}\n${JSON.stringify(runs)}`;
361
+ });
362
+ changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
363
+ previousRowSignatures = rowSignatures;
364
+ } else {
365
+ lines = snapshotLines(terminal, {
366
+ scope,
367
+ trimRight: true
368
+ });
369
+ hash = fnv1a32(lines.join("\n"));
370
+ }
371
+ changedCount = changedLines.size;
372
+ viewHtml = renderSnapshotViewHtml({
373
+ terminal,
374
+ sessionId: "replay",
375
+ scope,
376
+ hash,
377
+ lines,
378
+ meta: getMeta(terminal),
379
+ lineNumbers: true,
380
+ changedLines,
381
+ trimRight: true
382
+ });
383
+ }
384
+ const previousFrame = frames.at(-1);
385
+ frames.push({
386
+ id: `frame-${frames.length + 1}`,
387
+ atSeconds: args.atSeconds,
388
+ kind: args.kind,
389
+ label: args.label,
390
+ markLabel: args.markLabel,
391
+ viewHtml,
392
+ changedCount,
393
+ stepInfo: args.stepInfo,
394
+ previousViewHtml: previousFrame?.viewHtml
395
+ });
396
+ };
397
+ if (steps && steps.length > 0) for (let i = 0; i < steps.length; i += 1) {
398
+ const stepRec = steps[i];
399
+ if (!stepRec) continue;
400
+ const stepLabel = formatStepLabel$1(stepRec.step);
401
+ const viewText = stepRec.after?.text ?? "";
402
+ const displayIndex = (typeof stepRec.index === "number" ? stepRec.index : i) + 1;
403
+ const stepType = stepRec.step.type;
404
+ const kind = stepType === "mark" ? "mark" : stepType === "resize" ? "resize" : "step";
405
+ const markLabel = kind === "mark" && typeof stepRec.step.label === "string" ? String(stepRec.step.label) : void 0;
406
+ const stepParams = {};
407
+ for (const [key, value] of Object.entries(stepRec.step)) if (key !== "type") stepParams[key] = value;
408
+ capture({
409
+ atSeconds: displayIndex,
410
+ kind,
411
+ label: stepLabel,
412
+ markLabel,
413
+ stepInfo: {
414
+ index: displayIndex,
415
+ type: stepType,
416
+ ok: stepRec.ok,
417
+ error: stepRec.error,
418
+ params: Object.keys(stepParams).length > 0 ? stepParams : void 0,
419
+ durationMs: typeof stepRec.durationMs === "number" ? stepRec.durationMs : void 0
420
+ },
421
+ overrideViewText: {
422
+ text: viewText,
423
+ hash: stepRec.after?.hash
424
+ }
425
+ });
426
+ if (frames.length >= maxFrames) break;
427
+ }
428
+ else {
429
+ for (const event of parsed.events) {
430
+ const [time, type, data] = event;
431
+ if (type === "o") writeChain = writeChain.then(() => writeTerminal(terminal, data));
432
+ else if (type === "r") writeChain.then(() => {
433
+ const resized = parseResize(data);
434
+ if (resized) terminal.resize(resized.cols, resized.rows);
435
+ capture({
436
+ atSeconds: time,
437
+ kind: "resize",
438
+ label: `resize ${data}`
439
+ });
440
+ });
441
+ else if (type === "m") writeChain.then(() => {
442
+ const markLabel = (data ?? "").trim();
443
+ capture({
444
+ atSeconds: time,
445
+ kind: "mark",
446
+ label: markLabel ? `mark ${markLabel}` : "mark",
447
+ markLabel
448
+ });
449
+ });
450
+ }
451
+ await writeChain;
452
+ capture({
453
+ atSeconds: parsed.events.at(-1)?.[0] ?? 0,
454
+ kind: "final",
455
+ label: "final"
456
+ });
457
+ }
458
+ terminal.dispose();
459
+ return renderHtml({
460
+ cast,
461
+ header: parsed.header,
462
+ term: termInfo,
463
+ scope,
464
+ scriptName,
465
+ result,
466
+ artifacts,
467
+ frames,
468
+ eventCount: parsed.events.length
469
+ });
470
+ }
471
+ function formatStepLabel$1(step) {
472
+ const showText = envTruthy$1(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
473
+ if (step.type === "custom" && typeof step.name === "string") return `custom(${step.name})`;
474
+ if (step.type === "sendText") {
475
+ const enter = typeof step.enter === "boolean" ? step.enter : void 0;
476
+ const enterSuffix = enter !== void 0 ? `enter=${enter}` : "";
477
+ const description = typeof step.description === "string" ? step.description : "";
478
+ const text = typeof step.text === "string" ? step.text : description;
479
+ if (!text) return enterSuffix ? `sendText (${enterSuffix})` : "sendText";
480
+ if (!showText) return `sendText <redacted> (len=${text.length}${enterSuffix ? `, ${enterSuffix}` : ""})`;
481
+ return `sendText "${truncateInline$1(text)}"${enterSuffix ? ` (${enterSuffix})` : ""}`;
482
+ }
483
+ if (step.type === "waitForText") {
484
+ const text = typeof step.text === "string" ? step.text : void 0;
485
+ const regex = typeof step.regex === "string" ? step.regex : void 0;
486
+ const description = typeof step.description === "string" ? step.description : void 0;
487
+ if (!showText) {
488
+ if (text) return "waitForText (text)";
489
+ if (regex) return "waitForText (regex)";
490
+ return "waitForText";
491
+ }
492
+ if (text) return `waitFor "${truncateInline$1(text)}"`;
493
+ if (regex) return `waitFor /${truncateInline$1(regex)}/`;
494
+ if (description) return `waitForText "${truncateInline$1(description)}"`;
495
+ return "waitForText";
496
+ }
497
+ if (step.type === "assert") {
498
+ const text = typeof step.text === "string" ? step.text : void 0;
499
+ const regex = typeof step.regex === "string" ? step.regex : void 0;
500
+ const description = typeof step.description === "string" ? step.description : void 0;
501
+ if (!showText) {
502
+ if (text) return "assert (text)";
503
+ if (regex) return "assert (regex)";
504
+ return "assert";
505
+ }
506
+ if (text) return `assert "${truncateInline$1(text)}"`;
507
+ if (regex) return `assert /${truncateInline$1(regex)}/`;
508
+ if (description) return `assert "${truncateInline$1(description)}"`;
509
+ return "assert";
510
+ }
511
+ if (step.type === "pressKey" && typeof step.key === "string") return `pressKey ${step.key}`;
512
+ if (step.type === "mark") {
513
+ const label = typeof step.label === "string" ? step.label.trim() : "";
514
+ return label ? `mark ${label}` : "mark";
515
+ }
516
+ if (step.type === "resize") {
517
+ const cols = typeof step.cols === "number" ? step.cols : void 0;
518
+ const rows = typeof step.rows === "number" ? step.rows : void 0;
519
+ if (cols !== void 0 && rows !== void 0) return `resize ${cols}x${rows}`;
520
+ return "resize";
521
+ }
522
+ return step.type;
523
+ }
524
+ function truncateInline$1(text, maxChars = 60) {
525
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
526
+ if (normalized.length <= maxChars) return normalized;
527
+ return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
528
+ }
529
+ function envTruthy$1(value) {
530
+ if (!value?.trim()) return false;
531
+ const normalized = value.trim().toLowerCase();
532
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
533
+ }
534
+ function stripAnsi(str) {
535
+ return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
536
+ }
537
+ function parseSnapshotViewText(viewText) {
538
+ const lines = stripAnsi(viewText).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
539
+ const first = lines[0] ?? "";
540
+ const hasHeader = /\bsession=/.test(first) && /\bhash=/.test(first);
541
+ return {
542
+ headerLine: hasHeader ? first : null,
543
+ rows: (hasHeader ? lines.slice(1) : lines).map((line) => {
544
+ const match = line.match(/^(\d+│\s)(.*)$/);
545
+ if (!match) return { text: line };
546
+ return {
547
+ prefix: match[1],
548
+ text: match[2] ?? ""
549
+ };
550
+ })
551
+ };
552
+ }
553
+ function renderSnapshotViewTextHtml(options) {
554
+ const digits = Math.max(2, String(options.rows.length).length);
555
+ const out = [`<span class="headerblock">${escapeHtml(options.headerLine)}</span>`];
556
+ for (let i = 0; i < options.rows.length; i += 1) {
557
+ const row = options.rows[i];
558
+ const prefixHtml = `<span class="ln">${escapeHtml(row?.prefix ?? `${String(i + 1).padStart(digits, "0")}│ `)}</span>`;
559
+ const rowClass = options.changedLines.has(i) ? "row changed" : "row";
560
+ out.push(`<span class="${rowClass}">${prefixHtml}${escapeHtml(row?.text ?? "")}</span>`);
561
+ }
562
+ return out.join("");
563
+ }
564
+ async function writeTerminal(terminal, data) {
565
+ await new Promise((resolve) => {
566
+ terminal.write(data, resolve);
567
+ });
568
+ }
569
+ function renderHtml(input) {
570
+ const title = input.scriptName || coerceDisplayString(input.header.title) || "ptywright trace report";
571
+ const command = coerceDisplayString(input.header.command);
572
+ const timestamp = input.header.timestamp;
573
+ const headerJson = JSON.stringify(input.header, null, 2);
574
+ const durationSeconds = input.frames.at(-1)?.atSeconds ?? 0;
575
+ const markFrames = input.frames.filter((f) => f.kind === "mark");
576
+ const resultLabel = input.result?.ok === true ? "PASS" : input.result?.ok === false ? "FAIL" : "UNKNOWN";
577
+ const resultClass = input.result?.ok === true ? "pass" : input.result?.ok === false ? "fail" : "unknown";
578
+ const artifactsRows = [];
579
+ if (input.artifacts?.castHref?.trim()) artifactsRows.push({
580
+ label: "cast",
581
+ href: input.artifacts.castHref.trim()
582
+ });
583
+ if (input.artifacts?.failureErrorHref?.trim()) artifactsRows.push({
584
+ label: "failure.error.txt",
585
+ href: input.artifacts.failureErrorHref.trim()
586
+ });
587
+ if (input.artifacts?.failureStepHref?.trim()) artifactsRows.push({
588
+ label: "failure.step.json",
589
+ href: input.artifacts.failureStepHref.trim()
590
+ });
591
+ if (input.artifacts?.failureLastTextHref?.trim()) artifactsRows.push({
592
+ label: "failure.last.txt",
593
+ href: input.artifacts.failureLastTextHref.trim()
594
+ });
595
+ if (input.artifacts?.failureLastViewHref?.trim()) artifactsRows.push({
596
+ label: "failure.last.view.txt",
597
+ href: input.artifacts.failureLastViewHref.trim()
598
+ });
599
+ const artifactsHtml = artifactsRows.length === 0 ? `<p class="muted">No artifacts linked.</p>` : `<ul class="artifacts">
600
+ ${artifactsRows.map((a) => `<li><a href="${escapeHtml(a.href)}">${escapeHtml(a.label)}</a><span class="muted"> (${escapeHtml(a.href)})</span></li>`).join("\n")}
601
+ </ul>`;
602
+ const castPlayerHtml = `
603
+ <p class="muted">Render the full recording using <span class="mono">asciinema-player</span>.</p>
604
+ <div class="cast-controls">
605
+ <button id="castToggleSize" class="badge chip" type="button">expand</button>
606
+ <span id="castPlayerStatus" class="muted mono"></span>
607
+ </div>
608
+ <div id="castPlayer" class="cast-player"></div>
609
+ <script id="castData" type="application/json">${jsonForHtml(input.cast)}<\/script>
610
+ <script>
611
+ (function () {
612
+ const statusEl = document.getElementById("castPlayerStatus");
613
+ const container = document.getElementById("castPlayer");
614
+ const castEl = document.getElementById("castData");
615
+ const toggleBtn = document.getElementById("castToggleSize");
616
+ if (!container || !castEl) return;
617
+
618
+ // Load external assets automatically (no extra click).
619
+ const VERSION = "3.9.0";
620
+ const LOCAL_CSS = "./asciinema-player.css";
621
+ const LOCAL_JS = "./asciinema-player.min.js";
622
+ // Use multiple CDNs to avoid regional blocks (e.g. jsdelivr).
623
+ const CDN_BASES = [
624
+ "https://cdn.jsdelivr.net/npm/asciinema-player@" + VERSION + "/dist/bundle/",
625
+ "https://unpkg.com/asciinema-player@" + VERSION + "/dist/bundle/",
626
+ ];
627
+ const CSS_URLS = [LOCAL_CSS, ...CDN_BASES.map((b) => b + "asciinema-player.css")];
628
+ const JS_URLS = [LOCAL_JS, ...CDN_BASES.map((b) => b + "asciinema-player.min.js")];
629
+
630
+ function setStatus(text) {
631
+ if (statusEl) statusEl.textContent = text ? " " + text : "";
632
+ }
633
+
634
+ async function loadCssOnce() {
635
+ if (document.getElementById("asciinemaPlayerCss")) return;
636
+
637
+ for (const href of CSS_URLS) {
638
+ try {
639
+ await new Promise((resolve, reject) => {
640
+ const link = document.createElement("link");
641
+ link.id = "asciinemaPlayerCss";
642
+ link.rel = "stylesheet";
643
+ link.href = href;
644
+ link.onload = resolve;
645
+ link.onerror = reject;
646
+ document.head.appendChild(link);
647
+ });
648
+ return;
649
+ } catch {
650
+ const el = document.getElementById("asciinemaPlayerCss");
651
+ if (el) el.remove();
652
+ }
653
+ }
654
+ }
655
+
656
+ function loadScriptOnce() {
657
+ return new Promise((resolve, reject) => {
658
+ if (window.AsciinemaPlayer) return resolve(window.AsciinemaPlayer);
659
+ const existing = document.getElementById("asciinemaPlayerJs");
660
+ if (existing) {
661
+ // If another instance is loading, poll until available.
662
+ const startedAt = Date.now();
663
+ const poll = setInterval(() => {
664
+ if (window.AsciinemaPlayer) {
665
+ clearInterval(poll);
666
+ resolve(window.AsciinemaPlayer);
667
+ } else if (Date.now() - startedAt > 15000) {
668
+ clearInterval(poll);
669
+ reject(new Error("timeout loading asciinema-player"));
670
+ }
671
+ }, 100);
672
+ return;
673
+ }
674
+
675
+ let idx = 0;
676
+ const tryNext = () => {
677
+ const src = JS_URLS[idx++];
678
+ if (!src) {
679
+ reject(new Error("failed to load asciinema-player"));
680
+ return;
681
+ }
682
+
683
+ const script = document.createElement("script");
684
+ script.id = "asciinemaPlayerJs";
685
+ script.src = src;
686
+ script.async = true;
687
+ script.onload = () => resolve(window.AsciinemaPlayer);
688
+ script.onerror = () => {
689
+ script.remove();
690
+ if (window.AsciinemaPlayer) {
691
+ resolve(window.AsciinemaPlayer);
692
+ return;
693
+ }
694
+ tryNext();
695
+ };
696
+ document.head.appendChild(script);
697
+ };
698
+ tryNext();
699
+ });
700
+ }
701
+
702
+ function computeMarkers(castText) {
703
+ try {
704
+ const lines = String(castText || "").trimEnd().split("\\n");
705
+ const out = [];
706
+ for (let i = 1; i < lines.length; i++) {
707
+ const line = (lines[i] || "").trim();
708
+ if (!line) continue;
709
+ const value = JSON.parse(line);
710
+ if (!Array.isArray(value) || value.length < 3) continue;
711
+ const t = Number(value[0]);
712
+ const type = String(value[1]);
713
+ const data = String(value[2]);
714
+ if (!Number.isFinite(t)) continue;
715
+
716
+ // Prefer explicit marks when present.
717
+ if (type === "m") out.push(t);
718
+
719
+ // Also mark "Enter" submissions (helps jump between commands).
720
+ if (type === "i" && data.indexOf("\\r") >= 0) out.push(t);
721
+ }
722
+
723
+ out.sort((a, b) => a - b);
724
+ // Deduplicate with a tiny epsilon to keep the marker list sane.
725
+ const uniq = [];
726
+ let last = -1e9;
727
+ for (const t of out) {
728
+ if (t - last > 0.001) {
729
+ uniq.push(t);
730
+ last = t;
731
+ }
732
+ }
733
+ // Cap to avoid pathological UIs (e.g. every keypress).
734
+ return uniq.slice(0, 200);
735
+ } catch {
736
+ return [];
737
+ }
738
+ }
739
+
740
+ function toggleSize(player) {
741
+ container.classList.toggle("expanded");
742
+ if (toggleBtn) {
743
+ toggleBtn.textContent = container.classList.contains("expanded")
744
+ ? "collapse"
745
+ : "expand";
746
+ }
747
+ // Nudge the player to re-render after resize.
748
+ try {
749
+ if (player && typeof player.getCurrentTime === "function" && typeof player.seek === "function") {
750
+ const t = player.getCurrentTime();
751
+ player.seek(t);
752
+ }
753
+ } catch {
754
+ // ignore
755
+ }
756
+ }
757
+
758
+ async function mountPlayer() {
759
+ setStatus("loading…");
760
+ await loadCssOnce();
761
+ const AsciinemaPlayer = await loadScriptOnce();
762
+ if (!AsciinemaPlayer || !AsciinemaPlayer.create) {
763
+ throw new Error("AsciinemaPlayer API missing");
764
+ }
765
+
766
+ const castText = JSON.parse(castEl.textContent || '""');
767
+ const markers = computeMarkers(castText);
768
+
769
+ const player = AsciinemaPlayer.create({ data: () => castText }, container, {
770
+ // Keep it compact inside the report; user can expand if needed.
771
+ fit: "both",
772
+ controls: true,
773
+ preload: true,
774
+ autoPlay: false,
775
+ markers: markers.length ? markers : undefined,
776
+ });
777
+
778
+ if (toggleBtn) toggleBtn.addEventListener("click", () => toggleSize(player));
779
+
780
+ setStatus("ready");
781
+ }
782
+
783
+ mountPlayer().catch((err) => {
784
+ setStatus("failed: " + (err && err.message ? err.message : String(err)));
785
+ });
786
+ })();
787
+ <\/script>
788
+ `;
789
+ const markListHtml = markFrames.length === 0 ? `<p class="muted">No marks recorded.</p>` : `<ol class="marks">
790
+ ${markFrames.map((f) => {
791
+ const label = f.markLabel?.trim() || "(unnamed)";
792
+ return `<li><a href="#${escapeHtml(f.id)}">t=${f.atSeconds.toFixed(3)}s — ${escapeHtml(label)}</a></li>`;
793
+ }).join("\n")}
794
+ </ol>`;
795
+ const traceData = {
796
+ version: 2,
797
+ durationSeconds,
798
+ frames: input.frames.map((f, idx) => ({
799
+ index: idx + 1,
800
+ id: f.id,
801
+ atSeconds: f.atSeconds,
802
+ kind: f.kind,
803
+ label: f.label,
804
+ markLabel: f.markLabel ?? null,
805
+ changedCount: f.changedCount,
806
+ stepInfo: f.stepInfo ?? null
807
+ }))
808
+ };
809
+ const frameListHtml = input.frames.map((frame, idx) => {
810
+ const statusBadge = frame.stepInfo && frame.stepInfo.ok ? `<span class="badge pass">PASS</span>` : frame.stepInfo && !frame.stepInfo.ok ? `<span class="badge fail">FAIL</span>` : `<span class="badge">INFO</span>`;
811
+ const changedBadge = frame.changedCount > 0 ? `<span class="badge">changed=${frame.changedCount}</span>` : "";
812
+ return `<li>
813
+ <button
814
+ type="button"
815
+ class="frame-btn"
816
+ data-idx="${idx}"
817
+ data-id="${escapeHtml(frame.id)}"
818
+ data-kind="${escapeHtml(frame.kind)}"
819
+ data-ok="${frame.stepInfo ? String(frame.stepInfo.ok) : ""}"
820
+ data-changed="${String(frame.changedCount)}"
821
+ >
822
+ <div class="frame-btn-top">
823
+ ${statusBadge}
824
+ ${changedBadge}
825
+ <span class="mono frame-btn-time">t=${frame.atSeconds.toFixed(3)}s</span>
826
+ </div>
827
+ <div class="frame-btn-label mono">${escapeHtml(frame.label)}</div>
828
+ </button>
829
+ </li>`;
830
+ }).join("\n");
831
+ const templatesHtml = input.frames.map((frame) => {
832
+ const prevTpl = frame.previousViewHtml ? `<template id="prev-${escapeHtml(frame.id)}">${frame.previousViewHtml}</template>` : "";
833
+ return `<template id="tpl-${escapeHtml(frame.id)}">${frame.viewHtml}</template>${prevTpl}`;
834
+ }).join("\n");
835
+ return `<!doctype html>
836
+ <html lang="en">
837
+ <head>
838
+ <meta charset="utf-8" />
839
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
840
+ <title>${escapeHtml(title)}</title>
841
+ <style>
842
+ :root {
843
+ /* Base Colors - Slate/Zinc inspired */
844
+ --bg-body: #f8fafc;
845
+ --bg-card: #ffffff;
846
+ --bg-subtle: #f1f5f9;
847
+ --bg-hover: #e2e8f0;
848
+ --bg-active: #cbd5e1;
849
+
850
+ --border-subtle: #e2e8f0;
851
+ --border-default: #cbd5e1;
852
+ --border-active: #94a3b8;
853
+
854
+ --text-main: #0f172a;
855
+ --text-muted: #64748b;
856
+ --text-faint: #94a3b8;
857
+
858
+ /* Accents */
859
+ --accent-primary: #0f172a; /* Slate 900 */
860
+ --accent-primary-fg: #f8fafc;
861
+ --accent-brand: #3b82f6; /* Blue 500 */
862
+
863
+ /* Status Colors */
864
+ --status-pass-bg: #dcfce7;
865
+ --status-pass-text: #166534;
866
+ --status-pass-border: #86efac;
867
+
868
+ --status-fail-bg: #fee2e2;
869
+ --status-fail-text: #991b1b;
870
+ --status-fail-border: #fca5a5;
871
+
872
+ --status-info-bg: #e0f2fe;
873
+ --status-info-text: #075985;
874
+ --status-info-border: #7dd3fc;
875
+
876
+ --status-changed-bg: #fef3c7;
877
+ --status-changed-text: #92400e;
878
+
879
+ /* Fonts */
880
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
881
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
882
+
883
+ /* Shadows */
884
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
885
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
886
+ }
887
+
888
+ @media (prefers-color-scheme: dark) {
889
+ :root {
890
+ /* Dark Mode Base */
891
+ --bg-body: #0f172a;
892
+ --bg-card: #1e293b;
893
+ --bg-subtle: #334155;
894
+ --bg-hover: #475569;
895
+ --bg-active: #64748b;
896
+
897
+ --border-subtle: #334155;
898
+ --border-default: #475569;
899
+ --border-active: #64748b;
900
+
901
+ --text-main: #f8fafc;
902
+ --text-muted: #94a3b8;
903
+ --text-faint: #64748b;
904
+
905
+ --accent-primary: #f8fafc;
906
+ --accent-primary-fg: #0f172a;
907
+ --accent-brand: #60a5fa; /* Blue 400 */
908
+
909
+ /* Dark Mode Status */
910
+ --status-pass-bg: #052e16;
911
+ --status-pass-text: #4ade80;
912
+ --status-pass-border: #166534;
913
+
914
+ --status-fail-bg: #450a0a;
915
+ --status-fail-text: #f87171;
916
+ --status-fail-border: #991b1b;
917
+
918
+ --status-info-bg: #082f49;
919
+ --status-info-text: #38bdf8;
920
+ --status-info-border: #075985;
921
+
922
+ --status-changed-bg: #451a03;
923
+ --status-changed-text: #fbbf24;
924
+ }
925
+ }
926
+
927
+ body {
928
+ margin: 0;
929
+ background-color: var(--bg-body);
930
+ color: var(--text-main);
931
+ font-family: var(--font-sans);
932
+ line-height: 1.5;
933
+ font-size: 14px;
934
+ -webkit-font-smoothing: antialiased;
935
+ }
936
+
937
+ * {
938
+ box-sizing: border-box;
939
+ }
940
+
941
+ /* Layout & Containers */
942
+ header {
943
+ background-color: var(--bg-card);
944
+ padding: 16px 24px;
945
+ border-bottom: 1px solid var(--border-subtle);
946
+ box-shadow: var(--shadow-sm);
947
+ position: sticky;
948
+ top: 0;
949
+ z-index: 50;
950
+ }
951
+
952
+ main {
953
+ max-width: 1600px;
954
+ margin: 0 auto;
955
+ padding: 24px;
956
+ }
957
+
958
+ .section {
959
+ margin-bottom: 24px;
960
+ background-color: var(--bg-card);
961
+ border: 1px solid var(--border-subtle);
962
+ border-radius: 12px;
963
+ padding: 20px;
964
+ box-shadow: var(--shadow-sm);
965
+ }
966
+
967
+ h1, h2, h3 {
968
+ margin: 0;
969
+ font-weight: 600;
970
+ letter-spacing: -0.025em;
971
+ }
972
+
973
+ header h1 {
974
+ font-size: 20px;
975
+ margin-bottom: 8px;
976
+ color: var(--text-main);
977
+ }
978
+
979
+ h2 {
980
+ font-size: 16px;
981
+ margin-bottom: 16px;
982
+ padding-bottom: 8px;
983
+ border-bottom: 1px solid var(--border-subtle);
984
+ color: var(--text-main);
985
+ }
986
+
987
+ /* Typography & Utility */
988
+ .mono { font-family: var(--font-mono); }
989
+ .muted { color: var(--text-muted); }
990
+
991
+ a {
992
+ color: var(--accent-brand);
993
+ text-decoration: none;
994
+ }
995
+ a:hover { text-decoration: underline; }
996
+
997
+ pre {
998
+ margin: 0;
999
+ font-family: var(--font-mono);
1000
+ font-size: 13px;
1001
+ line-height: normal;
1002
+ white-space: pre;
1003
+ overflow: auto;
1004
+ }
1005
+
1006
+ /* Badges */
1007
+ .badges {
1008
+ display: flex;
1009
+ gap: 8px;
1010
+ flex-wrap: wrap;
1011
+ margin-top: 8px;
1012
+ }
1013
+
1014
+ .badge {
1015
+ display: inline-flex;
1016
+ align-items: center;
1017
+ padding: 2px 10px;
1018
+ border-radius: 9999px;
1019
+ font-size: 12px;
1020
+ font-weight: 500;
1021
+ border: 1px solid var(--border-default);
1022
+ background-color: var(--bg-subtle);
1023
+ color: var(--text-muted);
1024
+ }
1025
+
1026
+ .badge.pass {
1027
+ background-color: var(--status-pass-bg);
1028
+ color: var(--status-pass-text);
1029
+ border-color: var(--status-pass-border);
1030
+ }
1031
+
1032
+ .badge.fail {
1033
+ background-color: var(--status-fail-bg);
1034
+ color: var(--status-fail-text);
1035
+ border-color: var(--status-fail-border);
1036
+ }
1037
+
1038
+ .badge.chip {
1039
+ cursor: pointer;
1040
+ transition: all 0.2s;
1041
+ }
1042
+ .badge.chip:hover {
1043
+ background-color: var(--bg-hover);
1044
+ }
1045
+ .badge.chip[aria-pressed="true"] {
1046
+ background-color: var(--accent-primary);
1047
+ color: var(--accent-primary-fg);
1048
+ border-color: var(--accent-primary);
1049
+ }
1050
+ /* Special case: toggle badge in header */
1051
+ .badge.toggle { cursor: pointer; user-select: none; }
1052
+ #debugToggle:checked ~ header .badge.toggle {
1053
+ background-color: var(--accent-brand);
1054
+ color: white;
1055
+ border-color: var(--accent-brand);
1056
+ }
1057
+
1058
+ /* Trace Layout */
1059
+ .trace {
1060
+ display: grid;
1061
+ grid-template-columns: 320px 1fr;
1062
+ gap: 24px;
1063
+ height: 70vh;
1064
+ min-height: 500px;
1065
+ }
1066
+
1067
+ .trace aside {
1068
+ display: flex;
1069
+ flex-direction: column;
1070
+ height: 100%;
1071
+ overflow: hidden;
1072
+ }
1073
+
1074
+ /* Frame List in Sidebar */
1075
+ .controls {
1076
+ display: flex;
1077
+ gap: 8px;
1078
+ flex-wrap: wrap;
1079
+ margin-bottom: 12px;
1080
+ padding-bottom: 12px;
1081
+ border-bottom: 1px solid var(--border-subtle);
1082
+ }
1083
+
1084
+ .input {
1085
+ width: 100%;
1086
+ font-family: var(--font-mono);
1087
+ font-size: 13px;
1088
+ padding: 8px 12px;
1089
+ border-radius: 6px;
1090
+ border: 1px solid var(--border-default);
1091
+ background-color: var(--bg-body);
1092
+ color: var(--text-main);
1093
+ transition: border-color 0.2s;
1094
+ }
1095
+ .input:focus {
1096
+ outline: none;
1097
+ border-color: var(--accent-brand);
1098
+ box-shadow: 0 0 0 2px var(--bg-body), 0 0 0 4px var(--accent-brand);
1099
+ }
1100
+
1101
+ .frame-list {
1102
+ list-style: none;
1103
+ padding: 0;
1104
+ margin: 0;
1105
+ overflow-y: auto;
1106
+ flex: 1;
1107
+ display: flex;
1108
+ flex-direction: column;
1109
+ gap: 4px;
1110
+ }
1111
+
1112
+ .frame-btn {
1113
+ width: 100%;
1114
+ text-align: left;
1115
+ padding: 10px 12px;
1116
+ border-radius: 8px;
1117
+ border: 1px solid transparent;
1118
+ background: transparent;
1119
+ color: var(--text-main);
1120
+ cursor: pointer;
1121
+ transition: all 0.1s;
1122
+ }
1123
+
1124
+ .frame-btn:hover {
1125
+ background-color: var(--bg-hover);
1126
+ }
1127
+
1128
+ .frame-btn[aria-selected="true"] {
1129
+ background-color: var(--bg-active);
1130
+ border-color: var(--border-active);
1131
+ font-weight: 500;
1132
+ }
1133
+
1134
+ .frame-btn-top {
1135
+ display: flex;
1136
+ align-items: center;
1137
+ gap: 8px;
1138
+ margin-bottom: 4px;
1139
+ font-size: 11px;
1140
+ }
1141
+
1142
+ .frame-btn-label {
1143
+ font-family: var(--font-mono);
1144
+ font-size: 12px;
1145
+ overflow: hidden;
1146
+ text-overflow: ellipsis;
1147
+ white-space: nowrap;
1148
+ }
1149
+
1150
+ /* Main Viewer */
1151
+ .viewer {
1152
+ display: flex;
1153
+ flex-direction: column;
1154
+ height: 100%;
1155
+ border: 1px solid var(--border-default);
1156
+ border-radius: 8px;
1157
+ overflow: hidden;
1158
+ background-color: var(--bg-body);
1159
+ }
1160
+
1161
+ .viewer-tabs {
1162
+ display: flex;
1163
+ background-color: var(--bg-subtle);
1164
+ border-bottom: 1px solid var(--border-default);
1165
+ }
1166
+
1167
+ .viewer-tab {
1168
+ padding: 10px 16px;
1169
+ font-size: 13px;
1170
+ font-weight: 500;
1171
+ color: var(--text-muted);
1172
+ background: transparent;
1173
+ border: none;
1174
+ border-right: 1px solid var(--border-subtle);
1175
+ cursor: pointer;
1176
+ transition: background 0.2s;
1177
+ }
1178
+
1179
+ .viewer-tab:hover {
1180
+ background-color: var(--bg-hover);
1181
+ color: var(--text-main);
1182
+ }
1183
+
1184
+ .viewer-tab[aria-selected="true"] {
1185
+ background-color: var(--bg-body);
1186
+ color: var(--accent-brand);
1187
+ box-shadow: inset 0 -2px 0 0 var(--accent-brand);
1188
+ }
1189
+
1190
+ .viewer-tab.has-error {
1191
+ color: var(--status-fail-text);
1192
+ }
1193
+
1194
+ .viewer-header {
1195
+ padding: 12px 16px;
1196
+ border-bottom: 1px solid var(--border-subtle);
1197
+ background-color: var(--bg-card);
1198
+ font-size: 13px;
1199
+ display: flex;
1200
+ gap: 12px;
1201
+ align-items: baseline;
1202
+ }
1203
+ .viewer-title { font-weight: 600; color: var(--text-main); }
1204
+ .viewer-sub { color: var(--text-faint); font-size: 12px; }
1205
+
1206
+ .viewer-content {
1207
+ flex: 1;
1208
+ overflow: auto;
1209
+ position: relative;
1210
+ display: none;
1211
+ }
1212
+ .viewer-content.active { display: block; }
1213
+
1214
+ /* Terminal Render */
1215
+ .terminal {
1216
+ background-color: #0d1117; /* GitHub Dark dim */
1217
+ color: #c9d1d9;
1218
+ font-family: var(--font-mono);
1219
+ font-size: 13px;
1220
+ line-height: normal;
1221
+ padding: 16px;
1222
+ min-height: 100%;
1223
+ }
1224
+ .terminal .headerblock {
1225
+ color: #8b949e;
1226
+ margin-bottom: 8px;
1227
+ display: block;
1228
+ font-size: 11px;
1229
+ }
1230
+ .terminal .row {
1231
+ display: block;
1232
+ }
1233
+ .terminal .ln {
1234
+ display: inline-block;
1235
+ width: 3ch;
1236
+ margin-right: 1ch;
1237
+ color: #484f58;
1238
+ user-select: none;
1239
+ text-align: right;
1240
+ vertical-align: top;
1241
+ }
1242
+ .terminal .row.changed {
1243
+ background: rgba(187, 128, 9, 0.15); /* Yellow marking */
1244
+ }
1245
+ .terminal .seg { display: inline; }
1246
+
1247
+ /* Hide debug lines if toggle off */
1248
+ #debugToggle:not(:checked) ~ main .terminal .headerblock,
1249
+ #debugToggle:not(:checked) ~ main .terminal .ln {
1250
+ display: none;
1251
+ }
1252
+ #debugToggle:not(:checked) ~ main .terminal .row.changed {
1253
+ background: transparent;
1254
+ }
1255
+ .debug-toggle {
1256
+ position: absolute;
1257
+ width: 0; height: 0; opacity: 0;
1258
+ }
1259
+
1260
+ /* Timeline */
1261
+ .timeline {
1262
+ padding: 6px 0;
1263
+ margin-bottom: 16px;
1264
+ }
1265
+ .timeline-header {
1266
+ display: flex;
1267
+ justify-content: space-between;
1268
+ margin-bottom: 8px;
1269
+ font-size: 12px;
1270
+ color: var(--text-muted);
1271
+ font-family: var(--font-mono);
1272
+ }
1273
+ .timeline-track {
1274
+ height: 32px;
1275
+ background-color: var(--bg-subtle);
1276
+ border: 1px solid var(--border-default);
1277
+ border-radius: 4px;
1278
+ position: relative;
1279
+ cursor: pointer;
1280
+ overflow: hidden;
1281
+ }
1282
+ .timeline-bar {
1283
+ position: absolute;
1284
+ top: 4px; bottom: 4px;
1285
+ background-color: var(--border-active);
1286
+ border-radius: 1px;
1287
+ min-width: 2px;
1288
+ }
1289
+ .timeline-bar.pass { background-color: var(--status-pass-border); }
1290
+ .timeline-bar.fail { background-color: var(--status-fail-text); }
1291
+ .timeline-bar.info { background-color: var(--status-info-border); }
1292
+ .timeline-bar.selected {
1293
+ background-color: var(--accent-brand);
1294
+ z-index: 10;
1295
+ top: 0; bottom: 0;
1296
+ box-shadow: 0 0 0 1px white;
1297
+ }
1298
+
1299
+ /* Other Components */
1300
+ .call-row {
1301
+ display: grid;
1302
+ grid-template-columns: 100px 1fr;
1303
+ gap: 16px;
1304
+ padding: 8px 16px;
1305
+ border-bottom: 1px solid var(--border-subtle);
1306
+ font-size: 13px;
1307
+ }
1308
+ .call-key { color: var(--text-muted); font-weight: 500; text-align: right; }
1309
+ .call-value { color: var(--text-main); font-family: var(--font-mono); }
1310
+
1311
+ .error-box {
1312
+ margin: 16px;
1313
+ padding: 16px;
1314
+ background-color: var(--status-fail-bg);
1315
+ border: 1px solid var(--status-fail-border);
1316
+ border-radius: 6px;
1317
+ color: var(--status-fail-text);
1318
+ }
1319
+ .error-title { font-weight: 700; margin-bottom: 8px; }
1320
+
1321
+ .diff-view {
1322
+ display: grid;
1323
+ grid-template-columns: 1fr 1fr;
1324
+ height: 100%;
1325
+ }
1326
+ .diff-pane {
1327
+ overflow: auto;
1328
+ border-right: 1px solid #30363d;
1329
+ background-color: #0d1117;
1330
+ }
1331
+ .diff-pane-header {
1332
+ background: #161b22;
1333
+ color: #8b949e;
1334
+ padding: 8px 16px;
1335
+ font-size: 11px;
1336
+ font-weight: 600;
1337
+ border-bottom: 1px solid #30363d;
1338
+ position: sticky;
1339
+ top: 0;
1340
+ }
1341
+ /* Built-in player */
1342
+ .builtin-player {
1343
+ background: #0b0f14;
1344
+ border-radius: 10px;
1345
+ overflow: hidden;
1346
+ border: 1px solid color-mix(in oklab, currentColor 14%, transparent);
1347
+ }
1348
+ /* Cast player styles */
1349
+ .cast-player {
1350
+ height: 450px;
1351
+ min-height: 200px;
1352
+ max-height: 450px;
1353
+ overflow: hidden;
1354
+ background: transparent;
1355
+ margin-top: 16px;
1356
+ margin-bottom: 20px;
1357
+ border-bottom: 1px solid var(--border-subtle);
1358
+ }
1359
+
1360
+ /* Force left alignment of the player */
1361
+ .cast-player .ap-wrapper {
1362
+ display: flex;
1363
+ justify-content: flex-start !important;
1364
+ text-align: left;
1365
+ }
1366
+
1367
+ /* Style the inner player terminal box */
1368
+ .cast-player .ap-player {
1369
+ border-radius: 8px;
1370
+ box-shadow: var(--shadow-md);
1371
+ border: 1px solid var(--border-subtle);
1372
+ }
1373
+
1374
+ .cast-player.expanded {
1375
+ height: 80vh;
1376
+ }
1377
+
1378
+ .cast-controls {
1379
+ display: flex;
1380
+ align-items: center;
1381
+ gap: 12px;
1382
+ margin: 12px 0;
1383
+ padding-bottom: 12px;
1384
+ border-bottom: 1px solid var(--border-subtle);
1385
+ }
1386
+
1387
+ @media (max-width: 1024px) {
1388
+ .trace { grid-template-columns: 1fr; height: auto; }
1389
+ .viewer { height: 500px; }
1390
+ .frame-list { max-height: 300px; }
1391
+ }
1392
+ </style>
1393
+ </head>
1394
+ <body>
1395
+ <input id="debugToggle" class="debug-toggle" type="checkbox" checked />
1396
+ <header>
1397
+ <h1>${escapeHtml(title)}</h1>
1398
+ <div class="badges">
1399
+ <span class="badge ${resultClass}">result=${escapeHtml(resultLabel)}</span>
1400
+ <span class="badge">marks=${markFrames.length}</span>
1401
+ <span class="badge">duration=${durationSeconds.toFixed(3)}s</span>
1402
+ <label class="badge toggle" for="debugToggle">debug</label>
1403
+ </div>
1404
+ <div class="meta">term=${escapeHtml(input.term.type)} ${input.term.cols}x${input.term.rows} scope=${escapeHtml(input.scope)} events=${input.eventCount}
1405
+ command=${escapeHtml(command)}
1406
+ timestamp=${escapeHtml(coerceDisplayString(timestamp))}</div>
1407
+ <details>
1408
+ <summary>Raw header JSON</summary>
1409
+ <pre>${escapeHtml(headerJson)}</pre>
1410
+ </details>
1411
+ </header>
1412
+ <main>
1413
+ <section class="section">
1414
+ <h2>Task</h2>
1415
+ <pre>${escapeHtml([
1416
+ input.scriptName ? `script=${input.scriptName}` : null,
1417
+ command ? `command=${command}` : null,
1418
+ `term=${input.term.type} ${input.term.cols}x${input.term.rows}`,
1419
+ `scope=${input.scope}`
1420
+ ].filter(Boolean).join("\n"))}</pre>
1421
+ </section>
1422
+ <section class="section">
1423
+ <h2>Artifacts</h2>
1424
+ ${artifactsHtml}
1425
+ </section>
1426
+ <section class="section" id="cast-playback">
1427
+ <h2>Cast Playback</h2>
1428
+ ${castPlayerHtml}
1429
+ </section>
1430
+ <section class="section">
1431
+ <h2>Marks</h2>
1432
+ ${markListHtml}
1433
+ </section>
1434
+ <section class="section">
1435
+ <h2>Trace</h2>
1436
+ <!-- Timeline -->
1437
+ <div class="timeline" id="timeline">
1438
+ <div class="timeline-header">
1439
+ <span class="mono">Timeline</span>
1440
+ <span class="mono muted" id="timelineInfo">0 steps · 0.000s</span>
1441
+ </div>
1442
+ <div class="timeline-track" id="timelineTrack"></div>
1443
+ </div>
1444
+ <div class="trace">
1445
+ <aside>
1446
+ <div class="controls">
1447
+ <input id="frameSearch" class="input mono" placeholder="Search frames…" autocomplete="off" />
1448
+ <button id="modeAll" class="badge chip" type="button" aria-pressed="true">all</button>
1449
+ <button id="modeChanged" class="badge chip" type="button" aria-pressed="false">changed</button>
1450
+ <button id="modeMarks" class="badge chip" type="button" aria-pressed="false">marks</button>
1451
+ <button id="modeFailed" class="badge chip fail" type="button" aria-pressed="false">failed</button>
1452
+ <span id="visibleFrames" class="badge">visible=0</span>
1453
+ </div>
1454
+ <ol id="frameList" class="frame-list">
1455
+ ${frameListHtml}
1456
+ </ol>
1457
+ </aside>
1458
+ <div class="viewer">
1459
+ <div class="viewer-tabs" id="viewerTabs">
1460
+ <button class="viewer-tab" data-tab="snapshot" aria-selected="true">Snapshot</button>
1461
+ <button class="viewer-tab" data-tab="call">Call</button>
1462
+ <button class="viewer-tab" data-tab="errors" id="errorsTab">Errors</button>
1463
+ <button class="viewer-tab" data-tab="diff">Diff</button>
1464
+ </div>
1465
+ <div class="viewer-header">
1466
+ <span id="viewerTitle" class="viewer-title mono"></span>
1467
+ <span id="viewerSub" class="viewer-sub mono muted"></span>
1468
+ </div>
1469
+ <div id="viewerSnapshot" class="viewer-content active">
1470
+ <pre id="viewer" class="terminal"></pre>
1471
+ </div>
1472
+ <div id="viewerCall" class="viewer-content">
1473
+ <div class="call-details" id="callDetails"></div>
1474
+ </div>
1475
+ <div id="viewerErrors" class="viewer-content">
1476
+ <div id="errorContent"></div>
1477
+ </div>
1478
+ <div id="viewerDiff" class="viewer-content">
1479
+ <div class="diff-view" id="diffView">
1480
+ <div class="diff-pane">
1481
+ <div class="diff-pane-header">Previous</div>
1482
+ <pre id="diffPrev" class="terminal"></pre>
1483
+ </div>
1484
+ <div class="diff-pane">
1485
+ <div class="diff-pane-header">Current</div>
1486
+ <pre id="diffCurr" class="terminal"></pre>
1487
+ </div>
1488
+ </div>
1489
+ </div>
1490
+ </div>
1491
+ </div>
1492
+ <div class="muted mono" style="margin-top: 10px;">Tips: click a frame or timeline bar, use ↑/↓ (j/k) to navigate, 1-4 to switch tabs.</div>
1493
+ <script id="traceData" type="application/json">${jsonForHtml(traceData)}<\/script>
1494
+ ${templatesHtml}
1495
+ <script>
1496
+ (function () {
1497
+ const dataEl = document.getElementById("traceData");
1498
+ const listEl = document.getElementById("frameList");
1499
+ const viewerEl = document.getElementById("viewer");
1500
+ const titleEl = document.getElementById("viewerTitle");
1501
+ const subEl = document.getElementById("viewerSub");
1502
+ const searchEl = document.getElementById("frameSearch");
1503
+ const visibleEl = document.getElementById("visibleFrames");
1504
+ const modeAll = document.getElementById("modeAll");
1505
+ const modeChanged = document.getElementById("modeChanged");
1506
+ const modeMarks = document.getElementById("modeMarks");
1507
+ const modeFailed = document.getElementById("modeFailed");
1508
+ const timelineTrack = document.getElementById("timelineTrack");
1509
+ const timelineInfo = document.getElementById("timelineInfo");
1510
+ const viewerTabs = document.getElementById("viewerTabs");
1511
+ const callDetails = document.getElementById("callDetails");
1512
+ const errorContent = document.getElementById("errorContent");
1513
+ const diffPrev = document.getElementById("diffPrev");
1514
+ const diffCurr = document.getElementById("diffCurr");
1515
+ const errorsTab = document.getElementById("errorsTab");
1516
+ if (!dataEl || !listEl || !viewerEl || !titleEl || !subEl || !searchEl) return;
1517
+
1518
+ const raw = JSON.parse(dataEl.textContent || "{}");
1519
+ const frames = Array.isArray(raw.frames) ? raw.frames : [];
1520
+ const durationSeconds = raw.durationSeconds || 0;
1521
+ const buttons = Array.from(listEl.querySelectorAll("button.frame-btn"));
1522
+ const idToIndex = new Map();
1523
+ for (const f of frames) idToIndex.set(f.id, f.index - 1);
1524
+
1525
+ let mode = "all";
1526
+ let current = 0;
1527
+ let activeTab = "snapshot";
1528
+
1529
+ // Timeline setup
1530
+ if (timelineTrack && frames.length > 0) {
1531
+ const maxTime = Math.max(durationSeconds, frames[frames.length - 1]?.atSeconds || 1);
1532
+ timelineInfo.textContent = frames.length + " steps · " + maxTime.toFixed(3) + "s";
1533
+
1534
+ frames.forEach((f, idx) => {
1535
+ const bar = document.createElement("div");
1536
+ bar.className = "timeline-bar";
1537
+ const left = (f.atSeconds / maxTime) * 100;
1538
+ const width = Math.max(2, (1 / frames.length) * 100);
1539
+ bar.style.left = left + "%";
1540
+ bar.style.width = width + "%";
1541
+
1542
+ if (f.stepInfo) {
1543
+ bar.classList.add(f.stepInfo.ok ? "pass" : "fail");
1544
+ } else {
1545
+ bar.classList.add("info");
1546
+ }
1547
+
1548
+ bar.dataset.idx = idx;
1549
+ bar.title = f.label;
1550
+ bar.addEventListener("click", () => select(idx, true));
1551
+ timelineTrack.appendChild(bar);
1552
+
1553
+ // Add error markers
1554
+ if (f.stepInfo && !f.stepInfo.ok) {
1555
+ const marker = document.createElement("div");
1556
+ marker.className = "timeline-marker error";
1557
+ marker.style.left = left + "%";
1558
+ timelineTrack.appendChild(marker);
1559
+ }
1560
+
1561
+ // Add mark markers
1562
+ if (f.kind === "mark") {
1563
+ const marker = document.createElement("div");
1564
+ marker.className = "timeline-marker";
1565
+ marker.style.left = left + "%";
1566
+ timelineTrack.appendChild(marker);
1567
+ }
1568
+ });
1569
+ }
1570
+
1571
+ // Tab switching
1572
+ const tabs = viewerTabs ? Array.from(viewerTabs.querySelectorAll(".viewer-tab")) : [];
1573
+ const contents = {
1574
+ snapshot: document.getElementById("viewerSnapshot"),
1575
+ call: document.getElementById("viewerCall"),
1576
+ errors: document.getElementById("viewerErrors"),
1577
+ diff: document.getElementById("viewerDiff"),
1578
+ };
1579
+
1580
+ function switchTab(tabName) {
1581
+ activeTab = tabName;
1582
+ tabs.forEach(t => t.setAttribute("aria-selected", t.dataset.tab === tabName ? "true" : "false"));
1583
+ Object.entries(contents).forEach(([name, el]) => {
1584
+ if (el) el.classList.toggle("active", name === tabName);
1585
+ });
1586
+ }
1587
+
1588
+ tabs.forEach(tab => {
1589
+ tab.addEventListener("click", () => switchTab(tab.dataset.tab));
1590
+ });
1591
+
1592
+ function setPressed(el, on) {
1593
+ el.setAttribute("aria-pressed", on ? "true" : "false");
1594
+ }
1595
+
1596
+ function setMode(next) {
1597
+ mode = next;
1598
+ setPressed(modeAll, mode === "all");
1599
+ setPressed(modeChanged, mode === "changed");
1600
+ setPressed(modeMarks, mode === "marks");
1601
+ setPressed(modeFailed, mode === "failed");
1602
+ applyFilter();
1603
+ }
1604
+
1605
+ function applyFilter() {
1606
+ const q = (searchEl.value || "").trim().toLowerCase();
1607
+ let visible = 0;
1608
+ for (const btn of buttons) {
1609
+ const idx = Number(btn.dataset.idx || "0");
1610
+ let show = true;
1611
+ if (mode === "changed") show = Number(btn.dataset.changed || "0") > 0;
1612
+ else if (mode === "marks") show = btn.dataset.kind === "mark";
1613
+ else if (mode === "failed") show = btn.dataset.ok === "false";
1614
+ if (show && q) {
1615
+ const label = (btn.querySelector(".frame-btn-label")?.textContent || "").toLowerCase();
1616
+ if (!label.includes(q)) show = false;
1617
+ }
1618
+ btn.parentElement.style.display = show ? "" : "none";
1619
+ if (show) visible += 1;
1620
+ }
1621
+ if (visibleEl) visibleEl.textContent = "visible=" + visible;
1622
+
1623
+ if (buttons[current] && buttons[current].parentElement.style.display === "none") {
1624
+ const firstVisible = buttons.findIndex((b) => b.parentElement.style.display !== "none");
1625
+ if (firstVisible >= 0) select(firstVisible, false);
1626
+ } else {
1627
+ updateSelected();
1628
+ }
1629
+ }
1630
+
1631
+ function updateSelected() {
1632
+ for (const btn of buttons) {
1633
+ const idx = Number(btn.dataset.idx || "0");
1634
+ btn.setAttribute("aria-selected", idx === current ? "true" : "false");
1635
+ }
1636
+ // Update timeline selection
1637
+ if (timelineTrack) {
1638
+ const bars = timelineTrack.querySelectorAll(".timeline-bar");
1639
+ bars.forEach((bar, idx) => bar.classList.toggle("selected", idx === current));
1640
+ }
1641
+ }
1642
+
1643
+ function escapeHtml(s) {
1644
+ return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1645
+ }
1646
+
1647
+ function renderFrame(idx) {
1648
+ const f = frames[idx];
1649
+ if (!f) return;
1650
+
1651
+ // Render snapshot tab
1652
+ const tpl = document.getElementById("tpl-" + f.id);
1653
+ if (tpl && tpl.content) {
1654
+ viewerEl.innerHTML = "";
1655
+ viewerEl.appendChild(tpl.content.cloneNode(true));
1656
+ } else if (tpl) {
1657
+ viewerEl.innerHTML = tpl.innerHTML || "";
1658
+ } else {
1659
+ viewerEl.textContent = "(missing template)";
1660
+ }
1661
+
1662
+ // Render call tab
1663
+ if (callDetails) {
1664
+ let html = '<div class="call-row"><span class="call-key">type</span><span class="call-value mono">' + escapeHtml(f.stepInfo?.type || f.kind) + '</span></div>';
1665
+ html += '<div class="call-row"><span class="call-key">index</span><span class="call-value mono">' + (idx + 1) + '</span></div>';
1666
+ html += '<div class="call-row"><span class="call-key">time</span><span class="call-value mono">' + f.atSeconds.toFixed(3) + 's</span></div>';
1667
+ if (f.stepInfo?.durationMs !== undefined) {
1668
+ html += '<div class="call-row"><span class="call-key">duration</span><span class="call-value mono">' + f.stepInfo.durationMs + 'ms</span></div>';
1669
+ }
1670
+ if (f.stepInfo?.params) {
1671
+ Object.entries(f.stepInfo.params).forEach(([key, value]) => {
1672
+ const val = typeof value === "string" ? value : JSON.stringify(value);
1673
+ html += '<div class="call-row"><span class="call-key">' + escapeHtml(key) + '</span><span class="call-value mono">' + escapeHtml(val) + '</span></div>';
1674
+ });
1675
+ }
1676
+ callDetails.innerHTML = html;
1677
+ }
1678
+
1679
+ // Render errors tab
1680
+ if (errorContent) {
1681
+ if (f.stepInfo && !f.stepInfo.ok && f.stepInfo.error) {
1682
+ errorContent.innerHTML = '<div class="error-box"><div class="error-title">Step ' + (idx + 1) + ' Failed</div><div class="error-message">' + escapeHtml(f.stepInfo.error) + '</div></div>';
1683
+ if (errorsTab) errorsTab.classList.add("has-error");
1684
+ } else {
1685
+ errorContent.innerHTML = '<div class="muted" style="padding: 12px;">No errors for this step.</div>';
1686
+ if (errorsTab) errorsTab.classList.remove("has-error");
1687
+ }
1688
+ }
1689
+
1690
+ // Render diff tab
1691
+ if (diffPrev && diffCurr) {
1692
+ const prevTpl = document.getElementById("prev-" + f.id);
1693
+ if (prevTpl && prevTpl.content) {
1694
+ diffPrev.innerHTML = "";
1695
+ diffPrev.appendChild(prevTpl.content.cloneNode(true));
1696
+ } else if (prevTpl) {
1697
+ diffPrev.innerHTML = prevTpl.innerHTML || "";
1698
+ } else {
1699
+ diffPrev.textContent = "(first frame - no previous)";
1700
+ }
1701
+ if (tpl && tpl.content) {
1702
+ diffCurr.innerHTML = "";
1703
+ diffCurr.appendChild(tpl.content.cloneNode(true));
1704
+ } else if (tpl) {
1705
+ diffCurr.innerHTML = tpl.innerHTML || "";
1706
+ }
1707
+ }
1708
+
1709
+ titleEl.textContent = (idx + 1) + ". t=" + f.atSeconds.toFixed(3) + "s — " + f.label;
1710
+ const bits = [];
1711
+ if (f.kind) bits.push("kind=" + f.kind);
1712
+ if (typeof f.changedCount === "number") bits.push("changed=" + f.changedCount);
1713
+ if (f.stepInfo && typeof f.stepInfo.ok === "boolean") bits.push("ok=" + String(f.stepInfo.ok));
1714
+ subEl.textContent = bits.join(" ");
1715
+
1716
+ // Auto-switch to errors tab if step failed
1717
+ if (f.stepInfo && !f.stepInfo.ok && activeTab === "snapshot") {
1718
+ switchTab("errors");
1719
+ }
1720
+ }
1721
+
1722
+ function select(idx, updateHash) {
1723
+ current = Math.max(0, Math.min(buttons.length - 1, idx));
1724
+ updateSelected();
1725
+ renderFrame(current);
1726
+ if (updateHash) location.hash = frames[current]?.id ? "#" + frames[current].id : "";
1727
+ // Scroll button into view
1728
+ if (buttons[current]) buttons[current].scrollIntoView({ block: "nearest" });
1729
+ }
1730
+
1731
+ function selectById(id) {
1732
+ const idx = idToIndex.get(id);
1733
+ if (typeof idx === "number") select(idx, false);
1734
+ }
1735
+
1736
+ for (const btn of buttons) {
1737
+ btn.addEventListener("click", function () {
1738
+ select(Number(btn.dataset.idx || "0"), true);
1739
+ });
1740
+ }
1741
+
1742
+ modeAll.addEventListener("click", () => setMode("all"));
1743
+ modeChanged.addEventListener("click", () => setMode("changed"));
1744
+ modeMarks.addEventListener("click", () => setMode("marks"));
1745
+ modeFailed.addEventListener("click", () => setMode("failed"));
1746
+ searchEl.addEventListener("input", applyFilter);
1747
+
1748
+ window.addEventListener("hashchange", function () {
1749
+ const id = (location.hash || "").replace(/^#/, "");
1750
+ if (id) selectById(id);
1751
+ });
1752
+
1753
+ document.addEventListener("keydown", function (e) {
1754
+ const tag = (document.activeElement && document.activeElement.tagName) || "";
1755
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
1756
+
1757
+ // Navigation
1758
+ if (e.key === "ArrowDown" || e.key === "j") {
1759
+ e.preventDefault();
1760
+ let next = current + 1;
1761
+ while (next < buttons.length && buttons[next].parentElement.style.display === "none") next += 1;
1762
+ if (next < buttons.length) select(next, true);
1763
+ } else if (e.key === "ArrowUp" || e.key === "k") {
1764
+ e.preventDefault();
1765
+ let next = current - 1;
1766
+ while (next >= 0 && buttons[next].parentElement.style.display === "none") next -= 1;
1767
+ if (next >= 0) select(next, true);
1768
+ }
1769
+
1770
+ // Tab switching with number keys
1771
+ if (e.key === "1") switchTab("snapshot");
1772
+ if (e.key === "2") switchTab("call");
1773
+ if (e.key === "3") switchTab("errors");
1774
+ if (e.key === "4") switchTab("diff");
1775
+ });
1776
+
1777
+ // Initial frame: prefer hash, otherwise first failing, otherwise final.
1778
+ const hashId = (location.hash || "").replace(/^#/, "");
1779
+ if (hashId) {
1780
+ selectById(hashId);
1781
+ } else {
1782
+ const firstFail = buttons.findIndex((b) => b.dataset.ok === "false");
1783
+ if (firstFail >= 0) select(firstFail, false);
1784
+ else select(buttons.length - 1, false);
1785
+ }
1786
+
1787
+ applyFilter();
1788
+ })();
1789
+ <\/script>
1790
+ </section>
1791
+ <section class="summary">
1792
+ <h2>Summary</h2>
1793
+ <pre>${escapeHtml([
1794
+ `result=${resultLabel}`,
1795
+ `marks=${markFrames.length}`,
1796
+ `frames=${input.frames.length}`,
1797
+ `duration=${durationSeconds.toFixed(3)}s`,
1798
+ input.result?.ok === false && input.result.error ? `error=${input.result.error}` : null,
1799
+ input.result?.ok === false && input.result.failureStep ? `failedStep=${input.result.failureStep.index} ${input.result.failureStep.type}` : null
1800
+ ].filter(Boolean).join("\n"))}</pre>
1801
+ </section>
1802
+ </main>
1803
+ </body>
1804
+ </html>`;
1805
+ }
1806
+ function jsonForHtml(data) {
1807
+ return JSON.stringify(data).replaceAll("<", "\\u003c");
1808
+ }
1809
+ function renderSnapshotViewHtml(options) {
1810
+ const headerLine = formatHeaderLine({
1811
+ sessionId: options.sessionId,
1812
+ scope: options.scope,
1813
+ hash: options.hash,
1814
+ meta: options.meta,
1815
+ changedCount: options.changedLines.size
1816
+ });
1817
+ const digits = Math.max(2, String(options.lines.length).length);
1818
+ const out = [`<span class="headerblock">${escapeHtml(headerLine)}</span>`];
1819
+ if (options.scope === "visible") {
1820
+ for (let i = 0; i < options.lines.length; i += 1) {
1821
+ const n = i + 1;
1822
+ const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
1823
+ const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
1824
+ const contentHtml = renderVisibleRowHtml(options.terminal, i, options.trimRight);
1825
+ const rowClass = options.changedLines.has(i) ? "row changed" : "row";
1826
+ out.push(`<span class="${rowClass}">${prefixHtml}${contentHtml}</span>`);
1827
+ }
1828
+ return out.join("");
1829
+ }
1830
+ for (let i = 0; i < options.lines.length; i += 1) {
1831
+ const n = i + 1;
1832
+ const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
1833
+ const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
1834
+ out.push(`<span class="row">${prefixHtml}${escapeHtml(options.lines[i] ?? "")}</span>`);
1835
+ }
1836
+ return out.join("");
1837
+ }
1838
+ function renderVisibleRowHtml(terminal, rowIndex, trimRight) {
1839
+ const buffer = terminal.buffer.active;
1840
+ const nullCell = buffer.getNullCell();
1841
+ const startY = buffer.viewportY;
1842
+ const line = buffer.getLine(startY + rowIndex);
1843
+ const endCol = trimRight ? findMeaningfulEndCol(line, terminal.cols, nullCell) : terminal.cols;
1844
+ const segments = [];
1845
+ let currentKey = null;
1846
+ let currentStyle = null;
1847
+ let currentText = "";
1848
+ const flush = () => {
1849
+ if (!currentStyle) return;
1850
+ if (currentText.length === 0) return;
1851
+ segments.push({
1852
+ key: currentKey ?? styleKey(currentStyle),
1853
+ style: currentStyle,
1854
+ text: currentText
1855
+ });
1856
+ currentText = "";
1857
+ };
1858
+ for (let x = 0; x < endCol; x += 1) {
1859
+ const cell = line?.getCell(x, nullCell);
1860
+ if (!cell) {
1861
+ if (currentStyle) {
1862
+ flush();
1863
+ currentStyle = null;
1864
+ currentKey = null;
1865
+ }
1866
+ continue;
1867
+ }
1868
+ if (cell.getWidth() === 0) continue;
1869
+ const chars = cell.getChars() || " ";
1870
+ const style = extractStyle(cell);
1871
+ const key = styleKey(style);
1872
+ if (!currentStyle) {
1873
+ currentStyle = style;
1874
+ currentKey = key;
1875
+ currentText = chars;
1876
+ continue;
1877
+ }
1878
+ if (key === currentKey) {
1879
+ currentText += chars;
1880
+ continue;
1881
+ }
1882
+ flush();
1883
+ currentStyle = style;
1884
+ currentKey = key;
1885
+ currentText = chars;
1886
+ }
1887
+ if (currentStyle) flush();
1888
+ return segments.map((segment) => renderSegmentHtml(segment.text, segment.style)).join("");
1889
+ }
1890
+ function renderSegmentHtml(text, style) {
1891
+ const safeText = escapeHtml(text);
1892
+ if (isDefaultStyle(style)) return safeText;
1893
+ const css = styleToCss(style);
1894
+ if (!css) return `<span class="seg">${safeText}</span>`;
1895
+ return `<span class="seg" style="${css}">${safeText}</span>`;
1896
+ }
1897
+ function styleToCss(style) {
1898
+ let fg = colorToCss(style.fg);
1899
+ let bg = colorToCss(style.bg);
1900
+ if (style.inverse) {
1901
+ const tmp = fg;
1902
+ fg = bg;
1903
+ bg = tmp;
1904
+ }
1905
+ const decls = [];
1906
+ if (fg) decls.push(`color: ${fg}`);
1907
+ if (bg) decls.push(`background-color: ${bg}`);
1908
+ if (style.bold) decls.push("font-weight: 600");
1909
+ if (style.italic) decls.push("font-style: italic");
1910
+ if (style.dim) decls.push("opacity: 0.75");
1911
+ const decorations = [];
1912
+ if (style.underline) decorations.push("underline");
1913
+ if (style.strikethrough) decorations.push("line-through");
1914
+ if (decorations.length > 0) decls.push(`text-decoration: ${decorations.join(" ")}`);
1915
+ return decls.join("; ");
1916
+ }
1917
+ function colorToCss(color) {
1918
+ if (color.mode === "default") return null;
1919
+ if (color.mode === "rgb") return `#${(color.value & 16777215).toString(16).padStart(6, "0")}`;
1920
+ return xterm256Color(clampInt$1(color.value, 0, 255));
1921
+ }
1922
+ function xterm256Color(idx) {
1923
+ const table16 = [
1924
+ "#000000",
1925
+ "#800000",
1926
+ "#008000",
1927
+ "#808000",
1928
+ "#000080",
1929
+ "#800080",
1930
+ "#008080",
1931
+ "#c0c0c0",
1932
+ "#808080",
1933
+ "#ff0000",
1934
+ "#00ff00",
1935
+ "#ffff00",
1936
+ "#0000ff",
1937
+ "#ff00ff",
1938
+ "#00ffff",
1939
+ "#ffffff"
1940
+ ];
1941
+ if (idx < 16) return table16[idx] ?? "#000000";
1942
+ if (idx >= 16 && idx <= 231) {
1943
+ const c = [
1944
+ 0,
1945
+ 95,
1946
+ 135,
1947
+ 175,
1948
+ 215,
1949
+ 255
1950
+ ];
1951
+ const n = idx - 16;
1952
+ return `rgb(${c[Math.trunc(n / 36) % 6] ?? 0} ${c[Math.trunc(n / 6) % 6] ?? 0} ${c[n % 6] ?? 0})`;
1953
+ }
1954
+ const v = clampInt$1(8 + (idx - 232) * 10, 0, 255);
1955
+ return `rgb(${v} ${v} ${v})`;
1956
+ }
1957
+ function diffLineIndices(previous, next) {
1958
+ const out = /* @__PURE__ */ new Set();
1959
+ const max = Math.max(previous.length, next.length);
1960
+ for (let i = 0; i < max; i += 1) if ((previous[i] ?? "") !== (next[i] ?? "")) out.add(i);
1961
+ return out;
1962
+ }
1963
+ function formatHeaderLine(input) {
1964
+ const cursorViewportRow = input.meta.baseY + input.meta.cursorY - input.meta.viewportY;
1965
+ const cursorViewportCol = input.meta.cursorX;
1966
+ return [
1967
+ `session=${input.sessionId}`,
1968
+ `scope=${input.scope}`,
1969
+ `size=${input.meta.cols}x${input.meta.rows}`,
1970
+ `buffer=${input.meta.bufferType}`,
1971
+ `cursor=${cursorViewportCol + 1},${cursorViewportRow + 1}`,
1972
+ `hash=${input.hash}`,
1973
+ `changed=${input.changedCount}`
1974
+ ].join(" ");
1975
+ }
1976
+ function coerceDisplayString(value) {
1977
+ if (value === null || value === void 0) return "";
1978
+ if (typeof value === "string") return value;
1979
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1980
+ try {
1981
+ return JSON.stringify(value) ?? "";
1982
+ } catch {
1983
+ return "";
1984
+ }
1985
+ }
1986
+ function escapeHtml(text) {
1987
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
1988
+ }
1989
+ function getMeta(terminal) {
1990
+ const buffer = terminal.buffer.active;
1991
+ return {
1992
+ cols: terminal.cols,
1993
+ rows: terminal.rows,
1994
+ bufferType: buffer.type,
1995
+ viewportY: buffer.viewportY,
1996
+ baseY: buffer.baseY,
1997
+ length: buffer.length,
1998
+ cursorX: buffer.cursorX,
1999
+ cursorY: buffer.cursorY
2000
+ };
2001
+ }
2002
+ function parseAsciicast(cast) {
2003
+ const lines = cast.trimEnd().split("\n");
2004
+ const header = safeJsonObject(lines[0]);
2005
+ const events = [];
2006
+ for (const line of lines.slice(1)) {
2007
+ if (!line.trim()) continue;
2008
+ const value = JSON.parse(line);
2009
+ if (!Array.isArray(value) || value.length < 3) continue;
2010
+ const time = Number(value[0]);
2011
+ const type = String(value[1]);
2012
+ const data = String(value[2]);
2013
+ if (!Number.isFinite(time)) continue;
2014
+ if (type === "o" || type === "i" || type === "r" || type === "m") events.push([
2015
+ time,
2016
+ type,
2017
+ data
2018
+ ]);
2019
+ }
2020
+ return {
2021
+ header,
2022
+ events
2023
+ };
2024
+ }
2025
+ function safeJsonObject(line) {
2026
+ if (!line) return {};
2027
+ try {
2028
+ const value = JSON.parse(line);
2029
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2030
+ } catch {
2031
+ return {};
2032
+ }
2033
+ }
2034
+ function getTermInfo(header) {
2035
+ if (Number(header.version ?? 2) === 3) {
2036
+ const term = header.term;
2037
+ return {
2038
+ cols: clampInt$1(Number(term?.cols ?? 80), 1, 500),
2039
+ rows: clampInt$1(Number(term?.rows ?? 24), 1, 300),
2040
+ type: typeof term?.type === "string" ? term.type : "xterm-256color"
2041
+ };
2042
+ }
2043
+ return {
2044
+ cols: clampInt$1(Number(header.width ?? 80), 1, 500),
2045
+ rows: clampInt$1(Number(header.height ?? 24), 1, 300),
2046
+ type: typeof header.term === "string" ? header.term : "xterm-256color"
2047
+ };
2048
+ }
2049
+ function parseResize(value) {
2050
+ const match = /^\s*(\d+)x(\d+)\s*$/.exec(value);
2051
+ if (!match) return null;
2052
+ return {
2053
+ cols: clampInt$1(Number(match[1] ?? 0), 1, 500),
2054
+ rows: clampInt$1(Number(match[2] ?? 0), 1, 300)
2055
+ };
2056
+ }
2057
+ function clampInt$1(value, min, max) {
2058
+ if (!Number.isFinite(value)) return min;
2059
+ const int = Math.trunc(value);
2060
+ if (int < min) return min;
2061
+ if (int > max) return max;
2062
+ return int;
2063
+ }
2064
+ if (import.meta.main) {
2065
+ const inputPath = process.argv[2];
2066
+ if (!inputPath) {
2067
+ console.error("Usage: bun run src/trace/report.ts <path/to/cast>");
2068
+ process.exit(2);
2069
+ }
2070
+ const html = await generateTraceReportHtml(await Bun.file(inputPath).text());
2071
+ const outPath = join(dirname(inputPath), `${basename(inputPath, extname(inputPath))}.report.html`);
2072
+ writeFileSync(outPath, html);
2073
+ ensureAsciinemaPlayerAssets(outPath);
2074
+ console.log(outPath);
2075
+ }
2076
+ //#endregion
2077
+ //#region src/script/frame_session.ts
2078
+ var FrameSession = class {
2079
+ id;
2080
+ backend;
2081
+ frames;
2082
+ advanceOnInput;
2083
+ trace;
2084
+ snapshotRing = [];
2085
+ rawOutputRing = [];
2086
+ colsValue;
2087
+ rowsValue;
2088
+ activeFrame = 0;
2089
+ closed = {
2090
+ type: "process_exit",
2091
+ exitCode: 0
2092
+ };
2093
+ constructor(options) {
2094
+ if (options.frames.length === 0) throw new Error("frame backend requires at least one frame");
2095
+ this.id = options.id ?? crypto.randomUUID();
2096
+ this.backend = options.backend;
2097
+ this.frames = [...options.frames];
2098
+ this.advanceOnInput = options.advanceOnInput ?? true;
2099
+ this.colsValue = options.cols ?? inferCols(this.frames);
2100
+ this.rowsValue = options.rows ?? inferRows(this.frames);
2101
+ this.trace = new TraceRecorder({
2102
+ version: 2,
2103
+ width: this.colsValue,
2104
+ height: this.rowsValue,
2105
+ timestamp: Math.floor(Date.now() / 1e3),
2106
+ title: options.title ?? `${options.backend} frame backend`,
2107
+ command: `${options.backend}:frame`,
2108
+ term: `${options.backend}-test-backend`
2109
+ });
2110
+ this.recordCurrentFrameOutput();
2111
+ }
2112
+ get cols() {
2113
+ return this.colsValue;
2114
+ }
2115
+ get rows() {
2116
+ return this.rowsValue;
2117
+ }
2118
+ resize(cols, rows) {
2119
+ this.colsValue = clampInt(cols, 1, 500);
2120
+ this.rowsValue = clampInt(rows, 1, 300);
2121
+ this.trace.recordResize(this.colsValue, this.rowsValue);
2122
+ }
2123
+ sendText(text, options) {
2124
+ const payload = options?.enter ? `${text}\r` : text;
2125
+ this.trace.recordInput(payload);
2126
+ this.advanceFrame();
2127
+ }
2128
+ pressKey(key) {
2129
+ this.trace.recordInput(encodeKey(key));
2130
+ this.advanceFrame();
2131
+ }
2132
+ sendMouse(event) {
2133
+ this.trace.recordInput(encodeSgrMouse(event));
2134
+ this.advanceFrame();
2135
+ }
2136
+ mark(label) {
2137
+ this.trace.mark(label);
2138
+ }
2139
+ async flush() {
2140
+ await Promise.resolve();
2141
+ }
2142
+ getMeta() {
2143
+ return {
2144
+ cols: this.colsValue,
2145
+ rows: this.rowsValue,
2146
+ bufferType: "normal",
2147
+ viewportY: 0,
2148
+ baseY: 0,
2149
+ length: this.visibleLines({ trimRight: true }).length,
2150
+ cursorX: 0,
2151
+ cursorY: Math.max(0, Math.min(this.rowsValue - 1, this.currentLines().length - 1))
2152
+ };
2153
+ }
2154
+ async snapshotText(options) {
2155
+ if (options?.maxLines !== void 0 && options.tailLines !== void 0) throw new Error("snapshotText: maxLines and tailLines are mutually exclusive");
2156
+ let lines = options?.scope === "buffer" ? this.currentLines({ trimRight: options?.trimRight }) : this.visibleLines({ trimRight: options?.trimRight });
2157
+ if (options?.trimBottom ?? true) lines = trimBottomEmptyLines(lines);
2158
+ lines = sliceLines(lines, options);
2159
+ lines = applyTextMaskRules(lines, options?.mask);
2160
+ const text = lines.join("\n");
2161
+ const hash = fnv1a32(text);
2162
+ if (options?.captureFrame ?? true) this.captureFrame(text, hash);
2163
+ return {
2164
+ text,
2165
+ hash
2166
+ };
2167
+ }
2168
+ async snapshotAnsi(options) {
2169
+ const { text, hash } = await this.snapshotText(options);
2170
+ return {
2171
+ ansi: text,
2172
+ plain: text,
2173
+ hash,
2174
+ lines: text.split("\n").map((line) => ({
2175
+ ansi: line,
2176
+ plain: line
2177
+ }))
2178
+ };
2179
+ }
2180
+ async snapshotGrid(options) {
2181
+ const lines = this.visibleLines({ trimRight: options?.trimRight });
2182
+ const grid = {
2183
+ cols: this.colsValue,
2184
+ rows: this.rowsValue,
2185
+ bufferType: "normal",
2186
+ cursorX: 0,
2187
+ cursorY: Math.max(0, Math.min(this.rowsValue - 1, this.currentLines().length - 1)),
2188
+ viewportY: 0,
2189
+ lines,
2190
+ styleRuns: options?.includeStyles ? lines.map(() => []) : void 0
2191
+ };
2192
+ const hash = fnv1a32(JSON.stringify(grid));
2193
+ if (options?.captureFrame ?? true) this.captureFrame(lines.join("\n"), hash);
2194
+ return {
2195
+ grid,
2196
+ hash
2197
+ };
2198
+ }
2199
+ async snapshotCast(options) {
2200
+ await this.flush();
2201
+ return this.trace.snapshot({ tailEvents: options?.tailEvents });
2202
+ }
2203
+ async waitForText(args) {
2204
+ const startedAt = Date.now();
2205
+ while (true) {
2206
+ const snapshot = await this.snapshotText({
2207
+ scope: args.scope,
2208
+ captureFrame: true
2209
+ });
2210
+ if (args.text && snapshot.text.includes(args.text)) return {
2211
+ found: true,
2212
+ ...snapshot
2213
+ };
2214
+ if (args.regex && args.regex.test(snapshot.text)) return {
2215
+ found: true,
2216
+ ...snapshot
2217
+ };
2218
+ if (Date.now() - startedAt >= args.timeoutMs) return {
2219
+ found: false,
2220
+ ...snapshot
2221
+ };
2222
+ await sleep(Math.max(1, args.intervalMs));
2223
+ }
2224
+ }
2225
+ async waitForStableScreen() {
2226
+ return {
2227
+ stable: true,
2228
+ ...await this.snapshotText({ captureFrame: true })
2229
+ };
2230
+ }
2231
+ isClosed() {
2232
+ return this.closed !== null;
2233
+ }
2234
+ getCloseReason() {
2235
+ return this.closed;
2236
+ }
2237
+ close() {
2238
+ if (!this.closed) this.closed = { type: "closed_by_user" };
2239
+ }
2240
+ getSnapshotFrames() {
2241
+ return [...this.snapshotRing];
2242
+ }
2243
+ getRawOutputChunks() {
2244
+ return [...this.rawOutputRing];
2245
+ }
2246
+ advanceFrame() {
2247
+ if (!this.advanceOnInput) return;
2248
+ if (this.activeFrame >= this.frames.length - 1) return;
2249
+ this.activeFrame += 1;
2250
+ this.recordCurrentFrameOutput();
2251
+ }
2252
+ currentText() {
2253
+ return this.frames[this.activeFrame] ?? "";
2254
+ }
2255
+ currentLines(options) {
2256
+ const trimRight = options?.trimRight ?? true;
2257
+ return normalizeNewlines(this.currentText()).split("\n").map((line) => normalizeLineWidth(line, this.colsValue, trimRight));
2258
+ }
2259
+ visibleLines(options) {
2260
+ const lines = this.currentLines(options).slice(0, this.rowsValue);
2261
+ while (lines.length < this.rowsValue) lines.push("");
2262
+ return lines;
2263
+ }
2264
+ captureFrame(text, hash) {
2265
+ this.snapshotRing.push({
2266
+ atMs: Date.now(),
2267
+ hash,
2268
+ text
2269
+ });
2270
+ while (this.snapshotRing.length > 50) this.snapshotRing.shift();
2271
+ }
2272
+ recordCurrentFrameOutput() {
2273
+ const text = this.currentText();
2274
+ const output = `\x1b[2J\x1b[H${text}`;
2275
+ this.rawOutputRing.push(text);
2276
+ while (this.rawOutputRing.length > 50) this.rawOutputRing.shift();
2277
+ this.trace.recordOutput(output);
2278
+ }
2279
+ };
2280
+ async function createFrameSessionFromLaunch(args) {
2281
+ const backend = args.launch.backend;
2282
+ if (backend === void 0 || backend === "pty") throw new Error("createFrameSessionFromLaunch requires a non-pty backend");
2283
+ return new FrameSession({
2284
+ backend,
2285
+ frames: await resolveLaunchFrames(args.launch, args.cwd, backend),
2286
+ cols: args.launch.cols,
2287
+ rows: args.launch.rows,
2288
+ title: args.title,
2289
+ advanceOnInput: args.launch.advanceOnInput
2290
+ });
2291
+ }
2292
+ async function resolveLaunchFrames(launch, cwd, backend) {
2293
+ const frames = [];
2294
+ if (launch.frames?.length) frames.push(...normalizeFrames(launch.frames));
2295
+ if (launch.frame !== void 0) frames.push(launch.frame);
2296
+ if (launch.framePath) {
2297
+ const path = resolveLaunchPath(cwd, launch.framePath);
2298
+ frames.push(readFileSync(path, "utf8").replace(/\n$/, ""));
2299
+ }
2300
+ if (launch.frameModule) frames.push(...await loadFrameModule(resolveLaunchPath(cwd, launch.frameModule), backend));
2301
+ if (frames.length === 0) throw new Error(`launch.backend=${backend} requires frame, frames, framePath, or frameModule`);
2302
+ return frames;
2303
+ }
2304
+ async function loadFrameModule(modulePath, backend) {
2305
+ return normalizeFrames(await materializeFrameSource(selectModuleFrameSource(await import(pathToFileURL(modulePath).href), backend)));
2306
+ }
2307
+ function selectModuleFrameSource(mod, backend) {
2308
+ if (mod.frames !== void 0) return mod.frames;
2309
+ if (mod.default !== void 0) return mod.default;
2310
+ if (backend === "ink" && mod.lastFrame !== void 0) return mod.lastFrame;
2311
+ if (backend === "ink" && mod.frame !== void 0) return mod.frame;
2312
+ if (backend === "ratatui" && mod.snapshot !== void 0) return mod.snapshot;
2313
+ if (mod.frame !== void 0) return mod.frame;
2314
+ if (mod.snapshot !== void 0) return mod.snapshot;
2315
+ throw new Error(`frame module did not export frames/default/frame/snapshot/lastFrame`);
2316
+ }
2317
+ async function materializeFrameSource(source) {
2318
+ if (typeof source === "function") return await source();
2319
+ return source;
2320
+ }
2321
+ function normalizeFrames(source) {
2322
+ if (Array.isArray(source)) return source.map((frame) => normalizeFrame(frame));
2323
+ return [normalizeFrame(source)];
2324
+ }
2325
+ function normalizeFrame(frame) {
2326
+ if (typeof frame === "string") return normalizeNewlines(frame).replace(/\n$/, "");
2327
+ if (typeof frame === "object" && frame !== null) {
2328
+ const value = frame;
2329
+ const text = typeof value.text === "string" ? value.text : typeof value.frame === "string" ? value.frame : typeof value.snapshot === "string" ? value.snapshot : typeof value.lastFrame === "string" ? value.lastFrame : void 0;
2330
+ if (text !== void 0) return normalizeNewlines(text).replace(/\n$/, "");
2331
+ }
2332
+ throw new Error("frame entries must be strings or objects with text/frame/snapshot/lastFrame");
2333
+ }
2334
+ function resolveLaunchPath(cwd, path) {
2335
+ return isAbsolute(path) ? path : resolve(cwd, path);
2336
+ }
2337
+ function normalizeNewlines(text) {
2338
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2339
+ }
2340
+ function normalizeLineWidth(line, cols, trimRight) {
2341
+ const clipped = line.length > cols ? line.slice(0, cols) : line;
2342
+ return trimRight ? clipped.trimEnd() : clipped.padEnd(cols, " ");
2343
+ }
2344
+ function trimBottomEmptyLines(lines) {
2345
+ let end = lines.length;
2346
+ while (end > 0 && (lines[end - 1] ?? "").trim() === "") end -= 1;
2347
+ return lines.slice(0, end);
2348
+ }
2349
+ function sliceLines(lines, options) {
2350
+ if (options?.maxLines !== void 0) return lines.slice(0, Math.max(0, Math.trunc(options.maxLines)));
2351
+ if (options?.tailLines !== void 0) {
2352
+ const tail = Math.max(0, Math.trunc(options.tailLines));
2353
+ return lines.slice(Math.max(0, lines.length - tail));
2354
+ }
2355
+ return lines;
2356
+ }
2357
+ function inferCols(frames) {
2358
+ return clampInt(frames.reduce((acc, frame) => {
2359
+ const lines = normalizeNewlines(frame).split("\n");
2360
+ return Math.max(acc, ...lines.map((line) => line.length));
2361
+ }, 0) || 80, 1, 500);
2362
+ }
2363
+ function inferRows(frames) {
2364
+ return clampInt(frames.reduce((acc, frame) => {
2365
+ return Math.max(acc, normalizeNewlines(frame).split("\n").length);
2366
+ }, 0) || 24, 1, 300);
2367
+ }
2368
+ function clampInt(value, min, max) {
2369
+ if (!Number.isFinite(value)) return min;
2370
+ const int = Math.trunc(value);
2371
+ if (int < min) return min;
2372
+ if (int > max) return max;
2373
+ return int;
2374
+ }
2375
+ //#endregion
2376
+ //#region src/script/schema.ts
2377
+ const textMaskRuleSchema = z.object({
2378
+ regex: z.string().min(1),
2379
+ flags: z.string().optional(),
2380
+ replacement: z.string().optional(),
2381
+ preserveLength: z.boolean().optional()
2382
+ });
2383
+ const launchConfigSchema = z.object({
2384
+ backend: z.enum([
2385
+ "pty",
2386
+ "frames",
2387
+ "ink",
2388
+ "ratatui"
2389
+ ]).optional(),
2390
+ command: z.string().min(1).optional(),
2391
+ args: z.array(z.string()).optional(),
2392
+ cwd: z.string().optional(),
2393
+ env: z.record(z.string()).optional(),
2394
+ cols: z.number().int().positive().optional(),
2395
+ rows: z.number().int().positive().optional(),
2396
+ name: z.string().optional(),
2397
+ frame: z.string().optional(),
2398
+ frames: z.array(z.union([z.string(), z.object({
2399
+ name: z.string().optional(),
2400
+ text: z.string().optional(),
2401
+ frame: z.string().optional(),
2402
+ snapshot: z.string().optional(),
2403
+ lastFrame: z.string().optional()
2404
+ })])).optional(),
2405
+ framePath: z.string().optional(),
2406
+ frameModule: z.string().optional(),
2407
+ advanceOnInput: z.boolean().optional()
2408
+ }).superRefine((value, ctx) => {
2409
+ if ((value.backend ?? "pty") === "pty") {
2410
+ if (!value.command) ctx.addIssue({
2411
+ code: z.ZodIssueCode.custom,
2412
+ path: ["command"],
2413
+ message: "launch.command is required when backend=pty"
2414
+ });
2415
+ return;
2416
+ }
2417
+ if (!value.frame && !value.frames?.length && !value.framePath && !value.frameModule) ctx.addIssue({
2418
+ code: z.ZodIssueCode.custom,
2419
+ message: "framework launch requires one of frame, frames, framePath, or frameModule when backend is not pty"
2420
+ });
2421
+ });
2422
+ const scriptTraceSchema = z.object({
2423
+ saveCast: z.boolean().optional(),
2424
+ saveReport: z.boolean().optional(),
2425
+ castPath: z.string().optional(),
2426
+ reportPath: z.string().optional(),
2427
+ reportScope: z.enum(["visible", "buffer"]).optional(),
2428
+ reportMaxFrames: z.number().int().positive().optional()
2429
+ });
2430
+ const sendTextStepSchema = z.object({
2431
+ type: z.literal("sendText"),
2432
+ text: z.string(),
2433
+ enter: z.boolean().optional()
2434
+ });
2435
+ const pressKeyStepSchema = z.object({
2436
+ type: z.literal("pressKey"),
2437
+ key: z.string().min(1)
2438
+ });
2439
+ const sendMouseStepSchema = z.object({
2440
+ type: z.literal("sendMouse"),
2441
+ action: z.enum([
2442
+ "down",
2443
+ "up",
2444
+ "move",
2445
+ "click",
2446
+ "scroll_up",
2447
+ "scroll_down"
2448
+ ]),
2449
+ x: z.number().int(),
2450
+ y: z.number().int(),
2451
+ button: z.enum([
2452
+ "left",
2453
+ "middle",
2454
+ "right"
2455
+ ]).optional(),
2456
+ shift: z.boolean().optional(),
2457
+ alt: z.boolean().optional(),
2458
+ ctrl: z.boolean().optional()
2459
+ });
2460
+ const resizeStepSchema = z.object({
2461
+ type: z.literal("resize"),
2462
+ cols: z.number().int().positive(),
2463
+ rows: z.number().int().positive()
2464
+ });
2465
+ const markStepSchema = z.object({
2466
+ type: z.literal("mark"),
2467
+ label: z.string().optional()
2468
+ });
2469
+ const sleepStepSchema = z.object({
2470
+ type: z.literal("sleep"),
2471
+ ms: z.number().int().nonnegative()
2472
+ });
2473
+ const waitForTextStepSchema = z.object({
2474
+ type: z.literal("waitForText"),
2475
+ scope: z.enum(["visible", "buffer"]).optional(),
2476
+ text: z.string().optional(),
2477
+ regex: z.string().optional(),
2478
+ timeoutMs: z.number().int().positive().optional(),
2479
+ intervalMs: z.number().int().positive().optional()
2480
+ }).superRefine((value, ctx) => {
2481
+ if (!value.text && !value.regex) ctx.addIssue({
2482
+ code: z.ZodIssueCode.custom,
2483
+ message: "waitForText requires text or regex"
2484
+ });
2485
+ });
2486
+ const waitForStableScreenStepSchema = z.object({
2487
+ type: z.literal("waitForStableScreen"),
2488
+ timeoutMs: z.number().int().positive().optional(),
2489
+ quietMs: z.number().int().positive().optional(),
2490
+ intervalMs: z.number().int().positive().optional()
2491
+ });
2492
+ const waitForExitStepSchema = z.object({
2493
+ type: z.literal("waitForExit"),
2494
+ timeoutMs: z.number().int().positive().optional(),
2495
+ intervalMs: z.number().int().positive().optional(),
2496
+ exitCode: z.number().int().optional(),
2497
+ signal: z.union([z.number().int(), z.string()]).optional()
2498
+ });
2499
+ const expectMetaStepSchema = z.object({
2500
+ type: z.literal("expectMeta"),
2501
+ bufferType: z.enum(["normal", "alternate"]).optional(),
2502
+ cols: z.number().int().positive().optional(),
2503
+ rows: z.number().int().positive().optional(),
2504
+ cursor: z.object({
2505
+ x: z.number().int().positive(),
2506
+ y: z.number().int().positive()
2507
+ }).optional()
2508
+ }).superRefine((value, ctx) => {
2509
+ if (value.bufferType === void 0 && value.cols === void 0 && value.rows === void 0 && value.cursor === void 0) ctx.addIssue({
2510
+ code: z.ZodIssueCode.custom,
2511
+ message: "expectMeta requires at least one assertion (bufferType/cols/rows/cursor)"
2512
+ });
2513
+ });
2514
+ const snapshotStepSchema = z.object({
2515
+ type: z.literal("snapshot"),
2516
+ kind: z.enum([
2517
+ "text",
2518
+ "view",
2519
+ "ansi",
2520
+ "view_ansi",
2521
+ "grid"
2522
+ ]),
2523
+ scope: z.enum(["visible", "buffer"]).optional(),
2524
+ trimRight: z.boolean().optional(),
2525
+ trimBottom: z.boolean().optional(),
2526
+ maxLines: z.number().int().positive().optional(),
2527
+ tailLines: z.number().int().positive().optional(),
2528
+ lineNumbers: z.boolean().optional(),
2529
+ includeStyles: z.boolean().optional(),
2530
+ mask: z.array(textMaskRuleSchema).optional(),
2531
+ saveAs: z.string().optional(),
2532
+ saveTo: z.string().optional()
2533
+ }).superRefine((value, ctx) => {
2534
+ if (value.maxLines !== void 0 && value.tailLines !== void 0) ctx.addIssue({
2535
+ code: z.ZodIssueCode.custom,
2536
+ message: "snapshot: maxLines and tailLines are mutually exclusive"
2537
+ });
2538
+ });
2539
+ const expectStepSchema = z.object({
2540
+ type: z.literal("expect"),
2541
+ from: z.string().optional(),
2542
+ equals: z.string().optional(),
2543
+ contains: z.array(z.string()).optional(),
2544
+ notContains: z.array(z.string()).optional(),
2545
+ regex: z.string().optional()
2546
+ }).superRefine((value, ctx) => {
2547
+ if (value.equals === void 0 && !value.contains?.length && !value.notContains?.length && !value.regex) ctx.addIssue({
2548
+ code: z.ZodIssueCode.custom,
2549
+ message: "expect requires at least one matcher (equals/contains/notContains/regex)"
2550
+ });
2551
+ });
2552
+ const expectGoldenStepSchema = z.object({
2553
+ type: z.literal("expectGolden"),
2554
+ from: z.string().optional(),
2555
+ path: z.string().min(1)
2556
+ });
2557
+ const customStepSchema = z.object({
2558
+ type: z.literal("custom"),
2559
+ name: z.string().min(1),
2560
+ payload: z.unknown().optional()
2561
+ });
2562
+ const assertStepSchema = z.object({
2563
+ type: z.literal("assert"),
2564
+ scope: z.enum(["visible", "buffer"]).optional(),
2565
+ text: z.string().optional(),
2566
+ regex: z.string().optional(),
2567
+ description: z.string().optional()
2568
+ }).superRefine((value, ctx) => {
2569
+ if (!value.text && !value.regex) ctx.addIssue({
2570
+ code: z.ZodIssueCode.custom,
2571
+ message: "assert requires text or regex"
2572
+ });
2573
+ });
2574
+ const assertSemanticStepSchema = z.object({
2575
+ type: z.literal("assertSemantic"),
2576
+ prompt: z.string().min(1),
2577
+ description: z.string().optional()
2578
+ });
2579
+ const scriptStepSchema = z.union([
2580
+ sendTextStepSchema,
2581
+ pressKeyStepSchema,
2582
+ sendMouseStepSchema,
2583
+ resizeStepSchema,
2584
+ markStepSchema,
2585
+ sleepStepSchema,
2586
+ waitForTextStepSchema,
2587
+ waitForStableScreenStepSchema,
2588
+ waitForExitStepSchema,
2589
+ expectMetaStepSchema,
2590
+ snapshotStepSchema,
2591
+ expectStepSchema,
2592
+ expectGoldenStepSchema,
2593
+ customStepSchema,
2594
+ assertStepSchema,
2595
+ assertSemanticStepSchema
2596
+ ]);
2597
+ const scriptSchema = z.object({
2598
+ name: z.string().optional(),
2599
+ artifactsDir: z.string().optional(),
2600
+ launch: launchConfigSchema,
2601
+ trace: scriptTraceSchema.optional(),
2602
+ steps: z.array(scriptStepSchema).min(1)
2603
+ });
2604
+ //#endregion
2605
+ //#region src/script/runner.ts
2606
+ async function runScriptFile(scriptPath, options) {
2607
+ const raw = await Bun.file(scriptPath).text();
2608
+ const parsedJson = JSON.parse(raw);
2609
+ const baseName = basename(scriptPath, extname(scriptPath));
2610
+ return runScript(parsedJson && typeof parsedJson === "object" && !Array.isArray(parsedJson) && !("name" in parsedJson) ? {
2611
+ ...parsedJson,
2612
+ name: baseName
2613
+ } : parsedJson, options);
2614
+ }
2615
+ async function runScript(script, options) {
2616
+ const parsed = scriptSchema.parse(script);
2617
+ const scriptName = parsed.name ?? "script";
2618
+ const artifactsDir = resolveArtifactsDir(parsed, scriptName, options?.artifactsDir);
2619
+ mkdirSync(artifactsDir, { recursive: true });
2620
+ const launch = parsed.launch;
2621
+ const cwd = launch.cwd ? resolve(process.cwd(), launch.cwd) : process.cwd();
2622
+ const backend = launch.backend ?? "pty";
2623
+ let sessions = null;
2624
+ let session;
2625
+ if (backend === "pty") {
2626
+ sessions = new SessionManager({ snapshotRingSize: 50 });
2627
+ if (!launch.command) throw new Error("launch.command is required when backend=pty");
2628
+ session = sessions.launchSession({
2629
+ command: launch.command,
2630
+ args: launch.args ?? [],
2631
+ cwd,
2632
+ env: launch.env,
2633
+ cols: launch.cols,
2634
+ rows: launch.rows,
2635
+ name: launch.name
2636
+ });
2637
+ } else session = await createFrameSessionFromLaunch({
2638
+ launch,
2639
+ cwd,
2640
+ title: scriptName
2641
+ });
2642
+ const closeSession = () => {
2643
+ if (sessions) {
2644
+ sessions.closeAll();
2645
+ return;
2646
+ }
2647
+ session.close();
2648
+ };
2649
+ const snapshots = /* @__PURE__ */ new Map();
2650
+ let last = null;
2651
+ let currentStepIndex = -1;
2652
+ let currentStep = null;
2653
+ const resolveGoldenPath = (path) => isAbsolute(path) ? path : resolve(process.cwd(), path);
2654
+ const resolveArtifactPath = (path) => isAbsolute(path) ? path : resolve(artifactsDir, path);
2655
+ const trace = parsed.trace ?? {};
2656
+ const saveCast = trace.saveCast ?? true;
2657
+ const saveReport = trace.saveReport ?? true;
2658
+ const castPath = resolveArtifactPath(trace.castPath ?? `${scriptName}.cast`);
2659
+ const reportPath = resolveArtifactPath(trace.reportPath ?? `${scriptName}.report.html`);
2660
+ const stepHandlers = options?.steps;
2661
+ const executionSteps = [];
2662
+ try {
2663
+ for (let stepIndex = 0; stepIndex < parsed.steps.length; stepIndex += 1) {
2664
+ const step = parsed.steps[stepIndex];
2665
+ currentStepIndex = stepIndex;
2666
+ currentStep = step;
2667
+ const stepStartedAt = Date.now();
2668
+ const before = last;
2669
+ try {
2670
+ last = await runStep({
2671
+ step,
2672
+ stepIndex,
2673
+ session,
2674
+ snapshots,
2675
+ last,
2676
+ resolveGoldenPath,
2677
+ resolveArtifactPath,
2678
+ updateGoldens: options?.updateGoldens ?? envTruthy(process.env.UPDATE_GOLDENS),
2679
+ stepHandlers,
2680
+ artifactsDir
2681
+ });
2682
+ let after = last;
2683
+ if (!(last !== before)) try {
2684
+ const captured = await session.snapshotText({
2685
+ scope: "visible",
2686
+ trimRight: true,
2687
+ trimBottom: true,
2688
+ captureFrame: true
2689
+ });
2690
+ const lines = captured.text.split("\n");
2691
+ const view = formatSnapshotView({
2692
+ sessionId: session.id,
2693
+ scope: "visible",
2694
+ hash: captured.hash,
2695
+ lines,
2696
+ meta: session.getMeta(),
2697
+ lineNumbers: true
2698
+ });
2699
+ after = {
2700
+ kind: "view",
2701
+ hash: captured.hash,
2702
+ text: view
2703
+ };
2704
+ last = after;
2705
+ } catch {}
2706
+ executionSteps.push({
2707
+ index: stepIndex,
2708
+ step,
2709
+ before,
2710
+ after,
2711
+ durationMs: Date.now() - stepStartedAt,
2712
+ ok: true
2713
+ });
2714
+ } catch (err) {
2715
+ executionSteps.push({
2716
+ index: stepIndex,
2717
+ step,
2718
+ before,
2719
+ after: null,
2720
+ durationMs: Date.now() - stepStartedAt,
2721
+ ok: false,
2722
+ error: err.message
2723
+ });
2724
+ throw err;
2725
+ }
2726
+ }
2727
+ await writeTraceArtifacts({
2728
+ session,
2729
+ artifactsDir,
2730
+ saveCast,
2731
+ castPath,
2732
+ saveReport,
2733
+ reportPath,
2734
+ reportScope: trace.reportScope,
2735
+ reportMaxFrames: trace.reportMaxFrames,
2736
+ scriptName,
2737
+ result: { ok: true },
2738
+ executionSteps
2739
+ });
2740
+ writeTestDataArtifact({
2741
+ artifactsDir,
2742
+ scriptName,
2743
+ ok: true,
2744
+ executionSteps,
2745
+ resolveArtifactPath
2746
+ });
2747
+ closeSession();
2748
+ return {
2749
+ ok: true,
2750
+ artifactsDir
2751
+ };
2752
+ } catch (error) {
2753
+ try {
2754
+ writeTestDataArtifact({
2755
+ artifactsDir,
2756
+ scriptName,
2757
+ ok: false,
2758
+ error: error.message,
2759
+ executionSteps,
2760
+ resolveArtifactPath
2761
+ });
2762
+ await writeFailureArtifacts({
2763
+ session,
2764
+ artifactsDir,
2765
+ scriptName,
2766
+ stepIndex: currentStepIndex,
2767
+ step: currentStep,
2768
+ last,
2769
+ error
2770
+ });
2771
+ await writeTraceArtifacts({
2772
+ session,
2773
+ artifactsDir,
2774
+ saveCast,
2775
+ castPath,
2776
+ saveReport,
2777
+ reportPath,
2778
+ reportScope: trace.reportScope,
2779
+ reportMaxFrames: trace.reportMaxFrames,
2780
+ scriptName,
2781
+ result: {
2782
+ ok: false,
2783
+ error: error.message,
2784
+ failureStep: currentStep ? {
2785
+ index: currentStepIndex + 1,
2786
+ type: formatStepLabel(currentStep)
2787
+ } : void 0
2788
+ },
2789
+ executionSteps
2790
+ });
2791
+ } catch {} finally {
2792
+ closeSession();
2793
+ }
2794
+ throw error;
2795
+ }
2796
+ }
2797
+ function resolveArtifactsDir(script, scriptName, override) {
2798
+ if (override?.trim()) return resolve(override.trim());
2799
+ if (script.artifactsDir?.trim()) return resolve(script.artifactsDir.trim());
2800
+ return resolve(".tmp", "runs", scriptName);
2801
+ }
2802
+ async function runStep(args) {
2803
+ const { step } = args;
2804
+ try {
2805
+ if (step.type === "sendText") {
2806
+ args.session.sendText(step.text, { enter: step.enter });
2807
+ return args.last;
2808
+ }
2809
+ if (step.type === "pressKey") {
2810
+ args.session.pressKey(step.key);
2811
+ return args.last;
2812
+ }
2813
+ if (step.type === "sendMouse") {
2814
+ const modifiers = step.shift || step.alt || step.ctrl ? {
2815
+ shift: step.shift,
2816
+ alt: step.alt,
2817
+ ctrl: step.ctrl
2818
+ } : void 0;
2819
+ args.session.sendMouse({
2820
+ action: step.action,
2821
+ x: step.x,
2822
+ y: step.y,
2823
+ button: step.button,
2824
+ modifiers
2825
+ });
2826
+ return args.last;
2827
+ }
2828
+ if (step.type === "resize") {
2829
+ args.session.resize(step.cols, step.rows);
2830
+ return args.last;
2831
+ }
2832
+ if (step.type === "mark") {
2833
+ args.session.mark(step.label);
2834
+ return args.last;
2835
+ }
2836
+ if (step.type === "sleep") {
2837
+ await sleep(step.ms);
2838
+ return args.last;
2839
+ }
2840
+ if (step.type === "waitForText") {
2841
+ const regex = step.regex ? new RegExp(step.regex) : void 0;
2842
+ if (!(await args.session.waitForText({
2843
+ scope: step.scope,
2844
+ text: step.text,
2845
+ regex,
2846
+ timeoutMs: step.timeoutMs ?? 1e4,
2847
+ intervalMs: step.intervalMs ?? 100
2848
+ })).found) throw new Error(`step ${args.stepIndex + 1} waitForText not found: ${step.text ?? step.regex ?? ""}`);
2849
+ return args.last;
2850
+ }
2851
+ if (step.type === "waitForStableScreen") {
2852
+ if (!(await args.session.waitForStableScreen({
2853
+ timeoutMs: step.timeoutMs ?? 1e4,
2854
+ quietMs: step.quietMs ?? 400,
2855
+ intervalMs: step.intervalMs ?? 80
2856
+ })).stable) throw new Error(`step ${args.stepIndex + 1} waitForStableScreen timed out`);
2857
+ return args.last;
2858
+ }
2859
+ if (step.type === "waitForExit") {
2860
+ const startedAt = Date.now();
2861
+ const timeoutMs = step.timeoutMs ?? 1e4;
2862
+ const intervalMs = step.intervalMs ?? 50;
2863
+ while (Date.now() - startedAt <= timeoutMs) {
2864
+ if (args.session.isClosed()) break;
2865
+ await sleep(intervalMs);
2866
+ }
2867
+ const reason = args.session.getCloseReason();
2868
+ if (!reason) throw new Error("waitForExit timed out");
2869
+ if (reason.type !== "process_exit") throw new Error("waitForExit: session was closed by user");
2870
+ if (step.exitCode !== void 0 && reason.exitCode !== step.exitCode) throw new Error(`waitForExit: exitCode mismatch (got ${reason.exitCode})`);
2871
+ if (step.signal !== void 0 && reason.signal !== step.signal) throw new Error(`waitForExit: signal mismatch (got ${String(reason.signal ?? "")})`);
2872
+ return args.last;
2873
+ }
2874
+ if (step.type === "expectMeta") {
2875
+ await args.session.flush();
2876
+ const meta = args.session.getMeta();
2877
+ if (step.bufferType !== void 0 && meta.bufferType !== step.bufferType) throw new Error(`expectMeta.bufferType mismatch (got ${meta.bufferType})`);
2878
+ if (step.cols !== void 0 && meta.cols !== step.cols) throw new Error(`expectMeta.cols mismatch (got ${meta.cols})`);
2879
+ if (step.rows !== void 0 && meta.rows !== step.rows) throw new Error(`expectMeta.rows mismatch (got ${meta.rows})`);
2880
+ if (step.cursor) {
2881
+ const cursorViewportRow = meta.baseY + meta.cursorY - meta.viewportY;
2882
+ const actual = {
2883
+ x: meta.cursorX + 1,
2884
+ y: cursorViewportRow + 1
2885
+ };
2886
+ if (actual.x !== step.cursor.x || actual.y !== step.cursor.y) throw new Error(`expectMeta.cursor mismatch (got ${actual.x},${actual.y})`);
2887
+ }
2888
+ return args.last;
2889
+ }
2890
+ if (step.type === "snapshot") {
2891
+ const record = await snapshotStep(args.session, step);
2892
+ persistSnapshotRecord({
2893
+ record,
2894
+ saveAs: step.saveAs,
2895
+ saveTo: step.saveTo,
2896
+ snapshots: args.snapshots,
2897
+ resolveArtifactPath: args.resolveArtifactPath
2898
+ });
2899
+ return record;
2900
+ }
2901
+ if (step.type === "expect") {
2902
+ assertRecordMatches(selectSnapshot(args.last, args.snapshots, step.from), step, args.stepIndex);
2903
+ return args.last;
2904
+ }
2905
+ if (step.type === "assert") {
2906
+ const regex = step.regex ? new RegExp(step.regex) : void 0;
2907
+ if (!(await args.session.waitForText({
2908
+ scope: step.scope,
2909
+ text: step.text,
2910
+ regex,
2911
+ timeoutMs: 0,
2912
+ intervalMs: 0
2913
+ })).found) throw new Error(`step ${args.stepIndex + 1} assert failed: ${step.description || step.text || step.regex || "pattern mismatch"}`);
2914
+ return args.last;
2915
+ }
2916
+ if (step.type === "assertSemantic") return args.last;
2917
+ if (step.type === "expectGolden") {
2918
+ const record = selectSnapshot(args.last, args.snapshots, step.from);
2919
+ assertGoldenText(args.resolveGoldenPath(step.path), `${record.text}\n`, args.updateGoldens);
2920
+ return args.last;
2921
+ }
2922
+ if (step.type === "custom") {
2923
+ const handler = args.stepHandlers?.[step.name];
2924
+ if (!handler) throw new Error(`custom handler not found: ${step.name}`);
2925
+ const result = await handler({
2926
+ session: args.session,
2927
+ stepIndex: args.stepIndex,
2928
+ last: args.last,
2929
+ snapshots: args.snapshots,
2930
+ artifactsDir: args.artifactsDir,
2931
+ resolveArtifactPath: args.resolveArtifactPath,
2932
+ resolveGoldenPath: args.resolveGoldenPath,
2933
+ updateGoldens: args.updateGoldens,
2934
+ captureSnapshot: async (snapshotConfig) => {
2935
+ const record = await snapshotStep(args.session, {
2936
+ type: "snapshot",
2937
+ ...snapshotConfig
2938
+ });
2939
+ persistSnapshotRecord({
2940
+ record,
2941
+ saveAs: snapshotConfig.saveAs,
2942
+ saveTo: snapshotConfig.saveTo,
2943
+ snapshots: args.snapshots,
2944
+ resolveArtifactPath: args.resolveArtifactPath
2945
+ });
2946
+ return record;
2947
+ },
2948
+ getSnapshot: (from) => selectSnapshot(args.last, args.snapshots, from),
2949
+ writeArtifactText: (path, text) => {
2950
+ const resolved = args.resolveArtifactPath(path);
2951
+ mkdirSync(dirname(resolved), { recursive: true });
2952
+ writeFileSync(resolved, text, "utf8");
2953
+ },
2954
+ assertGoldenText: (path, text) => {
2955
+ assertGoldenText(args.resolveGoldenPath(path), text, args.updateGoldens);
2956
+ }
2957
+ }, step);
2958
+ if (!result) return args.last;
2959
+ return result;
2960
+ }
2961
+ throw new Error(`unknown type: ${step.type}`);
2962
+ } catch (error) {
2963
+ throw annotateStepError(error, args.stepIndex, step);
2964
+ }
2965
+ }
2966
+ function persistSnapshotRecord(args) {
2967
+ const saveAs = args.saveAs?.trim();
2968
+ if (saveAs) args.snapshots.set(saveAs, args.record);
2969
+ const saveTo = args.saveTo?.trim();
2970
+ if (!saveTo) return;
2971
+ const path = args.resolveArtifactPath(saveTo);
2972
+ mkdirSync(dirname(path), { recursive: true });
2973
+ writeFileSync(path, `${args.record.text}\n`, "utf8");
2974
+ }
2975
+ function annotateStepError(error, stepIndex, step) {
2976
+ const label = step.type === "custom" ? `custom(${step.name})` : step.type;
2977
+ const prefix = `step ${stepIndex + 1} ${label}`;
2978
+ if (error instanceof Error) {
2979
+ if (!error.message.startsWith("step ")) error.message = `${prefix}: ${error.message}`;
2980
+ return error;
2981
+ }
2982
+ return /* @__PURE__ */ new Error(`${prefix}: ${String(error)}`);
2983
+ }
2984
+ function selectSnapshot(last, snapshots, from) {
2985
+ const key = from?.trim() ? from.trim() : "last";
2986
+ if (key === "last") {
2987
+ if (!last) throw new Error("expect: no previous snapshot (from=last)");
2988
+ return last;
2989
+ }
2990
+ const found = snapshots.get(key);
2991
+ if (!found) throw new Error(`expect: unknown snapshot reference: ${key}`);
2992
+ return found;
2993
+ }
2994
+ function assertRecordMatches(record, step, stepIndex) {
2995
+ if (step.equals !== void 0 && record.text !== step.equals) throw new Error(`step ${stepIndex + 1} expect.equals failed`);
2996
+ if (step.contains && step.contains.length > 0) {
2997
+ for (const item of step.contains) if (!record.text.includes(item)) throw new Error(`step ${stepIndex + 1} expect.contains failed: ${JSON.stringify(item)}`);
2998
+ }
2999
+ if (step.notContains && step.notContains.length > 0) {
3000
+ for (const item of step.notContains) if (record.text.includes(item)) throw new Error(`step ${stepIndex + 1} expect.notContains failed: ${JSON.stringify(item)}`);
3001
+ }
3002
+ if (step.regex) {
3003
+ if (!new RegExp(step.regex).test(record.text)) throw new Error(`step ${stepIndex + 1} expect.regex failed: ${JSON.stringify(step.regex)}`);
3004
+ }
3005
+ }
3006
+ function assertGoldenText(path, text, update) {
3007
+ if (update) {
3008
+ mkdirSync(dirname(path), { recursive: true });
3009
+ writeFileSync(path, text, "utf8");
3010
+ return;
3011
+ }
3012
+ if (text !== readFileSync(path, "utf8")) throw new Error(`golden mismatch: ${path}`);
3013
+ }
3014
+ async function writeFailureArtifacts(args) {
3015
+ const { session, artifactsDir, scriptName, stepIndex, step, last, error } = args;
3016
+ const err = error instanceof Error ? error : new Error(String(error));
3017
+ const errorText = err.stack ?? err.message;
3018
+ writeFileSync(join(artifactsDir, "failure.error.txt"), `${errorText}\n`, "utf8");
3019
+ const stepPayload = {
3020
+ script: scriptName,
3021
+ stepIndex: stepIndex >= 0 ? stepIndex + 1 : null,
3022
+ step: step ?? null,
3023
+ last: last ? {
3024
+ kind: last.kind,
3025
+ hash: last.hash
3026
+ } : null
3027
+ };
3028
+ writeFileSync(join(artifactsDir, "failure.step.json"), `${JSON.stringify(stepPayload, null, 2)}\n`, "utf8");
3029
+ let capturedText = void 0;
3030
+ let capturedHash = void 0;
3031
+ try {
3032
+ const captured = await session.snapshotText({
3033
+ scope: "visible",
3034
+ trimRight: true,
3035
+ trimBottom: true,
3036
+ captureFrame: true
3037
+ });
3038
+ capturedText = captured.text;
3039
+ capturedHash = captured.hash;
3040
+ } catch {}
3041
+ const text = capturedText ?? last?.text;
3042
+ const hash = capturedHash ?? last?.hash ?? "unknown";
3043
+ if (text !== void 0) {
3044
+ writeFileSync(join(artifactsDir, "failure.last.txt"), `${text}\n`, "utf8");
3045
+ const view = formatSnapshotView({
3046
+ sessionId: session.id,
3047
+ scope: "visible",
3048
+ hash,
3049
+ lines: text.split("\n"),
3050
+ meta: session.getMeta(),
3051
+ lineNumbers: true
3052
+ });
3053
+ writeFileSync(join(artifactsDir, "failure.last.view.txt"), `${view}\n`, "utf8");
3054
+ }
3055
+ }
3056
+ async function snapshotStep(session, step) {
3057
+ if (step.kind === "grid") {
3058
+ if (step.mask && step.mask.length > 0) throw new Error("snapshot.kind=grid does not support mask (use text/view instead)");
3059
+ const { grid, hash } = await session.snapshotGrid({
3060
+ trimRight: step.trimRight,
3061
+ includeStyles: step.includeStyles,
3062
+ captureFrame: true
3063
+ });
3064
+ return {
3065
+ kind: step.kind,
3066
+ hash,
3067
+ text: JSON.stringify(grid, null, 2)
3068
+ };
3069
+ }
3070
+ if (step.kind === "ansi" || step.kind === "view_ansi") {
3071
+ const { ansi, hash } = await session.snapshotAnsi({
3072
+ scope: step.scope,
3073
+ trimRight: step.trimRight,
3074
+ trimBottom: step.trimBottom ?? true,
3075
+ maxLines: step.maxLines,
3076
+ tailLines: step.tailLines,
3077
+ mask: step.mask
3078
+ });
3079
+ if (step.kind === "ansi") return {
3080
+ kind: step.kind,
3081
+ hash,
3082
+ text: ansi
3083
+ };
3084
+ const lines = ansi.split("\n");
3085
+ const view = formatSnapshotView({
3086
+ sessionId: session.id,
3087
+ scope: step.scope ?? "visible",
3088
+ hash,
3089
+ lines,
3090
+ meta: session.getMeta(),
3091
+ lineNumbers: step.lineNumbers
3092
+ });
3093
+ return {
3094
+ kind: step.kind,
3095
+ hash,
3096
+ text: view
3097
+ };
3098
+ }
3099
+ const { text, hash } = await session.snapshotText({
3100
+ scope: step.scope,
3101
+ trimRight: step.trimRight,
3102
+ trimBottom: step.trimBottom ?? true,
3103
+ maxLines: step.maxLines,
3104
+ tailLines: step.tailLines,
3105
+ captureFrame: true,
3106
+ mask: step.mask
3107
+ });
3108
+ if (step.kind === "text") return {
3109
+ kind: step.kind,
3110
+ hash,
3111
+ text
3112
+ };
3113
+ const lines = text.split("\n");
3114
+ const view = formatSnapshotView({
3115
+ sessionId: session.id,
3116
+ scope: step.scope ?? "visible",
3117
+ hash,
3118
+ lines,
3119
+ meta: session.getMeta(),
3120
+ lineNumbers: step.lineNumbers
3121
+ });
3122
+ return {
3123
+ kind: step.kind,
3124
+ hash,
3125
+ text: view
3126
+ };
3127
+ }
3128
+ async function writeTraceArtifacts(args) {
3129
+ if (!args.saveCast && !args.saveReport) return;
3130
+ const snapshot = await args.session.snapshotCast();
3131
+ if (args.saveCast) {
3132
+ mkdirSync(dirname(args.castPath), { recursive: true });
3133
+ writeFileSync(args.castPath, snapshot.cast, "utf8");
3134
+ }
3135
+ if (args.saveReport) {
3136
+ const artifactHrefs = buildReportArtifactHrefs({
3137
+ reportPath: args.reportPath,
3138
+ castPath: args.saveCast ? args.castPath : null,
3139
+ artifactsDir: args.artifactsDir,
3140
+ includeFailures: args.result?.ok === false
3141
+ });
3142
+ const html = await generateTraceReportHtml(snapshot.cast, {
3143
+ scope: args.reportScope,
3144
+ maxFrames: args.reportMaxFrames,
3145
+ scriptName: args.scriptName,
3146
+ result: args.result,
3147
+ artifacts: artifactHrefs,
3148
+ steps: args.executionSteps
3149
+ });
3150
+ mkdirSync(dirname(args.reportPath), { recursive: true });
3151
+ writeFileSync(args.reportPath, html, "utf8");
3152
+ ensureAsciinemaPlayerAssets(args.reportPath);
3153
+ }
3154
+ }
3155
+ function buildReportArtifactHrefs(args) {
3156
+ const items = {};
3157
+ if (args.castPath) items.castHref = relativeHref(args.reportPath, args.castPath);
3158
+ if (args.includeFailures) {
3159
+ items.failureErrorHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.error.txt"));
3160
+ items.failureStepHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.step.json"));
3161
+ items.failureLastTextHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.last.txt"));
3162
+ items.failureLastViewHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.last.view.txt"));
3163
+ }
3164
+ return Object.keys(items).length ? items : void 0;
3165
+ }
3166
+ function relativeHref(fromFile, toFile) {
3167
+ const normalized = relative(dirname(fromFile), toFile).replace(/\\/g, "/");
3168
+ return normalized.startsWith(".") ? normalized : `./${normalized}`;
3169
+ }
3170
+ function formatStepLabel(step) {
3171
+ return step.type === "custom" ? `custom(${step.name})` : step.type;
3172
+ }
3173
+ function formatPublicStepLabel(step) {
3174
+ const showText = envTruthy(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
3175
+ if (step.type === "custom") return `custom(${step.name})`;
3176
+ if (step.type === "sendText") {
3177
+ const enter = step.enter !== void 0 ? ` enter=${String(step.enter)}` : "";
3178
+ if (!showText) return `sendText <redacted> (len=${step.text.length}${enter})`;
3179
+ return `sendText "${truncateInline(step.text)}"${enter ? ` (${enter.trim()})` : ""}`;
3180
+ }
3181
+ if (step.type === "pressKey") return `pressKey ${step.key}`;
3182
+ if (step.type === "sendMouse") return `sendMouse ${step.action} (${step.x},${step.y})`;
3183
+ if (step.type === "resize") return `resize ${step.cols}x${step.rows}`;
3184
+ if (step.type === "mark") return step.label ? `mark ${step.label}` : "mark";
3185
+ if (step.type === "sleep") return `sleep ${step.ms}ms`;
3186
+ if (step.type === "waitForText") {
3187
+ if (!showText) return step.text ? "waitForText (text)" : step.regex ? "waitForText (regex)" : "waitForText";
3188
+ if (step.text) return `waitFor "${truncateInline(step.text)}"`;
3189
+ if (step.regex) return `waitFor /${truncateInline(step.regex)}/`;
3190
+ return "waitForText";
3191
+ }
3192
+ if (step.type === "waitForStableScreen") return "waitForStableScreen";
3193
+ if (step.type === "waitForExit") return "waitForExit";
3194
+ if (step.type === "expectMeta") return "expectMeta";
3195
+ if (step.type === "snapshot") return `snapshot ${step.kind}${step.saveAs ? ` as ${step.saveAs}` : ""}`;
3196
+ if (step.type === "expect") {
3197
+ const parts = [];
3198
+ if (step.equals !== void 0) parts.push("equals");
3199
+ if (step.contains?.length) parts.push(`contains(${step.contains.length})`);
3200
+ if (step.notContains?.length) parts.push(`notContains(${step.notContains.length})`);
3201
+ if (step.regex) parts.push("regex");
3202
+ return parts.length ? `expect ${parts.join(",")}` : "expect";
3203
+ }
3204
+ if (step.type === "expectGolden") return `expectGolden ${step.path}`;
3205
+ if (step.type === "assert") {
3206
+ if (!showText) return step.text ? "assert (text)" : step.regex ? "assert (regex)" : "assert";
3207
+ if (step.text) return `assert "${truncateInline(step.text)}"`;
3208
+ if (step.regex) return `assert /${truncateInline(step.regex)}/`;
3209
+ if (step.description) return `assert "${truncateInline(step.description)}"`;
3210
+ return "assert";
3211
+ }
3212
+ if (step.type === "assertSemantic") return "assertSemantic";
3213
+ return assertUnreachableStep(step);
3214
+ }
3215
+ function truncateInline(text, maxChars = 60) {
3216
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
3217
+ if (normalized.length <= maxChars) return normalized;
3218
+ return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
3219
+ }
3220
+ function assertUnreachableStep(_step) {
3221
+ return "unknown";
3222
+ }
3223
+ function writeTestDataArtifact(args) {
3224
+ try {
3225
+ const testId = basename(args.artifactsDir);
3226
+ const outPath = args.resolveArtifactPath("test.data.js");
3227
+ mkdirSync(dirname(outPath), { recursive: true });
3228
+ const steps = args.executionSteps.map((s) => ({
3229
+ index: s.index + 1,
3230
+ type: s.step.type,
3231
+ label: formatPublicStepLabel(s.step),
3232
+ ok: s.ok,
3233
+ durationMs: s.durationMs,
3234
+ error: s.ok ? null : s.error ?? null
3235
+ }));
3236
+ const data = {
3237
+ version: 1,
3238
+ testId,
3239
+ scriptName: args.scriptName,
3240
+ ok: args.ok,
3241
+ error: args.ok ? null : args.error ?? null,
3242
+ stepCount: steps.length,
3243
+ steps,
3244
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
3245
+ };
3246
+ const json = JSON.stringify(data).replaceAll("<", "\\u003c");
3247
+ writeFileSync(outPath, `globalThis.__ptywright = globalThis.__ptywright || {};
3248
+ globalThis.__ptywright.tests = globalThis.__ptywright.tests || {};
3249
+ globalThis.__ptywright.tests[${JSON.stringify(testId)}] = ${json};\n`, "utf8");
3250
+ } catch {}
3251
+ }
3252
+ function envTruthy(value) {
3253
+ const v = value?.trim().toLowerCase();
3254
+ return v === "1" || v === "true" || v === "yes" || v === "on";
3255
+ }
3256
+ //#endregion
3257
+ export { ensureAsciinemaPlayerAssets as a, createDefaultPtyAdapter as c, generateTraceReportHtml as i, resolvePtyBackend as l, runScriptFile as n, formatSnapshotView as o, scriptSchema as r, SessionManager as s, runScript as t };