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.
Files changed (67) hide show
  1. package/README.md +318 -1
  2. package/dist/agent.mjs +2 -0
  3. package/dist/bin/ptywright.mjs +6 -0
  4. package/dist/cli-CfvlbRoZ.mjs +3585 -0
  5. package/dist/cli.mjs +2 -0
  6. package/{src/index.ts → dist/index.mjs} +7 -9
  7. package/dist/mcp.mjs +2 -0
  8. package/dist/pty-cassette.mjs +24 -0
  9. package/dist/pty_like-Cpkh_O9B.mjs +404 -0
  10. package/dist/runner-zApMYWZx.mjs +3257 -0
  11. package/dist/runner-zi0nItvB.mjs +1874 -0
  12. package/dist/script.mjs +2 -0
  13. package/dist/server-BC3yo-dq.mjs +3068 -0
  14. package/dist/session.mjs +2 -0
  15. package/dist/terminal_session-DopC7Xg6.mjs +893 -0
  16. package/package.json +28 -21
  17. package/schemas/ptywright-agent-cassette.schema.json +57 -0
  18. package/schemas/ptywright-agent-check.schema.json +122 -0
  19. package/schemas/ptywright-agent-manifest.schema.json +107 -0
  20. package/schemas/ptywright-agent-promote.schema.json +146 -0
  21. package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
  22. package/schemas/ptywright-agent-run.schema.json +126 -0
  23. package/schemas/ptywright-agent.schema.json +166 -0
  24. package/schemas/ptywright-pty-cassette.schema.json +86 -0
  25. package/schemas/ptywright-script-manifest.schema.json +75 -0
  26. package/schemas/ptywright-script-run-summary.schema.json +114 -0
  27. package/schemas/ptywright-script.schema.json +55 -3
  28. package/bin/ptywright +0 -4
  29. package/src/cli.ts +0 -414
  30. package/src/generator/doc_parser.ts +0 -341
  31. package/src/generator/generate.ts +0 -161
  32. package/src/generator/index.ts +0 -10
  33. package/src/generator/script_generator.ts +0 -209
  34. package/src/generator/step_extractor.ts +0 -397
  35. package/src/mcp/http_server.ts +0 -174
  36. package/src/mcp/script_recording.ts +0 -238
  37. package/src/mcp/server.ts +0 -1348
  38. package/src/pty/bun_pty_adapter.ts +0 -34
  39. package/src/pty/bun_terminal_adapter.ts +0 -149
  40. package/src/pty/pty_adapter.ts +0 -31
  41. package/src/script/dsl.ts +0 -188
  42. package/src/script/module.ts +0 -43
  43. package/src/script/path.ts +0 -151
  44. package/src/script/run.ts +0 -108
  45. package/src/script/run_all.ts +0 -229
  46. package/src/script/runner.ts +0 -983
  47. package/src/script/schema.ts +0 -237
  48. package/src/script/steps/assert_snapshot_equals.ts +0 -21
  49. package/src/script/steps/index.ts +0 -2
  50. package/src/script/suite_report.ts +0 -626
  51. package/src/session/session_manager.ts +0 -145
  52. package/src/session/terminal_session.ts +0 -473
  53. package/src/terminal/ansi.ts +0 -142
  54. package/src/terminal/keys.ts +0 -180
  55. package/src/terminal/mask.ts +0 -70
  56. package/src/terminal/mouse.ts +0 -75
  57. package/src/terminal/snapshot.ts +0 -196
  58. package/src/terminal/style.ts +0 -121
  59. package/src/terminal/view.ts +0 -49
  60. package/src/trace/asciicast.ts +0 -20
  61. package/src/trace/asciinema_player_assets.ts +0 -44
  62. package/src/trace/cast_to_txt.ts +0 -116
  63. package/src/trace/recorder.ts +0 -110
  64. package/src/trace/report.ts +0 -2092
  65. package/src/types.ts +0 -86
  66. package/src/util/hash.ts +0 -8
  67. package/src/util/sleep.ts +0 -5
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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("&", "&amp;")
1980
- .replaceAll("<", "&lt;")
1981
- .replaceAll(">", "&gt;")
1982
- .replaceAll('"', "&quot;")
1983
- .replaceAll("'", "&#39;");
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
- }