ptywright 0.1.1 → 0.3.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.
- package/README.md +318 -1
- package/dist/agent.mjs +2 -0
- package/dist/bin/ptywright.mjs +6 -0
- package/dist/cli-CfvlbRoZ.mjs +3585 -0
- package/dist/cli.mjs +2 -0
- package/{src/index.ts → dist/index.mjs} +7 -9
- package/dist/mcp.mjs +2 -0
- package/dist/pty-cassette.mjs +24 -0
- package/dist/pty_like-Cpkh_O9B.mjs +404 -0
- package/dist/runner-zApMYWZx.mjs +3257 -0
- package/dist/runner-zi0nItvB.mjs +1874 -0
- package/dist/script.mjs +2 -0
- package/dist/server-BC3yo-dq.mjs +3068 -0
- package/dist/session.mjs +2 -0
- package/dist/terminal_session-DopC7Xg6.mjs +893 -0
- package/package.json +28 -21
- package/schemas/ptywright-agent-cassette.schema.json +57 -0
- package/schemas/ptywright-agent-check.schema.json +122 -0
- package/schemas/ptywright-agent-manifest.schema.json +107 -0
- package/schemas/ptywright-agent-promote.schema.json +146 -0
- package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
- package/schemas/ptywright-agent-run.schema.json +126 -0
- package/schemas/ptywright-agent.schema.json +166 -0
- package/schemas/ptywright-pty-cassette.schema.json +86 -0
- package/schemas/ptywright-script-manifest.schema.json +75 -0
- package/schemas/ptywright-script-run-summary.schema.json +114 -0
- package/schemas/ptywright-script.schema.json +55 -3
- package/bin/ptywright +0 -4
- package/src/cli.ts +0 -414
- package/src/generator/doc_parser.ts +0 -341
- package/src/generator/generate.ts +0 -161
- package/src/generator/index.ts +0 -10
- package/src/generator/script_generator.ts +0 -209
- package/src/generator/step_extractor.ts +0 -397
- package/src/mcp/http_server.ts +0 -174
- package/src/mcp/script_recording.ts +0 -238
- package/src/mcp/server.ts +0 -1348
- package/src/pty/bun_pty_adapter.ts +0 -34
- package/src/pty/bun_terminal_adapter.ts +0 -149
- package/src/pty/pty_adapter.ts +0 -31
- package/src/script/dsl.ts +0 -188
- package/src/script/module.ts +0 -43
- package/src/script/path.ts +0 -151
- package/src/script/run.ts +0 -108
- package/src/script/run_all.ts +0 -229
- package/src/script/runner.ts +0 -983
- package/src/script/schema.ts +0 -237
- package/src/script/steps/assert_snapshot_equals.ts +0 -21
- package/src/script/steps/index.ts +0 -2
- package/src/script/suite_report.ts +0 -626
- package/src/session/session_manager.ts +0 -145
- package/src/session/terminal_session.ts +0 -473
- package/src/terminal/ansi.ts +0 -142
- package/src/terminal/keys.ts +0 -180
- package/src/terminal/mask.ts +0 -70
- package/src/terminal/mouse.ts +0 -75
- package/src/terminal/snapshot.ts +0 -196
- package/src/terminal/style.ts +0 -121
- package/src/terminal/view.ts +0 -49
- package/src/trace/asciicast.ts +0 -20
- package/src/trace/asciinema_player_assets.ts +0 -44
- package/src/trace/cast_to_txt.ts +0 -116
- package/src/trace/recorder.ts +0 -110
- package/src/trace/report.ts +0 -2092
- package/src/types.ts +0 -86
- package/src/util/hash.ts +0 -8
- 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
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 };
|