ptywright 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +459 -116
- package/dist/agent.mjs +2 -0
- package/dist/bin/ptywright.mjs +6 -0
- package/dist/cli-DIUx2w6X.mjs +3587 -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-DzZlFrt1.mjs +1897 -0
- package/dist/runner-zApMYWZx.mjs +3257 -0
- package/dist/script.mjs +2 -0
- package/dist/server-VHuEWWj_.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 +182 -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/skills/ptywright-testing/SKILL.md +53 -33
- 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
package/src/trace/report.ts
DELETED
|
@@ -1,2092 +0,0 @@
|
|
|
1
|
-
import { Terminal } from "@xterm/headless";
|
|
2
|
-
|
|
3
|
-
import { writeFileSync } from "node:fs";
|
|
4
|
-
import { basename, dirname, extname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import type { Color, CellStyle } from "../terminal/style";
|
|
7
|
-
import { extractStyle, findMeaningfulEndCol, isDefaultStyle, styleKey } from "../terminal/style";
|
|
8
|
-
import { snapshotGrid, snapshotLines } from "../terminal/snapshot";
|
|
9
|
-
import type { SnapshotScope } from "../terminal/snapshot";
|
|
10
|
-
import type { TerminalMeta } from "../terminal/view";
|
|
11
|
-
import { fnv1a32 } from "../util/hash";
|
|
12
|
-
|
|
13
|
-
import type { AsciicastEvent } from "./asciicast";
|
|
14
|
-
import { ensureAsciinemaPlayerAssets } from "./asciinema_player_assets";
|
|
15
|
-
|
|
16
|
-
type ParsedAsciicast = {
|
|
17
|
-
header: Record<string, unknown>;
|
|
18
|
-
events: AsciicastEvent[];
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type TraceReportResult = {
|
|
22
|
-
ok: boolean;
|
|
23
|
-
error?: string;
|
|
24
|
-
failureStep?: {
|
|
25
|
-
index: number;
|
|
26
|
-
type: string;
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export type TraceReportArtifacts = {
|
|
31
|
-
castHref?: string;
|
|
32
|
-
failureErrorHref?: string;
|
|
33
|
-
failureStepHref?: string;
|
|
34
|
-
failureLastTextHref?: string;
|
|
35
|
-
failureLastViewHref?: string;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
type ReportFrame = {
|
|
39
|
-
id: string;
|
|
40
|
-
atSeconds: number;
|
|
41
|
-
kind: "mark" | "resize" | "final" | "step";
|
|
42
|
-
markLabel?: string;
|
|
43
|
-
label: string;
|
|
44
|
-
viewHtml: string;
|
|
45
|
-
changedCount: number;
|
|
46
|
-
stepInfo?: {
|
|
47
|
-
index: number;
|
|
48
|
-
type: string;
|
|
49
|
-
ok: boolean;
|
|
50
|
-
error?: string;
|
|
51
|
-
params?: Record<string, unknown>;
|
|
52
|
-
durationMs?: number;
|
|
53
|
-
};
|
|
54
|
-
previousViewHtml?: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export async function generateTraceReportHtml(
|
|
58
|
-
cast: string,
|
|
59
|
-
options?: {
|
|
60
|
-
scope?: SnapshotScope;
|
|
61
|
-
maxFrames?: number;
|
|
62
|
-
scriptName?: string;
|
|
63
|
-
result?: TraceReportResult;
|
|
64
|
-
artifacts?: TraceReportArtifacts;
|
|
65
|
-
steps?: unknown[]; // Should be ScriptStep execution records
|
|
66
|
-
},
|
|
67
|
-
): Promise<string> {
|
|
68
|
-
const parsed = parseAsciicast(cast);
|
|
69
|
-
const termInfo = getTermInfo(parsed.header);
|
|
70
|
-
|
|
71
|
-
const terminal = new Terminal({
|
|
72
|
-
cols: termInfo.cols,
|
|
73
|
-
rows: termInfo.rows,
|
|
74
|
-
allowProposedApi: true,
|
|
75
|
-
scrollback: 2000,
|
|
76
|
-
convertEol: true,
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const scope = options?.scope ?? "visible";
|
|
80
|
-
const maxFrames = options?.maxFrames ?? 200;
|
|
81
|
-
const scriptName = options?.scriptName?.trim() ? options.scriptName.trim() : "";
|
|
82
|
-
const result = options?.result;
|
|
83
|
-
const artifacts = options?.artifacts;
|
|
84
|
-
const steps = options?.steps as
|
|
85
|
-
| Array<{
|
|
86
|
-
index: number;
|
|
87
|
-
step: { type: string; [key: string]: unknown };
|
|
88
|
-
ok: boolean;
|
|
89
|
-
error?: string;
|
|
90
|
-
durationMs?: number;
|
|
91
|
-
after?: { text: string; hash: string; kind: string };
|
|
92
|
-
}>
|
|
93
|
-
| undefined;
|
|
94
|
-
|
|
95
|
-
let writeChain: Promise<void> = Promise.resolve();
|
|
96
|
-
|
|
97
|
-
const frames: ReportFrame[] = [];
|
|
98
|
-
let previousRowSignatures: string[] | null = null;
|
|
99
|
-
|
|
100
|
-
const capture = (args: {
|
|
101
|
-
atSeconds: number;
|
|
102
|
-
kind: ReportFrame["kind"];
|
|
103
|
-
label: string;
|
|
104
|
-
markLabel?: string;
|
|
105
|
-
stepInfo?: ReportFrame["stepInfo"];
|
|
106
|
-
overrideViewText?: { text: string; hash?: string };
|
|
107
|
-
}): void => {
|
|
108
|
-
if (frames.length >= maxFrames) return;
|
|
109
|
-
|
|
110
|
-
let viewHtml: string;
|
|
111
|
-
let changedCount: number;
|
|
112
|
-
|
|
113
|
-
if (args.overrideViewText) {
|
|
114
|
-
const parsedView = parseSnapshotViewText(args.overrideViewText.text);
|
|
115
|
-
const headerLine =
|
|
116
|
-
parsedView.headerLine ??
|
|
117
|
-
(args.overrideViewText.hash?.trim()
|
|
118
|
-
? `hash=${args.overrideViewText.hash.trim()}`
|
|
119
|
-
: "snapshot");
|
|
120
|
-
|
|
121
|
-
const rowSignatures = parsedView.rows.map((r) => r.text);
|
|
122
|
-
const changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
|
|
123
|
-
previousRowSignatures = rowSignatures;
|
|
124
|
-
changedCount = changedLines.size;
|
|
125
|
-
|
|
126
|
-
viewHtml = renderSnapshotViewTextHtml({
|
|
127
|
-
headerLine,
|
|
128
|
-
rows: parsedView.rows,
|
|
129
|
-
changedLines,
|
|
130
|
-
});
|
|
131
|
-
} else {
|
|
132
|
-
let lines: string[];
|
|
133
|
-
let hash: string;
|
|
134
|
-
let changedLines = new Set<number>();
|
|
135
|
-
|
|
136
|
-
if (scope === "visible") {
|
|
137
|
-
const grid = snapshotGrid(terminal, { trimRight: true, includeStyles: true });
|
|
138
|
-
lines = grid.lines;
|
|
139
|
-
hash = fnv1a32(JSON.stringify(grid));
|
|
140
|
-
|
|
141
|
-
const rowSignatures = lines.map((line, idx) => {
|
|
142
|
-
const runs = grid.styleRuns?.[idx] ?? [];
|
|
143
|
-
if (line === "" && runs.length === 0) return "";
|
|
144
|
-
return `${line}\n${JSON.stringify(runs)}`;
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
|
|
148
|
-
previousRowSignatures = rowSignatures;
|
|
149
|
-
} else {
|
|
150
|
-
lines = snapshotLines(terminal, { scope, trimRight: true });
|
|
151
|
-
hash = fnv1a32(lines.join("\n"));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
changedCount = changedLines.size;
|
|
155
|
-
viewHtml = renderSnapshotViewHtml({
|
|
156
|
-
terminal,
|
|
157
|
-
sessionId: "replay",
|
|
158
|
-
scope,
|
|
159
|
-
hash,
|
|
160
|
-
lines,
|
|
161
|
-
meta: getMeta(terminal),
|
|
162
|
-
lineNumbers: true,
|
|
163
|
-
changedLines,
|
|
164
|
-
trimRight: true,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const previousFrame = frames.at(-1);
|
|
169
|
-
frames.push({
|
|
170
|
-
id: `frame-${frames.length + 1}`,
|
|
171
|
-
atSeconds: args.atSeconds,
|
|
172
|
-
kind: args.kind,
|
|
173
|
-
label: args.label,
|
|
174
|
-
markLabel: args.markLabel,
|
|
175
|
-
viewHtml,
|
|
176
|
-
changedCount,
|
|
177
|
-
stepInfo: args.stepInfo,
|
|
178
|
-
previousViewHtml: previousFrame?.viewHtml,
|
|
179
|
-
});
|
|
180
|
-
};
|
|
181
|
-
// Build frames. Prefer step-based snapshots when available (runner-provided).
|
|
182
|
-
if (steps && steps.length > 0) {
|
|
183
|
-
for (let i = 0; i < steps.length; i += 1) {
|
|
184
|
-
const stepRec = steps[i];
|
|
185
|
-
if (!stepRec) continue;
|
|
186
|
-
|
|
187
|
-
const stepLabel = formatStepLabel(stepRec.step);
|
|
188
|
-
const viewText = stepRec.after?.text ?? "";
|
|
189
|
-
const displayIndex = (typeof stepRec.index === "number" ? stepRec.index : i) + 1;
|
|
190
|
-
|
|
191
|
-
const stepType = stepRec.step.type;
|
|
192
|
-
const kind: ReportFrame["kind"] =
|
|
193
|
-
stepType === "mark" ? "mark" : stepType === "resize" ? "resize" : "step";
|
|
194
|
-
const markLabel =
|
|
195
|
-
kind === "mark" && typeof (stepRec.step as { label?: unknown }).label === "string"
|
|
196
|
-
? String((stepRec.step as { label?: unknown }).label)
|
|
197
|
-
: undefined;
|
|
198
|
-
|
|
199
|
-
// Extract step params for Call tab
|
|
200
|
-
const stepParams: Record<string, unknown> = {};
|
|
201
|
-
for (const [key, value] of Object.entries(stepRec.step)) {
|
|
202
|
-
if (key !== "type") {
|
|
203
|
-
stepParams[key] = value;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
capture({
|
|
208
|
-
atSeconds: displayIndex,
|
|
209
|
-
kind,
|
|
210
|
-
label: stepLabel,
|
|
211
|
-
markLabel,
|
|
212
|
-
stepInfo: {
|
|
213
|
-
index: displayIndex,
|
|
214
|
-
type: stepType,
|
|
215
|
-
ok: stepRec.ok,
|
|
216
|
-
error: stepRec.error,
|
|
217
|
-
params: Object.keys(stepParams).length > 0 ? stepParams : undefined,
|
|
218
|
-
durationMs: typeof stepRec.durationMs === "number" ? stepRec.durationMs : undefined,
|
|
219
|
-
},
|
|
220
|
-
overrideViewText: { text: viewText, hash: stepRec.after?.hash },
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
if (frames.length >= maxFrames) break;
|
|
224
|
-
}
|
|
225
|
-
} else {
|
|
226
|
-
for (const event of parsed.events) {
|
|
227
|
-
const [time, type, data] = event;
|
|
228
|
-
if (type === "o") {
|
|
229
|
-
writeChain = writeChain.then(() => writeTerminal(terminal, data));
|
|
230
|
-
} else if (type === "r") {
|
|
231
|
-
void writeChain.then(() => {
|
|
232
|
-
const resized = parseResize(data);
|
|
233
|
-
if (resized) {
|
|
234
|
-
terminal.resize(resized.cols, resized.rows);
|
|
235
|
-
}
|
|
236
|
-
capture({ atSeconds: time, kind: "resize", label: `resize ${data}` });
|
|
237
|
-
});
|
|
238
|
-
} else if (type === "m") {
|
|
239
|
-
void writeChain.then(() => {
|
|
240
|
-
const markLabel = (data ?? "").trim();
|
|
241
|
-
const label = markLabel ? `mark ${markLabel}` : "mark";
|
|
242
|
-
capture({ atSeconds: time, kind: "mark", label, markLabel });
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
await writeChain;
|
|
248
|
-
capture({
|
|
249
|
-
atSeconds: parsed.events.at(-1)?.[0] ?? 0,
|
|
250
|
-
kind: "final",
|
|
251
|
-
label: "final",
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
terminal.dispose();
|
|
256
|
-
|
|
257
|
-
return renderHtml({
|
|
258
|
-
cast,
|
|
259
|
-
header: parsed.header,
|
|
260
|
-
term: termInfo,
|
|
261
|
-
scope,
|
|
262
|
-
scriptName,
|
|
263
|
-
result,
|
|
264
|
-
artifacts,
|
|
265
|
-
frames,
|
|
266
|
-
eventCount: parsed.events.length,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function formatStepLabel(step: { type: string; [key: string]: unknown }): string {
|
|
271
|
-
const showText = envTruthy(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
|
|
272
|
-
|
|
273
|
-
if (step.type === "custom" && typeof step.name === "string") return `custom(${step.name})`;
|
|
274
|
-
|
|
275
|
-
if (step.type === "sendText") {
|
|
276
|
-
const enter = typeof step.enter === "boolean" ? step.enter : undefined;
|
|
277
|
-
const enterSuffix = enter !== undefined ? `enter=${enter}` : "";
|
|
278
|
-
const description = typeof step.description === "string" ? step.description : "";
|
|
279
|
-
const text = typeof step.text === "string" ? step.text : description;
|
|
280
|
-
|
|
281
|
-
if (!text) {
|
|
282
|
-
return enterSuffix ? `sendText (${enterSuffix})` : "sendText";
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (!showText) {
|
|
286
|
-
return `sendText <redacted> (len=${text.length}${enterSuffix ? `, ${enterSuffix}` : ""})`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return `sendText "${truncateInline(text)}"${enterSuffix ? ` (${enterSuffix})` : ""}`;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (step.type === "waitForText") {
|
|
293
|
-
const text = typeof step.text === "string" ? step.text : undefined;
|
|
294
|
-
const regex = typeof step.regex === "string" ? step.regex : undefined;
|
|
295
|
-
const description = typeof step.description === "string" ? step.description : undefined;
|
|
296
|
-
|
|
297
|
-
if (!showText) {
|
|
298
|
-
if (text) return "waitForText (text)";
|
|
299
|
-
if (regex) return "waitForText (regex)";
|
|
300
|
-
return "waitForText";
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (text) return `waitFor "${truncateInline(text)}"`;
|
|
304
|
-
if (regex) return `waitFor /${truncateInline(regex)}/`;
|
|
305
|
-
if (description) return `waitForText "${truncateInline(description)}"`;
|
|
306
|
-
return "waitForText";
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (step.type === "assert") {
|
|
310
|
-
const text = typeof step.text === "string" ? step.text : undefined;
|
|
311
|
-
const regex = typeof step.regex === "string" ? step.regex : undefined;
|
|
312
|
-
const description = typeof step.description === "string" ? step.description : undefined;
|
|
313
|
-
|
|
314
|
-
if (!showText) {
|
|
315
|
-
if (text) return "assert (text)";
|
|
316
|
-
if (regex) return "assert (regex)";
|
|
317
|
-
return "assert";
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (text) return `assert "${truncateInline(text)}"`;
|
|
321
|
-
if (regex) return `assert /${truncateInline(regex)}/`;
|
|
322
|
-
if (description) return `assert "${truncateInline(description)}"`;
|
|
323
|
-
return "assert";
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (step.type === "pressKey" && typeof step.key === "string") return `pressKey ${step.key}`;
|
|
327
|
-
|
|
328
|
-
if (step.type === "mark") {
|
|
329
|
-
const label = typeof step.label === "string" ? step.label.trim() : "";
|
|
330
|
-
return label ? `mark ${label}` : "mark";
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (step.type === "resize") {
|
|
334
|
-
const cols = typeof step.cols === "number" ? step.cols : undefined;
|
|
335
|
-
const rows = typeof step.rows === "number" ? step.rows : undefined;
|
|
336
|
-
if (cols !== undefined && rows !== undefined) return `resize ${cols}x${rows}`;
|
|
337
|
-
return "resize";
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return step.type;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function truncateInline(text: string, maxChars: number = 60): string {
|
|
344
|
-
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
|
|
345
|
-
if (normalized.length <= maxChars) return normalized;
|
|
346
|
-
return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function envTruthy(value: string | undefined): boolean {
|
|
350
|
-
if (!value?.trim()) return false;
|
|
351
|
-
const normalized = value.trim().toLowerCase();
|
|
352
|
-
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
type ParsedSnapshotViewText = {
|
|
356
|
-
headerLine: string | null;
|
|
357
|
-
rows: Array<{ prefix?: string; text: string }>;
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
function stripAnsi(str: string): string {
|
|
361
|
-
// eslint-disable-next-line no-control-regex
|
|
362
|
-
return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function parseSnapshotViewText(viewText: string): ParsedSnapshotViewText {
|
|
366
|
-
const normalized = stripAnsi(viewText).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
367
|
-
const lines = normalized.split("\n");
|
|
368
|
-
const first = lines[0] ?? "";
|
|
369
|
-
|
|
370
|
-
const hasHeader = /\bsession=/.test(first) && /\bhash=/.test(first);
|
|
371
|
-
const headerLine = hasHeader ? first : null;
|
|
372
|
-
const rowLines = hasHeader ? lines.slice(1) : lines;
|
|
373
|
-
|
|
374
|
-
const rows = rowLines.map((line) => {
|
|
375
|
-
const match = line.match(/^(\d+│\s)(.*)$/);
|
|
376
|
-
if (!match) return { text: line };
|
|
377
|
-
return { prefix: match[1], text: match[2] ?? "" };
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
return { headerLine, rows };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function renderSnapshotViewTextHtml(options: {
|
|
384
|
-
headerLine: string;
|
|
385
|
-
rows: Array<{ prefix?: string; text: string }>;
|
|
386
|
-
changedLines: Set<number>;
|
|
387
|
-
}): string {
|
|
388
|
-
const digits = Math.max(2, String(options.rows.length).length);
|
|
389
|
-
const out: string[] = [`<span class="headerblock">${escapeHtml(options.headerLine)}</span>`];
|
|
390
|
-
|
|
391
|
-
for (let i = 0; i < options.rows.length; i += 1) {
|
|
392
|
-
const row = options.rows[i];
|
|
393
|
-
const prefix = row?.prefix ?? `${String(i + 1).padStart(digits, "0")}│ `;
|
|
394
|
-
const prefixHtml = `<span class="ln">${escapeHtml(prefix)}</span>`;
|
|
395
|
-
const rowClass = options.changedLines.has(i) ? "row changed" : "row";
|
|
396
|
-
out.push(`<span class="${rowClass}">${prefixHtml}${escapeHtml(row?.text ?? "")}</span>`);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
return out.join("");
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function writeTerminal(terminal: Terminal, data: string): Promise<void> {
|
|
403
|
-
await new Promise<void>((resolve) => {
|
|
404
|
-
terminal.write(data, resolve);
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function renderHtml(input: {
|
|
409
|
-
cast: string;
|
|
410
|
-
header: Record<string, unknown>;
|
|
411
|
-
term: { cols: number; rows: number; type: string };
|
|
412
|
-
scope: SnapshotScope;
|
|
413
|
-
scriptName: string;
|
|
414
|
-
result?: TraceReportResult;
|
|
415
|
-
artifacts?: TraceReportArtifacts;
|
|
416
|
-
frames: ReportFrame[];
|
|
417
|
-
eventCount: number;
|
|
418
|
-
}): string {
|
|
419
|
-
const title =
|
|
420
|
-
input.scriptName || coerceDisplayString(input.header.title) || "ptywright trace report";
|
|
421
|
-
const command = coerceDisplayString(input.header.command);
|
|
422
|
-
const timestamp = input.header.timestamp;
|
|
423
|
-
|
|
424
|
-
const headerJson = JSON.stringify(input.header, null, 2);
|
|
425
|
-
|
|
426
|
-
const durationSeconds = input.frames.at(-1)?.atSeconds ?? 0;
|
|
427
|
-
const markFrames = input.frames.filter((f) => f.kind === "mark");
|
|
428
|
-
|
|
429
|
-
const resultLabel =
|
|
430
|
-
input.result?.ok === true ? "PASS" : input.result?.ok === false ? "FAIL" : "UNKNOWN";
|
|
431
|
-
const resultClass =
|
|
432
|
-
input.result?.ok === true ? "pass" : input.result?.ok === false ? "fail" : "unknown";
|
|
433
|
-
|
|
434
|
-
const artifactsRows: { label: string; href: string }[] = [];
|
|
435
|
-
if (input.artifacts?.castHref?.trim()) {
|
|
436
|
-
artifactsRows.push({ label: "cast", href: input.artifacts.castHref.trim() });
|
|
437
|
-
}
|
|
438
|
-
if (input.artifacts?.failureErrorHref?.trim()) {
|
|
439
|
-
artifactsRows.push({
|
|
440
|
-
label: "failure.error.txt",
|
|
441
|
-
href: input.artifacts.failureErrorHref.trim(),
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
if (input.artifacts?.failureStepHref?.trim()) {
|
|
445
|
-
artifactsRows.push({
|
|
446
|
-
label: "failure.step.json",
|
|
447
|
-
href: input.artifacts.failureStepHref.trim(),
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
if (input.artifacts?.failureLastTextHref?.trim()) {
|
|
451
|
-
artifactsRows.push({
|
|
452
|
-
label: "failure.last.txt",
|
|
453
|
-
href: input.artifacts.failureLastTextHref.trim(),
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
if (input.artifacts?.failureLastViewHref?.trim()) {
|
|
457
|
-
artifactsRows.push({
|
|
458
|
-
label: "failure.last.view.txt",
|
|
459
|
-
href: input.artifacts.failureLastViewHref.trim(),
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const artifactsHtml =
|
|
464
|
-
artifactsRows.length === 0
|
|
465
|
-
? `<p class="muted">No artifacts linked.</p>`
|
|
466
|
-
: `<ul class="artifacts">
|
|
467
|
-
${artifactsRows
|
|
468
|
-
.map(
|
|
469
|
-
(a) =>
|
|
470
|
-
`<li><a href="${escapeHtml(a.href)}">${escapeHtml(a.label)}</a><span class="muted"> (${escapeHtml(a.href)})</span></li>`,
|
|
471
|
-
)
|
|
472
|
-
.join("\n")}
|
|
473
|
-
</ul>`;
|
|
474
|
-
|
|
475
|
-
const castPlayerHtml = `
|
|
476
|
-
<p class="muted">Render the full recording using <span class="mono">asciinema-player</span>.</p>
|
|
477
|
-
<div class="cast-controls">
|
|
478
|
-
<button id="castToggleSize" class="badge chip" type="button">expand</button>
|
|
479
|
-
<span id="castPlayerStatus" class="muted mono"></span>
|
|
480
|
-
</div>
|
|
481
|
-
<div id="castPlayer" class="cast-player"></div>
|
|
482
|
-
<script id="castData" type="application/json">${jsonForHtml(input.cast)}</script>
|
|
483
|
-
<script>
|
|
484
|
-
(function () {
|
|
485
|
-
const statusEl = document.getElementById("castPlayerStatus");
|
|
486
|
-
const container = document.getElementById("castPlayer");
|
|
487
|
-
const castEl = document.getElementById("castData");
|
|
488
|
-
const toggleBtn = document.getElementById("castToggleSize");
|
|
489
|
-
if (!container || !castEl) return;
|
|
490
|
-
|
|
491
|
-
// Load external assets automatically (no extra click).
|
|
492
|
-
const VERSION = "3.9.0";
|
|
493
|
-
const LOCAL_CSS = "./asciinema-player.css";
|
|
494
|
-
const LOCAL_JS = "./asciinema-player.min.js";
|
|
495
|
-
// Use multiple CDNs to avoid regional blocks (e.g. jsdelivr).
|
|
496
|
-
const CDN_BASES = [
|
|
497
|
-
"https://cdn.jsdelivr.net/npm/asciinema-player@" + VERSION + "/dist/bundle/",
|
|
498
|
-
"https://unpkg.com/asciinema-player@" + VERSION + "/dist/bundle/",
|
|
499
|
-
];
|
|
500
|
-
const CSS_URLS = [LOCAL_CSS, ...CDN_BASES.map((b) => b + "asciinema-player.css")];
|
|
501
|
-
const JS_URLS = [LOCAL_JS, ...CDN_BASES.map((b) => b + "asciinema-player.min.js")];
|
|
502
|
-
|
|
503
|
-
function setStatus(text) {
|
|
504
|
-
if (statusEl) statusEl.textContent = text ? " " + text : "";
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
async function loadCssOnce() {
|
|
508
|
-
if (document.getElementById("asciinemaPlayerCss")) return;
|
|
509
|
-
|
|
510
|
-
for (const href of CSS_URLS) {
|
|
511
|
-
try {
|
|
512
|
-
await new Promise((resolve, reject) => {
|
|
513
|
-
const link = document.createElement("link");
|
|
514
|
-
link.id = "asciinemaPlayerCss";
|
|
515
|
-
link.rel = "stylesheet";
|
|
516
|
-
link.href = href;
|
|
517
|
-
link.onload = resolve;
|
|
518
|
-
link.onerror = reject;
|
|
519
|
-
document.head.appendChild(link);
|
|
520
|
-
});
|
|
521
|
-
return;
|
|
522
|
-
} catch {
|
|
523
|
-
const el = document.getElementById("asciinemaPlayerCss");
|
|
524
|
-
if (el) el.remove();
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function loadScriptOnce() {
|
|
530
|
-
return new Promise((resolve, reject) => {
|
|
531
|
-
if (window.AsciinemaPlayer) return resolve(window.AsciinemaPlayer);
|
|
532
|
-
const existing = document.getElementById("asciinemaPlayerJs");
|
|
533
|
-
if (existing) {
|
|
534
|
-
// If another instance is loading, poll until available.
|
|
535
|
-
const startedAt = Date.now();
|
|
536
|
-
const poll = setInterval(() => {
|
|
537
|
-
if (window.AsciinemaPlayer) {
|
|
538
|
-
clearInterval(poll);
|
|
539
|
-
resolve(window.AsciinemaPlayer);
|
|
540
|
-
} else if (Date.now() - startedAt > 15000) {
|
|
541
|
-
clearInterval(poll);
|
|
542
|
-
reject(new Error("timeout loading asciinema-player"));
|
|
543
|
-
}
|
|
544
|
-
}, 100);
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
let idx = 0;
|
|
549
|
-
const tryNext = () => {
|
|
550
|
-
const src = JS_URLS[idx++];
|
|
551
|
-
if (!src) {
|
|
552
|
-
reject(new Error("failed to load asciinema-player"));
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const script = document.createElement("script");
|
|
557
|
-
script.id = "asciinemaPlayerJs";
|
|
558
|
-
script.src = src;
|
|
559
|
-
script.async = true;
|
|
560
|
-
script.onload = () => resolve(window.AsciinemaPlayer);
|
|
561
|
-
script.onerror = () => {
|
|
562
|
-
script.remove();
|
|
563
|
-
if (window.AsciinemaPlayer) {
|
|
564
|
-
resolve(window.AsciinemaPlayer);
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
tryNext();
|
|
568
|
-
};
|
|
569
|
-
document.head.appendChild(script);
|
|
570
|
-
};
|
|
571
|
-
tryNext();
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function computeMarkers(castText) {
|
|
576
|
-
try {
|
|
577
|
-
const lines = String(castText || "").trimEnd().split("\\n");
|
|
578
|
-
const out = [];
|
|
579
|
-
for (let i = 1; i < lines.length; i++) {
|
|
580
|
-
const line = (lines[i] || "").trim();
|
|
581
|
-
if (!line) continue;
|
|
582
|
-
const value = JSON.parse(line);
|
|
583
|
-
if (!Array.isArray(value) || value.length < 3) continue;
|
|
584
|
-
const t = Number(value[0]);
|
|
585
|
-
const type = String(value[1]);
|
|
586
|
-
const data = String(value[2]);
|
|
587
|
-
if (!Number.isFinite(t)) continue;
|
|
588
|
-
|
|
589
|
-
// Prefer explicit marks when present.
|
|
590
|
-
if (type === "m") out.push(t);
|
|
591
|
-
|
|
592
|
-
// Also mark "Enter" submissions (helps jump between commands).
|
|
593
|
-
if (type === "i" && data.indexOf("\\r") >= 0) out.push(t);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
out.sort((a, b) => a - b);
|
|
597
|
-
// Deduplicate with a tiny epsilon to keep the marker list sane.
|
|
598
|
-
const uniq = [];
|
|
599
|
-
let last = -1e9;
|
|
600
|
-
for (const t of out) {
|
|
601
|
-
if (t - last > 0.001) {
|
|
602
|
-
uniq.push(t);
|
|
603
|
-
last = t;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
// Cap to avoid pathological UIs (e.g. every keypress).
|
|
607
|
-
return uniq.slice(0, 200);
|
|
608
|
-
} catch {
|
|
609
|
-
return [];
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function toggleSize(player) {
|
|
614
|
-
container.classList.toggle("expanded");
|
|
615
|
-
if (toggleBtn) {
|
|
616
|
-
toggleBtn.textContent = container.classList.contains("expanded")
|
|
617
|
-
? "collapse"
|
|
618
|
-
: "expand";
|
|
619
|
-
}
|
|
620
|
-
// Nudge the player to re-render after resize.
|
|
621
|
-
try {
|
|
622
|
-
if (player && typeof player.getCurrentTime === "function" && typeof player.seek === "function") {
|
|
623
|
-
const t = player.getCurrentTime();
|
|
624
|
-
player.seek(t);
|
|
625
|
-
}
|
|
626
|
-
} catch {
|
|
627
|
-
// ignore
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
async function mountPlayer() {
|
|
632
|
-
setStatus("loading…");
|
|
633
|
-
await loadCssOnce();
|
|
634
|
-
const AsciinemaPlayer = await loadScriptOnce();
|
|
635
|
-
if (!AsciinemaPlayer || !AsciinemaPlayer.create) {
|
|
636
|
-
throw new Error("AsciinemaPlayer API missing");
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const castText = JSON.parse(castEl.textContent || '""');
|
|
640
|
-
const markers = computeMarkers(castText);
|
|
641
|
-
|
|
642
|
-
const player = AsciinemaPlayer.create({ data: () => castText }, container, {
|
|
643
|
-
// Keep it compact inside the report; user can expand if needed.
|
|
644
|
-
fit: "both",
|
|
645
|
-
controls: true,
|
|
646
|
-
preload: true,
|
|
647
|
-
autoPlay: false,
|
|
648
|
-
markers: markers.length ? markers : undefined,
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
if (toggleBtn) toggleBtn.addEventListener("click", () => toggleSize(player));
|
|
652
|
-
|
|
653
|
-
setStatus("ready");
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
mountPlayer().catch((err) => {
|
|
657
|
-
setStatus("failed: " + (err && err.message ? err.message : String(err)));
|
|
658
|
-
});
|
|
659
|
-
})();
|
|
660
|
-
</script>
|
|
661
|
-
`;
|
|
662
|
-
|
|
663
|
-
const markListHtml =
|
|
664
|
-
markFrames.length === 0
|
|
665
|
-
? `<p class="muted">No marks recorded.</p>`
|
|
666
|
-
: `<ol class="marks">
|
|
667
|
-
${markFrames
|
|
668
|
-
.map((f) => {
|
|
669
|
-
const label = f.markLabel?.trim() || "(unnamed)";
|
|
670
|
-
return `<li><a href="#${escapeHtml(f.id)}">t=${f.atSeconds.toFixed(3)}s — ${escapeHtml(label)}</a></li>`;
|
|
671
|
-
})
|
|
672
|
-
.join("\n")}
|
|
673
|
-
</ol>`;
|
|
674
|
-
|
|
675
|
-
const traceData = {
|
|
676
|
-
version: 2,
|
|
677
|
-
durationSeconds,
|
|
678
|
-
frames: input.frames.map((f, idx) => ({
|
|
679
|
-
index: idx + 1,
|
|
680
|
-
id: f.id,
|
|
681
|
-
atSeconds: f.atSeconds,
|
|
682
|
-
kind: f.kind,
|
|
683
|
-
label: f.label,
|
|
684
|
-
markLabel: f.markLabel ?? null,
|
|
685
|
-
changedCount: f.changedCount,
|
|
686
|
-
stepInfo: f.stepInfo ?? null,
|
|
687
|
-
})),
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
const frameListHtml = input.frames
|
|
691
|
-
.map((frame, idx) => {
|
|
692
|
-
const statusBadge =
|
|
693
|
-
frame.stepInfo && frame.stepInfo.ok
|
|
694
|
-
? `<span class="badge pass">PASS</span>`
|
|
695
|
-
: frame.stepInfo && !frame.stepInfo.ok
|
|
696
|
-
? `<span class="badge fail">FAIL</span>`
|
|
697
|
-
: `<span class="badge">INFO</span>`;
|
|
698
|
-
|
|
699
|
-
const changedBadge =
|
|
700
|
-
frame.changedCount > 0 ? `<span class="badge">changed=${frame.changedCount}</span>` : "";
|
|
701
|
-
|
|
702
|
-
return `<li>
|
|
703
|
-
<button
|
|
704
|
-
type="button"
|
|
705
|
-
class="frame-btn"
|
|
706
|
-
data-idx="${idx}"
|
|
707
|
-
data-id="${escapeHtml(frame.id)}"
|
|
708
|
-
data-kind="${escapeHtml(frame.kind)}"
|
|
709
|
-
data-ok="${frame.stepInfo ? String(frame.stepInfo.ok) : ""}"
|
|
710
|
-
data-changed="${String(frame.changedCount)}"
|
|
711
|
-
>
|
|
712
|
-
<div class="frame-btn-top">
|
|
713
|
-
${statusBadge}
|
|
714
|
-
${changedBadge}
|
|
715
|
-
<span class="mono frame-btn-time">t=${frame.atSeconds.toFixed(3)}s</span>
|
|
716
|
-
</div>
|
|
717
|
-
<div class="frame-btn-label mono">${escapeHtml(frame.label)}</div>
|
|
718
|
-
</button>
|
|
719
|
-
</li>`;
|
|
720
|
-
})
|
|
721
|
-
.join("\n");
|
|
722
|
-
|
|
723
|
-
const templatesHtml = input.frames
|
|
724
|
-
.map((frame) => {
|
|
725
|
-
const prevTpl = frame.previousViewHtml
|
|
726
|
-
? `<template id="prev-${escapeHtml(frame.id)}">${frame.previousViewHtml}</template>`
|
|
727
|
-
: "";
|
|
728
|
-
return `<template id="tpl-${escapeHtml(frame.id)}">${frame.viewHtml}</template>${prevTpl}`;
|
|
729
|
-
})
|
|
730
|
-
.join("\n");
|
|
731
|
-
|
|
732
|
-
return `<!doctype html>
|
|
733
|
-
<html lang="en">
|
|
734
|
-
<head>
|
|
735
|
-
<meta charset="utf-8" />
|
|
736
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
737
|
-
<title>${escapeHtml(title)}</title>
|
|
738
|
-
<style>
|
|
739
|
-
:root {
|
|
740
|
-
/* Base Colors - Slate/Zinc inspired */
|
|
741
|
-
--bg-body: #f8fafc;
|
|
742
|
-
--bg-card: #ffffff;
|
|
743
|
-
--bg-subtle: #f1f5f9;
|
|
744
|
-
--bg-hover: #e2e8f0;
|
|
745
|
-
--bg-active: #cbd5e1;
|
|
746
|
-
|
|
747
|
-
--border-subtle: #e2e8f0;
|
|
748
|
-
--border-default: #cbd5e1;
|
|
749
|
-
--border-active: #94a3b8;
|
|
750
|
-
|
|
751
|
-
--text-main: #0f172a;
|
|
752
|
-
--text-muted: #64748b;
|
|
753
|
-
--text-faint: #94a3b8;
|
|
754
|
-
|
|
755
|
-
/* Accents */
|
|
756
|
-
--accent-primary: #0f172a; /* Slate 900 */
|
|
757
|
-
--accent-primary-fg: #f8fafc;
|
|
758
|
-
--accent-brand: #3b82f6; /* Blue 500 */
|
|
759
|
-
|
|
760
|
-
/* Status Colors */
|
|
761
|
-
--status-pass-bg: #dcfce7;
|
|
762
|
-
--status-pass-text: #166534;
|
|
763
|
-
--status-pass-border: #86efac;
|
|
764
|
-
|
|
765
|
-
--status-fail-bg: #fee2e2;
|
|
766
|
-
--status-fail-text: #991b1b;
|
|
767
|
-
--status-fail-border: #fca5a5;
|
|
768
|
-
|
|
769
|
-
--status-info-bg: #e0f2fe;
|
|
770
|
-
--status-info-text: #075985;
|
|
771
|
-
--status-info-border: #7dd3fc;
|
|
772
|
-
|
|
773
|
-
--status-changed-bg: #fef3c7;
|
|
774
|
-
--status-changed-text: #92400e;
|
|
775
|
-
|
|
776
|
-
/* Fonts */
|
|
777
|
-
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
778
|
-
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
779
|
-
|
|
780
|
-
/* Shadows */
|
|
781
|
-
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
782
|
-
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
@media (prefers-color-scheme: dark) {
|
|
786
|
-
:root {
|
|
787
|
-
/* Dark Mode Base */
|
|
788
|
-
--bg-body: #0f172a;
|
|
789
|
-
--bg-card: #1e293b;
|
|
790
|
-
--bg-subtle: #334155;
|
|
791
|
-
--bg-hover: #475569;
|
|
792
|
-
--bg-active: #64748b;
|
|
793
|
-
|
|
794
|
-
--border-subtle: #334155;
|
|
795
|
-
--border-default: #475569;
|
|
796
|
-
--border-active: #64748b;
|
|
797
|
-
|
|
798
|
-
--text-main: #f8fafc;
|
|
799
|
-
--text-muted: #94a3b8;
|
|
800
|
-
--text-faint: #64748b;
|
|
801
|
-
|
|
802
|
-
--accent-primary: #f8fafc;
|
|
803
|
-
--accent-primary-fg: #0f172a;
|
|
804
|
-
--accent-brand: #60a5fa; /* Blue 400 */
|
|
805
|
-
|
|
806
|
-
/* Dark Mode Status */
|
|
807
|
-
--status-pass-bg: #052e16;
|
|
808
|
-
--status-pass-text: #4ade80;
|
|
809
|
-
--status-pass-border: #166534;
|
|
810
|
-
|
|
811
|
-
--status-fail-bg: #450a0a;
|
|
812
|
-
--status-fail-text: #f87171;
|
|
813
|
-
--status-fail-border: #991b1b;
|
|
814
|
-
|
|
815
|
-
--status-info-bg: #082f49;
|
|
816
|
-
--status-info-text: #38bdf8;
|
|
817
|
-
--status-info-border: #075985;
|
|
818
|
-
|
|
819
|
-
--status-changed-bg: #451a03;
|
|
820
|
-
--status-changed-text: #fbbf24;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
body {
|
|
825
|
-
margin: 0;
|
|
826
|
-
background-color: var(--bg-body);
|
|
827
|
-
color: var(--text-main);
|
|
828
|
-
font-family: var(--font-sans);
|
|
829
|
-
line-height: 1.5;
|
|
830
|
-
font-size: 14px;
|
|
831
|
-
-webkit-font-smoothing: antialiased;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
* {
|
|
835
|
-
box-sizing: border-box;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/* Layout & Containers */
|
|
839
|
-
header {
|
|
840
|
-
background-color: var(--bg-card);
|
|
841
|
-
padding: 16px 24px;
|
|
842
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
843
|
-
box-shadow: var(--shadow-sm);
|
|
844
|
-
position: sticky;
|
|
845
|
-
top: 0;
|
|
846
|
-
z-index: 50;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
main {
|
|
850
|
-
max-width: 1600px;
|
|
851
|
-
margin: 0 auto;
|
|
852
|
-
padding: 24px;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
.section {
|
|
856
|
-
margin-bottom: 24px;
|
|
857
|
-
background-color: var(--bg-card);
|
|
858
|
-
border: 1px solid var(--border-subtle);
|
|
859
|
-
border-radius: 12px;
|
|
860
|
-
padding: 20px;
|
|
861
|
-
box-shadow: var(--shadow-sm);
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
h1, h2, h3 {
|
|
865
|
-
margin: 0;
|
|
866
|
-
font-weight: 600;
|
|
867
|
-
letter-spacing: -0.025em;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
header h1 {
|
|
871
|
-
font-size: 20px;
|
|
872
|
-
margin-bottom: 8px;
|
|
873
|
-
color: var(--text-main);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
h2 {
|
|
877
|
-
font-size: 16px;
|
|
878
|
-
margin-bottom: 16px;
|
|
879
|
-
padding-bottom: 8px;
|
|
880
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
881
|
-
color: var(--text-main);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/* Typography & Utility */
|
|
885
|
-
.mono { font-family: var(--font-mono); }
|
|
886
|
-
.muted { color: var(--text-muted); }
|
|
887
|
-
|
|
888
|
-
a {
|
|
889
|
-
color: var(--accent-brand);
|
|
890
|
-
text-decoration: none;
|
|
891
|
-
}
|
|
892
|
-
a:hover { text-decoration: underline; }
|
|
893
|
-
|
|
894
|
-
pre {
|
|
895
|
-
margin: 0;
|
|
896
|
-
font-family: var(--font-mono);
|
|
897
|
-
font-size: 13px;
|
|
898
|
-
line-height: normal;
|
|
899
|
-
white-space: pre;
|
|
900
|
-
overflow: auto;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/* Badges */
|
|
904
|
-
.badges {
|
|
905
|
-
display: flex;
|
|
906
|
-
gap: 8px;
|
|
907
|
-
flex-wrap: wrap;
|
|
908
|
-
margin-top: 8px;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
.badge {
|
|
912
|
-
display: inline-flex;
|
|
913
|
-
align-items: center;
|
|
914
|
-
padding: 2px 10px;
|
|
915
|
-
border-radius: 9999px;
|
|
916
|
-
font-size: 12px;
|
|
917
|
-
font-weight: 500;
|
|
918
|
-
border: 1px solid var(--border-default);
|
|
919
|
-
background-color: var(--bg-subtle);
|
|
920
|
-
color: var(--text-muted);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
.badge.pass {
|
|
924
|
-
background-color: var(--status-pass-bg);
|
|
925
|
-
color: var(--status-pass-text);
|
|
926
|
-
border-color: var(--status-pass-border);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
.badge.fail {
|
|
930
|
-
background-color: var(--status-fail-bg);
|
|
931
|
-
color: var(--status-fail-text);
|
|
932
|
-
border-color: var(--status-fail-border);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
.badge.chip {
|
|
936
|
-
cursor: pointer;
|
|
937
|
-
transition: all 0.2s;
|
|
938
|
-
}
|
|
939
|
-
.badge.chip:hover {
|
|
940
|
-
background-color: var(--bg-hover);
|
|
941
|
-
}
|
|
942
|
-
.badge.chip[aria-pressed="true"] {
|
|
943
|
-
background-color: var(--accent-primary);
|
|
944
|
-
color: var(--accent-primary-fg);
|
|
945
|
-
border-color: var(--accent-primary);
|
|
946
|
-
}
|
|
947
|
-
/* Special case: toggle badge in header */
|
|
948
|
-
.badge.toggle { cursor: pointer; user-select: none; }
|
|
949
|
-
#debugToggle:checked ~ header .badge.toggle {
|
|
950
|
-
background-color: var(--accent-brand);
|
|
951
|
-
color: white;
|
|
952
|
-
border-color: var(--accent-brand);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/* Trace Layout */
|
|
956
|
-
.trace {
|
|
957
|
-
display: grid;
|
|
958
|
-
grid-template-columns: 320px 1fr;
|
|
959
|
-
gap: 24px;
|
|
960
|
-
height: 70vh;
|
|
961
|
-
min-height: 500px;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
.trace aside {
|
|
965
|
-
display: flex;
|
|
966
|
-
flex-direction: column;
|
|
967
|
-
height: 100%;
|
|
968
|
-
overflow: hidden;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/* Frame List in Sidebar */
|
|
972
|
-
.controls {
|
|
973
|
-
display: flex;
|
|
974
|
-
gap: 8px;
|
|
975
|
-
flex-wrap: wrap;
|
|
976
|
-
margin-bottom: 12px;
|
|
977
|
-
padding-bottom: 12px;
|
|
978
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
.input {
|
|
982
|
-
width: 100%;
|
|
983
|
-
font-family: var(--font-mono);
|
|
984
|
-
font-size: 13px;
|
|
985
|
-
padding: 8px 12px;
|
|
986
|
-
border-radius: 6px;
|
|
987
|
-
border: 1px solid var(--border-default);
|
|
988
|
-
background-color: var(--bg-body);
|
|
989
|
-
color: var(--text-main);
|
|
990
|
-
transition: border-color 0.2s;
|
|
991
|
-
}
|
|
992
|
-
.input:focus {
|
|
993
|
-
outline: none;
|
|
994
|
-
border-color: var(--accent-brand);
|
|
995
|
-
box-shadow: 0 0 0 2px var(--bg-body), 0 0 0 4px var(--accent-brand);
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
.frame-list {
|
|
999
|
-
list-style: none;
|
|
1000
|
-
padding: 0;
|
|
1001
|
-
margin: 0;
|
|
1002
|
-
overflow-y: auto;
|
|
1003
|
-
flex: 1;
|
|
1004
|
-
display: flex;
|
|
1005
|
-
flex-direction: column;
|
|
1006
|
-
gap: 4px;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
.frame-btn {
|
|
1010
|
-
width: 100%;
|
|
1011
|
-
text-align: left;
|
|
1012
|
-
padding: 10px 12px;
|
|
1013
|
-
border-radius: 8px;
|
|
1014
|
-
border: 1px solid transparent;
|
|
1015
|
-
background: transparent;
|
|
1016
|
-
color: var(--text-main);
|
|
1017
|
-
cursor: pointer;
|
|
1018
|
-
transition: all 0.1s;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
.frame-btn:hover {
|
|
1022
|
-
background-color: var(--bg-hover);
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
.frame-btn[aria-selected="true"] {
|
|
1026
|
-
background-color: var(--bg-active);
|
|
1027
|
-
border-color: var(--border-active);
|
|
1028
|
-
font-weight: 500;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
.frame-btn-top {
|
|
1032
|
-
display: flex;
|
|
1033
|
-
align-items: center;
|
|
1034
|
-
gap: 8px;
|
|
1035
|
-
margin-bottom: 4px;
|
|
1036
|
-
font-size: 11px;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
.frame-btn-label {
|
|
1040
|
-
font-family: var(--font-mono);
|
|
1041
|
-
font-size: 12px;
|
|
1042
|
-
overflow: hidden;
|
|
1043
|
-
text-overflow: ellipsis;
|
|
1044
|
-
white-space: nowrap;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/* Main Viewer */
|
|
1048
|
-
.viewer {
|
|
1049
|
-
display: flex;
|
|
1050
|
-
flex-direction: column;
|
|
1051
|
-
height: 100%;
|
|
1052
|
-
border: 1px solid var(--border-default);
|
|
1053
|
-
border-radius: 8px;
|
|
1054
|
-
overflow: hidden;
|
|
1055
|
-
background-color: var(--bg-body);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
.viewer-tabs {
|
|
1059
|
-
display: flex;
|
|
1060
|
-
background-color: var(--bg-subtle);
|
|
1061
|
-
border-bottom: 1px solid var(--border-default);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
.viewer-tab {
|
|
1065
|
-
padding: 10px 16px;
|
|
1066
|
-
font-size: 13px;
|
|
1067
|
-
font-weight: 500;
|
|
1068
|
-
color: var(--text-muted);
|
|
1069
|
-
background: transparent;
|
|
1070
|
-
border: none;
|
|
1071
|
-
border-right: 1px solid var(--border-subtle);
|
|
1072
|
-
cursor: pointer;
|
|
1073
|
-
transition: background 0.2s;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
.viewer-tab:hover {
|
|
1077
|
-
background-color: var(--bg-hover);
|
|
1078
|
-
color: var(--text-main);
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
.viewer-tab[aria-selected="true"] {
|
|
1082
|
-
background-color: var(--bg-body);
|
|
1083
|
-
color: var(--accent-brand);
|
|
1084
|
-
box-shadow: inset 0 -2px 0 0 var(--accent-brand);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
.viewer-tab.has-error {
|
|
1088
|
-
color: var(--status-fail-text);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
.viewer-header {
|
|
1092
|
-
padding: 12px 16px;
|
|
1093
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
1094
|
-
background-color: var(--bg-card);
|
|
1095
|
-
font-size: 13px;
|
|
1096
|
-
display: flex;
|
|
1097
|
-
gap: 12px;
|
|
1098
|
-
align-items: baseline;
|
|
1099
|
-
}
|
|
1100
|
-
.viewer-title { font-weight: 600; color: var(--text-main); }
|
|
1101
|
-
.viewer-sub { color: var(--text-faint); font-size: 12px; }
|
|
1102
|
-
|
|
1103
|
-
.viewer-content {
|
|
1104
|
-
flex: 1;
|
|
1105
|
-
overflow: auto;
|
|
1106
|
-
position: relative;
|
|
1107
|
-
display: none;
|
|
1108
|
-
}
|
|
1109
|
-
.viewer-content.active { display: block; }
|
|
1110
|
-
|
|
1111
|
-
/* Terminal Render */
|
|
1112
|
-
.terminal {
|
|
1113
|
-
background-color: #0d1117; /* GitHub Dark dim */
|
|
1114
|
-
color: #c9d1d9;
|
|
1115
|
-
font-family: var(--font-mono);
|
|
1116
|
-
font-size: 13px;
|
|
1117
|
-
line-height: normal;
|
|
1118
|
-
padding: 16px;
|
|
1119
|
-
min-height: 100%;
|
|
1120
|
-
}
|
|
1121
|
-
.terminal .headerblock {
|
|
1122
|
-
color: #8b949e;
|
|
1123
|
-
margin-bottom: 8px;
|
|
1124
|
-
display: block;
|
|
1125
|
-
font-size: 11px;
|
|
1126
|
-
}
|
|
1127
|
-
.terminal .row {
|
|
1128
|
-
display: block;
|
|
1129
|
-
}
|
|
1130
|
-
.terminal .ln {
|
|
1131
|
-
display: inline-block;
|
|
1132
|
-
width: 3ch;
|
|
1133
|
-
margin-right: 1ch;
|
|
1134
|
-
color: #484f58;
|
|
1135
|
-
user-select: none;
|
|
1136
|
-
text-align: right;
|
|
1137
|
-
vertical-align: top;
|
|
1138
|
-
}
|
|
1139
|
-
.terminal .row.changed {
|
|
1140
|
-
background: rgba(187, 128, 9, 0.15); /* Yellow marking */
|
|
1141
|
-
}
|
|
1142
|
-
.terminal .seg { display: inline; }
|
|
1143
|
-
|
|
1144
|
-
/* Hide debug lines if toggle off */
|
|
1145
|
-
#debugToggle:not(:checked) ~ main .terminal .headerblock,
|
|
1146
|
-
#debugToggle:not(:checked) ~ main .terminal .ln {
|
|
1147
|
-
display: none;
|
|
1148
|
-
}
|
|
1149
|
-
#debugToggle:not(:checked) ~ main .terminal .row.changed {
|
|
1150
|
-
background: transparent;
|
|
1151
|
-
}
|
|
1152
|
-
.debug-toggle {
|
|
1153
|
-
position: absolute;
|
|
1154
|
-
width: 0; height: 0; opacity: 0;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
/* Timeline */
|
|
1158
|
-
.timeline {
|
|
1159
|
-
padding: 6px 0;
|
|
1160
|
-
margin-bottom: 16px;
|
|
1161
|
-
}
|
|
1162
|
-
.timeline-header {
|
|
1163
|
-
display: flex;
|
|
1164
|
-
justify-content: space-between;
|
|
1165
|
-
margin-bottom: 8px;
|
|
1166
|
-
font-size: 12px;
|
|
1167
|
-
color: var(--text-muted);
|
|
1168
|
-
font-family: var(--font-mono);
|
|
1169
|
-
}
|
|
1170
|
-
.timeline-track {
|
|
1171
|
-
height: 32px;
|
|
1172
|
-
background-color: var(--bg-subtle);
|
|
1173
|
-
border: 1px solid var(--border-default);
|
|
1174
|
-
border-radius: 4px;
|
|
1175
|
-
position: relative;
|
|
1176
|
-
cursor: pointer;
|
|
1177
|
-
overflow: hidden;
|
|
1178
|
-
}
|
|
1179
|
-
.timeline-bar {
|
|
1180
|
-
position: absolute;
|
|
1181
|
-
top: 4px; bottom: 4px;
|
|
1182
|
-
background-color: var(--border-active);
|
|
1183
|
-
border-radius: 1px;
|
|
1184
|
-
min-width: 2px;
|
|
1185
|
-
}
|
|
1186
|
-
.timeline-bar.pass { background-color: var(--status-pass-border); }
|
|
1187
|
-
.timeline-bar.fail { background-color: var(--status-fail-text); }
|
|
1188
|
-
.timeline-bar.info { background-color: var(--status-info-border); }
|
|
1189
|
-
.timeline-bar.selected {
|
|
1190
|
-
background-color: var(--accent-brand);
|
|
1191
|
-
z-index: 10;
|
|
1192
|
-
top: 0; bottom: 0;
|
|
1193
|
-
box-shadow: 0 0 0 1px white;
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/* Other Components */
|
|
1197
|
-
.call-row {
|
|
1198
|
-
display: grid;
|
|
1199
|
-
grid-template-columns: 100px 1fr;
|
|
1200
|
-
gap: 16px;
|
|
1201
|
-
padding: 8px 16px;
|
|
1202
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
1203
|
-
font-size: 13px;
|
|
1204
|
-
}
|
|
1205
|
-
.call-key { color: var(--text-muted); font-weight: 500; text-align: right; }
|
|
1206
|
-
.call-value { color: var(--text-main); font-family: var(--font-mono); }
|
|
1207
|
-
|
|
1208
|
-
.error-box {
|
|
1209
|
-
margin: 16px;
|
|
1210
|
-
padding: 16px;
|
|
1211
|
-
background-color: var(--status-fail-bg);
|
|
1212
|
-
border: 1px solid var(--status-fail-border);
|
|
1213
|
-
border-radius: 6px;
|
|
1214
|
-
color: var(--status-fail-text);
|
|
1215
|
-
}
|
|
1216
|
-
.error-title { font-weight: 700; margin-bottom: 8px; }
|
|
1217
|
-
|
|
1218
|
-
.diff-view {
|
|
1219
|
-
display: grid;
|
|
1220
|
-
grid-template-columns: 1fr 1fr;
|
|
1221
|
-
height: 100%;
|
|
1222
|
-
}
|
|
1223
|
-
.diff-pane {
|
|
1224
|
-
overflow: auto;
|
|
1225
|
-
border-right: 1px solid #30363d;
|
|
1226
|
-
background-color: #0d1117;
|
|
1227
|
-
}
|
|
1228
|
-
.diff-pane-header {
|
|
1229
|
-
background: #161b22;
|
|
1230
|
-
color: #8b949e;
|
|
1231
|
-
padding: 8px 16px;
|
|
1232
|
-
font-size: 11px;
|
|
1233
|
-
font-weight: 600;
|
|
1234
|
-
border-bottom: 1px solid #30363d;
|
|
1235
|
-
position: sticky;
|
|
1236
|
-
top: 0;
|
|
1237
|
-
}
|
|
1238
|
-
/* Built-in player */
|
|
1239
|
-
.builtin-player {
|
|
1240
|
-
background: #0b0f14;
|
|
1241
|
-
border-radius: 10px;
|
|
1242
|
-
overflow: hidden;
|
|
1243
|
-
border: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
|
1244
|
-
}
|
|
1245
|
-
/* Cast player styles */
|
|
1246
|
-
.cast-player {
|
|
1247
|
-
height: 450px;
|
|
1248
|
-
min-height: 200px;
|
|
1249
|
-
max-height: 450px;
|
|
1250
|
-
overflow: hidden;
|
|
1251
|
-
background: transparent;
|
|
1252
|
-
margin-top: 16px;
|
|
1253
|
-
margin-bottom: 20px;
|
|
1254
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
/* Force left alignment of the player */
|
|
1258
|
-
.cast-player .ap-wrapper {
|
|
1259
|
-
display: flex;
|
|
1260
|
-
justify-content: flex-start !important;
|
|
1261
|
-
text-align: left;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
/* Style the inner player terminal box */
|
|
1265
|
-
.cast-player .ap-player {
|
|
1266
|
-
border-radius: 8px;
|
|
1267
|
-
box-shadow: var(--shadow-md);
|
|
1268
|
-
border: 1px solid var(--border-subtle);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
.cast-player.expanded {
|
|
1272
|
-
height: 80vh;
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
.cast-controls {
|
|
1276
|
-
display: flex;
|
|
1277
|
-
align-items: center;
|
|
1278
|
-
gap: 12px;
|
|
1279
|
-
margin: 12px 0;
|
|
1280
|
-
padding-bottom: 12px;
|
|
1281
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
@media (max-width: 1024px) {
|
|
1285
|
-
.trace { grid-template-columns: 1fr; height: auto; }
|
|
1286
|
-
.viewer { height: 500px; }
|
|
1287
|
-
.frame-list { max-height: 300px; }
|
|
1288
|
-
}
|
|
1289
|
-
</style>
|
|
1290
|
-
</head>
|
|
1291
|
-
<body>
|
|
1292
|
-
<input id="debugToggle" class="debug-toggle" type="checkbox" checked />
|
|
1293
|
-
<header>
|
|
1294
|
-
<h1>${escapeHtml(title)}</h1>
|
|
1295
|
-
<div class="badges">
|
|
1296
|
-
<span class="badge ${resultClass}">result=${escapeHtml(resultLabel)}</span>
|
|
1297
|
-
<span class="badge">marks=${markFrames.length}</span>
|
|
1298
|
-
<span class="badge">duration=${durationSeconds.toFixed(3)}s</span>
|
|
1299
|
-
<label class="badge toggle" for="debugToggle">debug</label>
|
|
1300
|
-
</div>
|
|
1301
|
-
<div class="meta">term=${escapeHtml(input.term.type)} ${input.term.cols}x${input.term.rows} scope=${escapeHtml(input.scope)} events=${input.eventCount}
|
|
1302
|
-
command=${escapeHtml(command)}
|
|
1303
|
-
timestamp=${escapeHtml(coerceDisplayString(timestamp))}</div>
|
|
1304
|
-
<details>
|
|
1305
|
-
<summary>Raw header JSON</summary>
|
|
1306
|
-
<pre>${escapeHtml(headerJson)}</pre>
|
|
1307
|
-
</details>
|
|
1308
|
-
</header>
|
|
1309
|
-
<main>
|
|
1310
|
-
<section class="section">
|
|
1311
|
-
<h2>Task</h2>
|
|
1312
|
-
<pre>${escapeHtml(
|
|
1313
|
-
[
|
|
1314
|
-
input.scriptName ? `script=${input.scriptName}` : null,
|
|
1315
|
-
command ? `command=${command}` : null,
|
|
1316
|
-
`term=${input.term.type} ${input.term.cols}x${input.term.rows}`,
|
|
1317
|
-
`scope=${input.scope}`,
|
|
1318
|
-
]
|
|
1319
|
-
.filter(Boolean)
|
|
1320
|
-
.join("\n"),
|
|
1321
|
-
)}</pre>
|
|
1322
|
-
</section>
|
|
1323
|
-
<section class="section">
|
|
1324
|
-
<h2>Artifacts</h2>
|
|
1325
|
-
${artifactsHtml}
|
|
1326
|
-
</section>
|
|
1327
|
-
<section class="section" id="cast-playback">
|
|
1328
|
-
<h2>Cast Playback</h2>
|
|
1329
|
-
${castPlayerHtml}
|
|
1330
|
-
</section>
|
|
1331
|
-
<section class="section">
|
|
1332
|
-
<h2>Marks</h2>
|
|
1333
|
-
${markListHtml}
|
|
1334
|
-
</section>
|
|
1335
|
-
<section class="section">
|
|
1336
|
-
<h2>Trace</h2>
|
|
1337
|
-
<!-- Timeline -->
|
|
1338
|
-
<div class="timeline" id="timeline">
|
|
1339
|
-
<div class="timeline-header">
|
|
1340
|
-
<span class="mono">Timeline</span>
|
|
1341
|
-
<span class="mono muted" id="timelineInfo">0 steps · 0.000s</span>
|
|
1342
|
-
</div>
|
|
1343
|
-
<div class="timeline-track" id="timelineTrack"></div>
|
|
1344
|
-
</div>
|
|
1345
|
-
<div class="trace">
|
|
1346
|
-
<aside>
|
|
1347
|
-
<div class="controls">
|
|
1348
|
-
<input id="frameSearch" class="input mono" placeholder="Search frames…" autocomplete="off" />
|
|
1349
|
-
<button id="modeAll" class="badge chip" type="button" aria-pressed="true">all</button>
|
|
1350
|
-
<button id="modeChanged" class="badge chip" type="button" aria-pressed="false">changed</button>
|
|
1351
|
-
<button id="modeMarks" class="badge chip" type="button" aria-pressed="false">marks</button>
|
|
1352
|
-
<button id="modeFailed" class="badge chip fail" type="button" aria-pressed="false">failed</button>
|
|
1353
|
-
<span id="visibleFrames" class="badge">visible=0</span>
|
|
1354
|
-
</div>
|
|
1355
|
-
<ol id="frameList" class="frame-list">
|
|
1356
|
-
${frameListHtml}
|
|
1357
|
-
</ol>
|
|
1358
|
-
</aside>
|
|
1359
|
-
<div class="viewer">
|
|
1360
|
-
<div class="viewer-tabs" id="viewerTabs">
|
|
1361
|
-
<button class="viewer-tab" data-tab="snapshot" aria-selected="true">Snapshot</button>
|
|
1362
|
-
<button class="viewer-tab" data-tab="call">Call</button>
|
|
1363
|
-
<button class="viewer-tab" data-tab="errors" id="errorsTab">Errors</button>
|
|
1364
|
-
<button class="viewer-tab" data-tab="diff">Diff</button>
|
|
1365
|
-
</div>
|
|
1366
|
-
<div class="viewer-header">
|
|
1367
|
-
<span id="viewerTitle" class="viewer-title mono"></span>
|
|
1368
|
-
<span id="viewerSub" class="viewer-sub mono muted"></span>
|
|
1369
|
-
</div>
|
|
1370
|
-
<div id="viewerSnapshot" class="viewer-content active">
|
|
1371
|
-
<pre id="viewer" class="terminal"></pre>
|
|
1372
|
-
</div>
|
|
1373
|
-
<div id="viewerCall" class="viewer-content">
|
|
1374
|
-
<div class="call-details" id="callDetails"></div>
|
|
1375
|
-
</div>
|
|
1376
|
-
<div id="viewerErrors" class="viewer-content">
|
|
1377
|
-
<div id="errorContent"></div>
|
|
1378
|
-
</div>
|
|
1379
|
-
<div id="viewerDiff" class="viewer-content">
|
|
1380
|
-
<div class="diff-view" id="diffView">
|
|
1381
|
-
<div class="diff-pane">
|
|
1382
|
-
<div class="diff-pane-header">Previous</div>
|
|
1383
|
-
<pre id="diffPrev" class="terminal"></pre>
|
|
1384
|
-
</div>
|
|
1385
|
-
<div class="diff-pane">
|
|
1386
|
-
<div class="diff-pane-header">Current</div>
|
|
1387
|
-
<pre id="diffCurr" class="terminal"></pre>
|
|
1388
|
-
</div>
|
|
1389
|
-
</div>
|
|
1390
|
-
</div>
|
|
1391
|
-
</div>
|
|
1392
|
-
</div>
|
|
1393
|
-
<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>
|
|
1394
|
-
<script id="traceData" type="application/json">${jsonForHtml(traceData)}</script>
|
|
1395
|
-
${templatesHtml}
|
|
1396
|
-
<script>
|
|
1397
|
-
(function () {
|
|
1398
|
-
const dataEl = document.getElementById("traceData");
|
|
1399
|
-
const listEl = document.getElementById("frameList");
|
|
1400
|
-
const viewerEl = document.getElementById("viewer");
|
|
1401
|
-
const titleEl = document.getElementById("viewerTitle");
|
|
1402
|
-
const subEl = document.getElementById("viewerSub");
|
|
1403
|
-
const searchEl = document.getElementById("frameSearch");
|
|
1404
|
-
const visibleEl = document.getElementById("visibleFrames");
|
|
1405
|
-
const modeAll = document.getElementById("modeAll");
|
|
1406
|
-
const modeChanged = document.getElementById("modeChanged");
|
|
1407
|
-
const modeMarks = document.getElementById("modeMarks");
|
|
1408
|
-
const modeFailed = document.getElementById("modeFailed");
|
|
1409
|
-
const timelineTrack = document.getElementById("timelineTrack");
|
|
1410
|
-
const timelineInfo = document.getElementById("timelineInfo");
|
|
1411
|
-
const viewerTabs = document.getElementById("viewerTabs");
|
|
1412
|
-
const callDetails = document.getElementById("callDetails");
|
|
1413
|
-
const errorContent = document.getElementById("errorContent");
|
|
1414
|
-
const diffPrev = document.getElementById("diffPrev");
|
|
1415
|
-
const diffCurr = document.getElementById("diffCurr");
|
|
1416
|
-
const errorsTab = document.getElementById("errorsTab");
|
|
1417
|
-
if (!dataEl || !listEl || !viewerEl || !titleEl || !subEl || !searchEl) return;
|
|
1418
|
-
|
|
1419
|
-
const raw = JSON.parse(dataEl.textContent || "{}");
|
|
1420
|
-
const frames = Array.isArray(raw.frames) ? raw.frames : [];
|
|
1421
|
-
const durationSeconds = raw.durationSeconds || 0;
|
|
1422
|
-
const buttons = Array.from(listEl.querySelectorAll("button.frame-btn"));
|
|
1423
|
-
const idToIndex = new Map();
|
|
1424
|
-
for (const f of frames) idToIndex.set(f.id, f.index - 1);
|
|
1425
|
-
|
|
1426
|
-
let mode = "all";
|
|
1427
|
-
let current = 0;
|
|
1428
|
-
let activeTab = "snapshot";
|
|
1429
|
-
|
|
1430
|
-
// Timeline setup
|
|
1431
|
-
if (timelineTrack && frames.length > 0) {
|
|
1432
|
-
const maxTime = Math.max(durationSeconds, frames[frames.length - 1]?.atSeconds || 1);
|
|
1433
|
-
timelineInfo.textContent = frames.length + " steps · " + maxTime.toFixed(3) + "s";
|
|
1434
|
-
|
|
1435
|
-
frames.forEach((f, idx) => {
|
|
1436
|
-
const bar = document.createElement("div");
|
|
1437
|
-
bar.className = "timeline-bar";
|
|
1438
|
-
const left = (f.atSeconds / maxTime) * 100;
|
|
1439
|
-
const width = Math.max(2, (1 / frames.length) * 100);
|
|
1440
|
-
bar.style.left = left + "%";
|
|
1441
|
-
bar.style.width = width + "%";
|
|
1442
|
-
|
|
1443
|
-
if (f.stepInfo) {
|
|
1444
|
-
bar.classList.add(f.stepInfo.ok ? "pass" : "fail");
|
|
1445
|
-
} else {
|
|
1446
|
-
bar.classList.add("info");
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
bar.dataset.idx = idx;
|
|
1450
|
-
bar.title = f.label;
|
|
1451
|
-
bar.addEventListener("click", () => select(idx, true));
|
|
1452
|
-
timelineTrack.appendChild(bar);
|
|
1453
|
-
|
|
1454
|
-
// Add error markers
|
|
1455
|
-
if (f.stepInfo && !f.stepInfo.ok) {
|
|
1456
|
-
const marker = document.createElement("div");
|
|
1457
|
-
marker.className = "timeline-marker error";
|
|
1458
|
-
marker.style.left = left + "%";
|
|
1459
|
-
timelineTrack.appendChild(marker);
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// Add mark markers
|
|
1463
|
-
if (f.kind === "mark") {
|
|
1464
|
-
const marker = document.createElement("div");
|
|
1465
|
-
marker.className = "timeline-marker";
|
|
1466
|
-
marker.style.left = left + "%";
|
|
1467
|
-
timelineTrack.appendChild(marker);
|
|
1468
|
-
}
|
|
1469
|
-
});
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
// Tab switching
|
|
1473
|
-
const tabs = viewerTabs ? Array.from(viewerTabs.querySelectorAll(".viewer-tab")) : [];
|
|
1474
|
-
const contents = {
|
|
1475
|
-
snapshot: document.getElementById("viewerSnapshot"),
|
|
1476
|
-
call: document.getElementById("viewerCall"),
|
|
1477
|
-
errors: document.getElementById("viewerErrors"),
|
|
1478
|
-
diff: document.getElementById("viewerDiff"),
|
|
1479
|
-
};
|
|
1480
|
-
|
|
1481
|
-
function switchTab(tabName) {
|
|
1482
|
-
activeTab = tabName;
|
|
1483
|
-
tabs.forEach(t => t.setAttribute("aria-selected", t.dataset.tab === tabName ? "true" : "false"));
|
|
1484
|
-
Object.entries(contents).forEach(([name, el]) => {
|
|
1485
|
-
if (el) el.classList.toggle("active", name === tabName);
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
tabs.forEach(tab => {
|
|
1490
|
-
tab.addEventListener("click", () => switchTab(tab.dataset.tab));
|
|
1491
|
-
});
|
|
1492
|
-
|
|
1493
|
-
function setPressed(el, on) {
|
|
1494
|
-
el.setAttribute("aria-pressed", on ? "true" : "false");
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
function setMode(next) {
|
|
1498
|
-
mode = next;
|
|
1499
|
-
setPressed(modeAll, mode === "all");
|
|
1500
|
-
setPressed(modeChanged, mode === "changed");
|
|
1501
|
-
setPressed(modeMarks, mode === "marks");
|
|
1502
|
-
setPressed(modeFailed, mode === "failed");
|
|
1503
|
-
applyFilter();
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
function applyFilter() {
|
|
1507
|
-
const q = (searchEl.value || "").trim().toLowerCase();
|
|
1508
|
-
let visible = 0;
|
|
1509
|
-
for (const btn of buttons) {
|
|
1510
|
-
const idx = Number(btn.dataset.idx || "0");
|
|
1511
|
-
let show = true;
|
|
1512
|
-
if (mode === "changed") show = Number(btn.dataset.changed || "0") > 0;
|
|
1513
|
-
else if (mode === "marks") show = btn.dataset.kind === "mark";
|
|
1514
|
-
else if (mode === "failed") show = btn.dataset.ok === "false";
|
|
1515
|
-
if (show && q) {
|
|
1516
|
-
const label = (btn.querySelector(".frame-btn-label")?.textContent || "").toLowerCase();
|
|
1517
|
-
if (!label.includes(q)) show = false;
|
|
1518
|
-
}
|
|
1519
|
-
btn.parentElement.style.display = show ? "" : "none";
|
|
1520
|
-
if (show) visible += 1;
|
|
1521
|
-
}
|
|
1522
|
-
if (visibleEl) visibleEl.textContent = "visible=" + visible;
|
|
1523
|
-
|
|
1524
|
-
if (buttons[current] && buttons[current].parentElement.style.display === "none") {
|
|
1525
|
-
const firstVisible = buttons.findIndex((b) => b.parentElement.style.display !== "none");
|
|
1526
|
-
if (firstVisible >= 0) select(firstVisible, false);
|
|
1527
|
-
} else {
|
|
1528
|
-
updateSelected();
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
function updateSelected() {
|
|
1533
|
-
for (const btn of buttons) {
|
|
1534
|
-
const idx = Number(btn.dataset.idx || "0");
|
|
1535
|
-
btn.setAttribute("aria-selected", idx === current ? "true" : "false");
|
|
1536
|
-
}
|
|
1537
|
-
// Update timeline selection
|
|
1538
|
-
if (timelineTrack) {
|
|
1539
|
-
const bars = timelineTrack.querySelectorAll(".timeline-bar");
|
|
1540
|
-
bars.forEach((bar, idx) => bar.classList.toggle("selected", idx === current));
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
function escapeHtml(s) {
|
|
1545
|
-
return (s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
function renderFrame(idx) {
|
|
1549
|
-
const f = frames[idx];
|
|
1550
|
-
if (!f) return;
|
|
1551
|
-
|
|
1552
|
-
// Render snapshot tab
|
|
1553
|
-
const tpl = document.getElementById("tpl-" + f.id);
|
|
1554
|
-
if (tpl && tpl.content) {
|
|
1555
|
-
viewerEl.innerHTML = "";
|
|
1556
|
-
viewerEl.appendChild(tpl.content.cloneNode(true));
|
|
1557
|
-
} else if (tpl) {
|
|
1558
|
-
viewerEl.innerHTML = tpl.innerHTML || "";
|
|
1559
|
-
} else {
|
|
1560
|
-
viewerEl.textContent = "(missing template)";
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// Render call tab
|
|
1564
|
-
if (callDetails) {
|
|
1565
|
-
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>';
|
|
1566
|
-
html += '<div class="call-row"><span class="call-key">index</span><span class="call-value mono">' + (idx + 1) + '</span></div>';
|
|
1567
|
-
html += '<div class="call-row"><span class="call-key">time</span><span class="call-value mono">' + f.atSeconds.toFixed(3) + 's</span></div>';
|
|
1568
|
-
if (f.stepInfo?.durationMs !== undefined) {
|
|
1569
|
-
html += '<div class="call-row"><span class="call-key">duration</span><span class="call-value mono">' + f.stepInfo.durationMs + 'ms</span></div>';
|
|
1570
|
-
}
|
|
1571
|
-
if (f.stepInfo?.params) {
|
|
1572
|
-
Object.entries(f.stepInfo.params).forEach(([key, value]) => {
|
|
1573
|
-
const val = typeof value === "string" ? value : JSON.stringify(value);
|
|
1574
|
-
html += '<div class="call-row"><span class="call-key">' + escapeHtml(key) + '</span><span class="call-value mono">' + escapeHtml(val) + '</span></div>';
|
|
1575
|
-
});
|
|
1576
|
-
}
|
|
1577
|
-
callDetails.innerHTML = html;
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// Render errors tab
|
|
1581
|
-
if (errorContent) {
|
|
1582
|
-
if (f.stepInfo && !f.stepInfo.ok && f.stepInfo.error) {
|
|
1583
|
-
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>';
|
|
1584
|
-
if (errorsTab) errorsTab.classList.add("has-error");
|
|
1585
|
-
} else {
|
|
1586
|
-
errorContent.innerHTML = '<div class="muted" style="padding: 12px;">No errors for this step.</div>';
|
|
1587
|
-
if (errorsTab) errorsTab.classList.remove("has-error");
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
// Render diff tab
|
|
1592
|
-
if (diffPrev && diffCurr) {
|
|
1593
|
-
const prevTpl = document.getElementById("prev-" + f.id);
|
|
1594
|
-
if (prevTpl && prevTpl.content) {
|
|
1595
|
-
diffPrev.innerHTML = "";
|
|
1596
|
-
diffPrev.appendChild(prevTpl.content.cloneNode(true));
|
|
1597
|
-
} else if (prevTpl) {
|
|
1598
|
-
diffPrev.innerHTML = prevTpl.innerHTML || "";
|
|
1599
|
-
} else {
|
|
1600
|
-
diffPrev.textContent = "(first frame - no previous)";
|
|
1601
|
-
}
|
|
1602
|
-
if (tpl && tpl.content) {
|
|
1603
|
-
diffCurr.innerHTML = "";
|
|
1604
|
-
diffCurr.appendChild(tpl.content.cloneNode(true));
|
|
1605
|
-
} else if (tpl) {
|
|
1606
|
-
diffCurr.innerHTML = tpl.innerHTML || "";
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
titleEl.textContent = (idx + 1) + ". t=" + f.atSeconds.toFixed(3) + "s — " + f.label;
|
|
1611
|
-
const bits = [];
|
|
1612
|
-
if (f.kind) bits.push("kind=" + f.kind);
|
|
1613
|
-
if (typeof f.changedCount === "number") bits.push("changed=" + f.changedCount);
|
|
1614
|
-
if (f.stepInfo && typeof f.stepInfo.ok === "boolean") bits.push("ok=" + String(f.stepInfo.ok));
|
|
1615
|
-
subEl.textContent = bits.join(" ");
|
|
1616
|
-
|
|
1617
|
-
// Auto-switch to errors tab if step failed
|
|
1618
|
-
if (f.stepInfo && !f.stepInfo.ok && activeTab === "snapshot") {
|
|
1619
|
-
switchTab("errors");
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
function select(idx, updateHash) {
|
|
1624
|
-
current = Math.max(0, Math.min(buttons.length - 1, idx));
|
|
1625
|
-
updateSelected();
|
|
1626
|
-
renderFrame(current);
|
|
1627
|
-
if (updateHash) location.hash = frames[current]?.id ? "#" + frames[current].id : "";
|
|
1628
|
-
// Scroll button into view
|
|
1629
|
-
if (buttons[current]) buttons[current].scrollIntoView({ block: "nearest" });
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
function selectById(id) {
|
|
1633
|
-
const idx = idToIndex.get(id);
|
|
1634
|
-
if (typeof idx === "number") select(idx, false);
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
for (const btn of buttons) {
|
|
1638
|
-
btn.addEventListener("click", function () {
|
|
1639
|
-
select(Number(btn.dataset.idx || "0"), true);
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
modeAll.addEventListener("click", () => setMode("all"));
|
|
1644
|
-
modeChanged.addEventListener("click", () => setMode("changed"));
|
|
1645
|
-
modeMarks.addEventListener("click", () => setMode("marks"));
|
|
1646
|
-
modeFailed.addEventListener("click", () => setMode("failed"));
|
|
1647
|
-
searchEl.addEventListener("input", applyFilter);
|
|
1648
|
-
|
|
1649
|
-
window.addEventListener("hashchange", function () {
|
|
1650
|
-
const id = (location.hash || "").replace(/^#/, "");
|
|
1651
|
-
if (id) selectById(id);
|
|
1652
|
-
});
|
|
1653
|
-
|
|
1654
|
-
document.addEventListener("keydown", function (e) {
|
|
1655
|
-
const tag = (document.activeElement && document.activeElement.tagName) || "";
|
|
1656
|
-
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
1657
|
-
|
|
1658
|
-
// Navigation
|
|
1659
|
-
if (e.key === "ArrowDown" || e.key === "j") {
|
|
1660
|
-
e.preventDefault();
|
|
1661
|
-
let next = current + 1;
|
|
1662
|
-
while (next < buttons.length && buttons[next].parentElement.style.display === "none") next += 1;
|
|
1663
|
-
if (next < buttons.length) select(next, true);
|
|
1664
|
-
} else if (e.key === "ArrowUp" || e.key === "k") {
|
|
1665
|
-
e.preventDefault();
|
|
1666
|
-
let next = current - 1;
|
|
1667
|
-
while (next >= 0 && buttons[next].parentElement.style.display === "none") next -= 1;
|
|
1668
|
-
if (next >= 0) select(next, true);
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
// Tab switching with number keys
|
|
1672
|
-
if (e.key === "1") switchTab("snapshot");
|
|
1673
|
-
if (e.key === "2") switchTab("call");
|
|
1674
|
-
if (e.key === "3") switchTab("errors");
|
|
1675
|
-
if (e.key === "4") switchTab("diff");
|
|
1676
|
-
});
|
|
1677
|
-
|
|
1678
|
-
// Initial frame: prefer hash, otherwise first failing, otherwise final.
|
|
1679
|
-
const hashId = (location.hash || "").replace(/^#/, "");
|
|
1680
|
-
if (hashId) {
|
|
1681
|
-
selectById(hashId);
|
|
1682
|
-
} else {
|
|
1683
|
-
const firstFail = buttons.findIndex((b) => b.dataset.ok === "false");
|
|
1684
|
-
if (firstFail >= 0) select(firstFail, false);
|
|
1685
|
-
else select(buttons.length - 1, false);
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
applyFilter();
|
|
1689
|
-
})();
|
|
1690
|
-
</script>
|
|
1691
|
-
</section>
|
|
1692
|
-
<section class="summary">
|
|
1693
|
-
<h2>Summary</h2>
|
|
1694
|
-
<pre>${escapeHtml(
|
|
1695
|
-
[
|
|
1696
|
-
`result=${resultLabel}`,
|
|
1697
|
-
`marks=${markFrames.length}`,
|
|
1698
|
-
`frames=${input.frames.length}`,
|
|
1699
|
-
`duration=${durationSeconds.toFixed(3)}s`,
|
|
1700
|
-
input.result?.ok === false && input.result.error ? `error=${input.result.error}` : null,
|
|
1701
|
-
input.result?.ok === false && input.result.failureStep
|
|
1702
|
-
? `failedStep=${input.result.failureStep.index} ${input.result.failureStep.type}`
|
|
1703
|
-
: null,
|
|
1704
|
-
]
|
|
1705
|
-
.filter(Boolean)
|
|
1706
|
-
.join("\n"),
|
|
1707
|
-
)}</pre>
|
|
1708
|
-
</section>
|
|
1709
|
-
</main>
|
|
1710
|
-
</body>
|
|
1711
|
-
</html>`;
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
function jsonForHtml(data: unknown): string {
|
|
1715
|
-
return JSON.stringify(data).replaceAll("<", "\\u003c");
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
function renderSnapshotViewHtml(options: {
|
|
1719
|
-
terminal: Terminal;
|
|
1720
|
-
sessionId: string;
|
|
1721
|
-
scope: SnapshotScope;
|
|
1722
|
-
hash: string;
|
|
1723
|
-
lines: string[];
|
|
1724
|
-
meta: TerminalMeta;
|
|
1725
|
-
lineNumbers: boolean;
|
|
1726
|
-
changedLines: Set<number>;
|
|
1727
|
-
trimRight: boolean;
|
|
1728
|
-
}): string {
|
|
1729
|
-
const headerLine = formatHeaderLine({
|
|
1730
|
-
sessionId: options.sessionId,
|
|
1731
|
-
scope: options.scope,
|
|
1732
|
-
hash: options.hash,
|
|
1733
|
-
meta: options.meta,
|
|
1734
|
-
changedCount: options.changedLines.size,
|
|
1735
|
-
});
|
|
1736
|
-
|
|
1737
|
-
const digits = Math.max(2, String(options.lines.length).length);
|
|
1738
|
-
const out: string[] = [`<span class="headerblock">${escapeHtml(headerLine)}</span>`];
|
|
1739
|
-
|
|
1740
|
-
if (options.scope === "visible") {
|
|
1741
|
-
for (let i = 0; i < options.lines.length; i += 1) {
|
|
1742
|
-
const n = i + 1;
|
|
1743
|
-
const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
|
|
1744
|
-
const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
|
|
1745
|
-
|
|
1746
|
-
const contentHtml = renderVisibleRowHtml(options.terminal, i, options.trimRight);
|
|
1747
|
-
const rowClass = options.changedLines.has(i) ? "row changed" : "row";
|
|
1748
|
-
out.push(`<span class="${rowClass}">${prefixHtml}${contentHtml}</span>`);
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
return out.join("");
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
// buffer scope: currently renders plain text only
|
|
1755
|
-
for (let i = 0; i < options.lines.length; i += 1) {
|
|
1756
|
-
const n = i + 1;
|
|
1757
|
-
const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
|
|
1758
|
-
const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
|
|
1759
|
-
out.push(`<span class="row">${prefixHtml}${escapeHtml(options.lines[i] ?? "")}</span>`);
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
return out.join("");
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
function renderVisibleRowHtml(terminal: Terminal, rowIndex: number, trimRight: boolean): string {
|
|
1766
|
-
const buffer = terminal.buffer.active;
|
|
1767
|
-
const nullCell = buffer.getNullCell();
|
|
1768
|
-
|
|
1769
|
-
const startY = buffer.viewportY;
|
|
1770
|
-
const line = buffer.getLine(startY + rowIndex);
|
|
1771
|
-
const endCol = trimRight ? findMeaningfulEndCol(line, terminal.cols, nullCell) : terminal.cols;
|
|
1772
|
-
|
|
1773
|
-
type Segment = { key: string; style: CellStyle; text: string };
|
|
1774
|
-
|
|
1775
|
-
const segments: Segment[] = [];
|
|
1776
|
-
|
|
1777
|
-
let currentKey: string | null = null;
|
|
1778
|
-
let currentStyle: CellStyle | null = null;
|
|
1779
|
-
let currentText = "";
|
|
1780
|
-
|
|
1781
|
-
const flush = () => {
|
|
1782
|
-
if (!currentStyle) return;
|
|
1783
|
-
if (currentText.length === 0) return;
|
|
1784
|
-
segments.push({
|
|
1785
|
-
key: currentKey ?? styleKey(currentStyle),
|
|
1786
|
-
style: currentStyle,
|
|
1787
|
-
text: currentText,
|
|
1788
|
-
});
|
|
1789
|
-
currentText = "";
|
|
1790
|
-
};
|
|
1791
|
-
|
|
1792
|
-
for (let x = 0; x < endCol; x += 1) {
|
|
1793
|
-
const cell = line?.getCell(x, nullCell);
|
|
1794
|
-
if (!cell) {
|
|
1795
|
-
if (currentStyle) {
|
|
1796
|
-
flush();
|
|
1797
|
-
currentStyle = null;
|
|
1798
|
-
currentKey = null;
|
|
1799
|
-
}
|
|
1800
|
-
continue;
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
const width = cell.getWidth();
|
|
1804
|
-
if (width === 0) {
|
|
1805
|
-
continue;
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
const chars = cell.getChars() || " ";
|
|
1809
|
-
const style = extractStyle(cell);
|
|
1810
|
-
const key = styleKey(style);
|
|
1811
|
-
|
|
1812
|
-
if (!currentStyle) {
|
|
1813
|
-
currentStyle = style;
|
|
1814
|
-
currentKey = key;
|
|
1815
|
-
currentText = chars;
|
|
1816
|
-
continue;
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
if (key === currentKey) {
|
|
1820
|
-
currentText += chars;
|
|
1821
|
-
continue;
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
flush();
|
|
1825
|
-
currentStyle = style;
|
|
1826
|
-
currentKey = key;
|
|
1827
|
-
currentText = chars;
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
if (currentStyle) {
|
|
1831
|
-
flush();
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
return segments.map((segment) => renderSegmentHtml(segment.text, segment.style)).join("");
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
function renderSegmentHtml(text: string, style: CellStyle): string {
|
|
1838
|
-
const safeText = escapeHtml(text);
|
|
1839
|
-
|
|
1840
|
-
if (isDefaultStyle(style)) {
|
|
1841
|
-
return safeText;
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
const css = styleToCss(style);
|
|
1845
|
-
if (!css) {
|
|
1846
|
-
return `<span class="seg">${safeText}</span>`;
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
return `<span class="seg" style="${css}">${safeText}</span>`;
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
function styleToCss(style: CellStyle): string {
|
|
1853
|
-
let fg = colorToCss(style.fg);
|
|
1854
|
-
let bg = colorToCss(style.bg);
|
|
1855
|
-
|
|
1856
|
-
if (style.inverse) {
|
|
1857
|
-
const tmp = fg;
|
|
1858
|
-
fg = bg;
|
|
1859
|
-
bg = tmp;
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
const decls: string[] = [];
|
|
1863
|
-
|
|
1864
|
-
if (fg) decls.push(`color: ${fg}`);
|
|
1865
|
-
if (bg) decls.push(`background-color: ${bg}`);
|
|
1866
|
-
|
|
1867
|
-
if (style.bold) decls.push("font-weight: 600");
|
|
1868
|
-
if (style.italic) decls.push("font-style: italic");
|
|
1869
|
-
if (style.dim) decls.push("opacity: 0.75");
|
|
1870
|
-
|
|
1871
|
-
const decorations: string[] = [];
|
|
1872
|
-
if (style.underline) decorations.push("underline");
|
|
1873
|
-
if (style.strikethrough) decorations.push("line-through");
|
|
1874
|
-
if (decorations.length > 0) {
|
|
1875
|
-
decls.push(`text-decoration: ${decorations.join(" ")}`);
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
return decls.join("; ");
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
function colorToCss(color: Color): string | null {
|
|
1882
|
-
if (color.mode === "default") return null;
|
|
1883
|
-
|
|
1884
|
-
if (color.mode === "rgb") {
|
|
1885
|
-
const value = color.value & 0xffffff;
|
|
1886
|
-
return `#${value.toString(16).padStart(6, "0")}`;
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
const idx = clampInt(color.value, 0, 255);
|
|
1890
|
-
return xterm256Color(idx);
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
function xterm256Color(idx: number): string {
|
|
1894
|
-
const table16 = [
|
|
1895
|
-
"#000000",
|
|
1896
|
-
"#800000",
|
|
1897
|
-
"#008000",
|
|
1898
|
-
"#808000",
|
|
1899
|
-
"#000080",
|
|
1900
|
-
"#800080",
|
|
1901
|
-
"#008080",
|
|
1902
|
-
"#c0c0c0",
|
|
1903
|
-
"#808080",
|
|
1904
|
-
"#ff0000",
|
|
1905
|
-
"#00ff00",
|
|
1906
|
-
"#ffff00",
|
|
1907
|
-
"#0000ff",
|
|
1908
|
-
"#ff00ff",
|
|
1909
|
-
"#00ffff",
|
|
1910
|
-
"#ffffff",
|
|
1911
|
-
];
|
|
1912
|
-
|
|
1913
|
-
if (idx < 16) return table16[idx] ?? "#000000";
|
|
1914
|
-
|
|
1915
|
-
if (idx >= 16 && idx <= 231) {
|
|
1916
|
-
const c = [0, 95, 135, 175, 215, 255];
|
|
1917
|
-
const n = idx - 16;
|
|
1918
|
-
|
|
1919
|
-
const r = c[Math.trunc(n / 36) % 6] ?? 0;
|
|
1920
|
-
const g = c[Math.trunc(n / 6) % 6] ?? 0;
|
|
1921
|
-
const b = c[n % 6] ?? 0;
|
|
1922
|
-
|
|
1923
|
-
return `rgb(${r} ${g} ${b})`;
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
const gray = 8 + (idx - 232) * 10;
|
|
1927
|
-
const v = clampInt(gray, 0, 255);
|
|
1928
|
-
return `rgb(${v} ${v} ${v})`;
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
function diffLineIndices(previous: string[], next: string[]): Set<number> {
|
|
1932
|
-
const out = new Set<number>();
|
|
1933
|
-
const max = Math.max(previous.length, next.length);
|
|
1934
|
-
|
|
1935
|
-
for (let i = 0; i < max; i += 1) {
|
|
1936
|
-
const a = previous[i] ?? "";
|
|
1937
|
-
const b = next[i] ?? "";
|
|
1938
|
-
if (a !== b) out.add(i);
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
return out;
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
function formatHeaderLine(input: {
|
|
1945
|
-
sessionId: string;
|
|
1946
|
-
scope: SnapshotScope;
|
|
1947
|
-
hash: string;
|
|
1948
|
-
meta: TerminalMeta;
|
|
1949
|
-
changedCount: number;
|
|
1950
|
-
}): string {
|
|
1951
|
-
const cursorAbsY = input.meta.baseY + input.meta.cursorY;
|
|
1952
|
-
const cursorViewportRow = cursorAbsY - input.meta.viewportY;
|
|
1953
|
-
const cursorViewportCol = input.meta.cursorX;
|
|
1954
|
-
|
|
1955
|
-
return [
|
|
1956
|
-
`session=${input.sessionId}`,
|
|
1957
|
-
`scope=${input.scope}`,
|
|
1958
|
-
`size=${input.meta.cols}x${input.meta.rows}`,
|
|
1959
|
-
`buffer=${input.meta.bufferType}`,
|
|
1960
|
-
`cursor=${cursorViewportCol + 1},${cursorViewportRow + 1}`,
|
|
1961
|
-
`hash=${input.hash}`,
|
|
1962
|
-
`changed=${input.changedCount}`,
|
|
1963
|
-
].join(" ");
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
function coerceDisplayString(value: unknown): string {
|
|
1967
|
-
if (value === null || value === undefined) return "";
|
|
1968
|
-
if (typeof value === "string") return value;
|
|
1969
|
-
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1970
|
-
try {
|
|
1971
|
-
return JSON.stringify(value) ?? "";
|
|
1972
|
-
} catch {
|
|
1973
|
-
return "";
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
function escapeHtml(text: string): string {
|
|
1978
|
-
return text
|
|
1979
|
-
.replaceAll("&", "&")
|
|
1980
|
-
.replaceAll("<", "<")
|
|
1981
|
-
.replaceAll(">", ">")
|
|
1982
|
-
.replaceAll('"', """)
|
|
1983
|
-
.replaceAll("'", "'");
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
function getMeta(terminal: Terminal): TerminalMeta {
|
|
1987
|
-
const buffer = terminal.buffer.active;
|
|
1988
|
-
return {
|
|
1989
|
-
cols: terminal.cols,
|
|
1990
|
-
rows: terminal.rows,
|
|
1991
|
-
bufferType: buffer.type,
|
|
1992
|
-
viewportY: buffer.viewportY,
|
|
1993
|
-
baseY: buffer.baseY,
|
|
1994
|
-
length: buffer.length,
|
|
1995
|
-
cursorX: buffer.cursorX,
|
|
1996
|
-
cursorY: buffer.cursorY,
|
|
1997
|
-
};
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
function parseAsciicast(cast: string): ParsedAsciicast {
|
|
2001
|
-
const lines = cast.trimEnd().split("\n");
|
|
2002
|
-
const header = safeJsonObject(lines[0]);
|
|
2003
|
-
|
|
2004
|
-
const events: AsciicastEvent[] = [];
|
|
2005
|
-
for (const line of lines.slice(1)) {
|
|
2006
|
-
if (!line.trim()) continue;
|
|
2007
|
-
const value = JSON.parse(line) as unknown;
|
|
2008
|
-
if (!Array.isArray(value) || value.length < 3) continue;
|
|
2009
|
-
|
|
2010
|
-
const time = Number(value[0]);
|
|
2011
|
-
const type = String(value[1]);
|
|
2012
|
-
const data = String(value[2]);
|
|
2013
|
-
|
|
2014
|
-
if (!Number.isFinite(time)) continue;
|
|
2015
|
-
|
|
2016
|
-
if (type === "o" || type === "i" || type === "r" || type === "m") {
|
|
2017
|
-
events.push([time, type, data] as AsciicastEvent);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
return { header, events };
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
function safeJsonObject(line: string | undefined): Record<string, unknown> {
|
|
2025
|
-
if (!line) return {};
|
|
2026
|
-
try {
|
|
2027
|
-
const value = JSON.parse(line) as unknown;
|
|
2028
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
2029
|
-
? (value as Record<string, unknown>)
|
|
2030
|
-
: {};
|
|
2031
|
-
} catch {
|
|
2032
|
-
return {};
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
function getTermInfo(header: Record<string, unknown>): {
|
|
2037
|
-
cols: number;
|
|
2038
|
-
rows: number;
|
|
2039
|
-
type: string;
|
|
2040
|
-
} {
|
|
2041
|
-
const version = Number(header.version ?? 2);
|
|
2042
|
-
|
|
2043
|
-
if (version === 3) {
|
|
2044
|
-
const term = header.term as { cols?: unknown; rows?: unknown; type?: unknown } | undefined;
|
|
2045
|
-
const cols = clampInt(Number(term?.cols ?? 80), 1, 500);
|
|
2046
|
-
const rows = clampInt(Number(term?.rows ?? 24), 1, 300);
|
|
2047
|
-
const type = typeof term?.type === "string" ? term.type : "xterm-256color";
|
|
2048
|
-
return { cols, rows, type };
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
const cols = clampInt(Number(header.width ?? 80), 1, 500);
|
|
2052
|
-
const rows = clampInt(Number(header.height ?? 24), 1, 300);
|
|
2053
|
-
const type = typeof header.term === "string" ? header.term : "xterm-256color";
|
|
2054
|
-
return { cols, rows, type };
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
function parseResize(value: string): { cols: number; rows: number } | null {
|
|
2058
|
-
const match = /^\s*(\d+)x(\d+)\s*$/.exec(value);
|
|
2059
|
-
if (!match) return null;
|
|
2060
|
-
|
|
2061
|
-
const cols = clampInt(Number(match[1] ?? 0), 1, 500);
|
|
2062
|
-
const rows = clampInt(Number(match[2] ?? 0), 1, 300);
|
|
2063
|
-
return { cols, rows };
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
function clampInt(value: number, min: number, max: number): number {
|
|
2067
|
-
if (!Number.isFinite(value)) return min;
|
|
2068
|
-
const int = Math.trunc(value);
|
|
2069
|
-
if (int < min) return min;
|
|
2070
|
-
if (int > max) return max;
|
|
2071
|
-
return int;
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
if (import.meta.main) {
|
|
2075
|
-
const inputPath = process.argv[2];
|
|
2076
|
-
if (!inputPath) {
|
|
2077
|
-
console.error("Usage: bun run src/trace/report.ts <path/to/cast>");
|
|
2078
|
-
process.exit(2);
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
const cast = await Bun.file(inputPath).text();
|
|
2082
|
-
const html = await generateTraceReportHtml(cast);
|
|
2083
|
-
|
|
2084
|
-
const dir = dirname(inputPath);
|
|
2085
|
-
const base = basename(inputPath, extname(inputPath));
|
|
2086
|
-
const outPath = join(dir, `${base}.report.html`);
|
|
2087
|
-
|
|
2088
|
-
writeFileSync(outPath, html);
|
|
2089
|
-
ensureAsciinemaPlayerAssets(outPath);
|
|
2090
|
-
// eslint-disable-next-line no-console
|
|
2091
|
-
console.log(outPath);
|
|
2092
|
-
}
|