@automaze/proof 0.20260311.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/LICENSE +202 -0
- package/README.md +221 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +934 -0
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +200 -0
- package/dist/detect.d.ts +2 -0
- package/dist/detect.js +31 -0
- package/dist/detect.test.d.ts +1 -0
- package/dist/detect.test.js +54 -0
- package/dist/duration.d.ts +1 -0
- package/dist/duration.js +23 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +843 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +261 -0
- package/dist/modes/terminal.d.ts +5 -0
- package/dist/modes/terminal.js +287 -0
- package/dist/modes/visual.d.ts +10 -0
- package/dist/modes/visual.js +165 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +196 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { Proof } from "./index";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { mkdtemp, readFile, rm } from "fs/promises";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
describe("Proof", () => {
|
|
8
|
+
let tempDir;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tempDir = await mkdtemp(join(tmpdir(), "proof-test-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
describe("constructor", () => {
|
|
16
|
+
test("creates run directory path from appName, date, and run", () => {
|
|
17
|
+
const proof = new Proof({
|
|
18
|
+
appName: "test-app",
|
|
19
|
+
proofDir: tempDir,
|
|
20
|
+
run: "my-run",
|
|
21
|
+
});
|
|
22
|
+
expect(proof).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
test("uses HHMM as default run name", () => {
|
|
25
|
+
const proof = new Proof({
|
|
26
|
+
appName: "test-app",
|
|
27
|
+
proofDir: tempDir,
|
|
28
|
+
});
|
|
29
|
+
expect(proof).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("capture (terminal mode)", () => {
|
|
33
|
+
test("produces .cast and .html files", async () => {
|
|
34
|
+
const proof = new Proof({
|
|
35
|
+
appName: "test-app",
|
|
36
|
+
proofDir: tempDir,
|
|
37
|
+
run: "test-run",
|
|
38
|
+
});
|
|
39
|
+
const recording = await proof.capture({
|
|
40
|
+
command: `bun test ${join(import.meta.dir, "../test-app/cli/app.test.ts")}`,
|
|
41
|
+
mode: "terminal",
|
|
42
|
+
label: "cli",
|
|
43
|
+
description: "CLI tests",
|
|
44
|
+
});
|
|
45
|
+
expect(recording.mode).toBe("terminal");
|
|
46
|
+
expect(recording.path).toEndWith(".html");
|
|
47
|
+
expect(recording.duration).toBeGreaterThan(0);
|
|
48
|
+
expect(existsSync(recording.path)).toBe(true);
|
|
49
|
+
const castPath = recording.path.replace(".html", ".cast");
|
|
50
|
+
expect(existsSync(castPath)).toBe(true);
|
|
51
|
+
const castContent = await readFile(castPath, "utf-8");
|
|
52
|
+
const lines = castContent.trim().split("\n");
|
|
53
|
+
const header = JSON.parse(lines[0]);
|
|
54
|
+
expect(header.version).toBe(2);
|
|
55
|
+
expect(header.width).toBe(120);
|
|
56
|
+
expect(header.height).toBe(30);
|
|
57
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
58
|
+
const event = JSON.parse(lines[1]);
|
|
59
|
+
expect(event).toHaveLength(3);
|
|
60
|
+
expect(typeof event[0]).toBe("number");
|
|
61
|
+
expect(event[1]).toBe("o");
|
|
62
|
+
expect(typeof event[2]).toBe("string");
|
|
63
|
+
});
|
|
64
|
+
test("HTML player is self-contained", async () => {
|
|
65
|
+
const proof = new Proof({
|
|
66
|
+
appName: "test-app",
|
|
67
|
+
proofDir: tempDir,
|
|
68
|
+
run: "test-run",
|
|
69
|
+
});
|
|
70
|
+
const recording = await proof.capture({
|
|
71
|
+
command: `bun test ${join(import.meta.dir, "../test-app/cli/app.test.ts")}`,
|
|
72
|
+
mode: "terminal",
|
|
73
|
+
label: "cli",
|
|
74
|
+
});
|
|
75
|
+
const html = await readFile(recording.path, "utf-8");
|
|
76
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
77
|
+
expect(html).toContain("ansiToHtml");
|
|
78
|
+
expect(html).toContain("<select");
|
|
79
|
+
expect(html).toContain("0.1x");
|
|
80
|
+
expect(html).toContain("4x");
|
|
81
|
+
expect(html).not.toContain("<script src=");
|
|
82
|
+
expect(html).not.toContain("<link rel=");
|
|
83
|
+
});
|
|
84
|
+
test("works with any command, not just bun test", async () => {
|
|
85
|
+
const proof = new Proof({
|
|
86
|
+
appName: "test-app",
|
|
87
|
+
proofDir: tempDir,
|
|
88
|
+
run: "test-run",
|
|
89
|
+
});
|
|
90
|
+
const recording = await proof.capture({
|
|
91
|
+
command: "echo hello && echo world",
|
|
92
|
+
mode: "terminal",
|
|
93
|
+
label: "echo",
|
|
94
|
+
});
|
|
95
|
+
expect(recording.mode).toBe("terminal");
|
|
96
|
+
const castContent = await readFile(recording.path.replace(".html", ".cast"), "utf-8");
|
|
97
|
+
expect(castContent).toContain("hello");
|
|
98
|
+
expect(castContent).toContain("world");
|
|
99
|
+
});
|
|
100
|
+
test("throws if command is missing", async () => {
|
|
101
|
+
const proof = new Proof({
|
|
102
|
+
appName: "test-app",
|
|
103
|
+
proofDir: tempDir,
|
|
104
|
+
run: "test-run",
|
|
105
|
+
});
|
|
106
|
+
expect(proof.capture({ mode: "terminal", label: "fail" })).rejects.toThrow("terminal mode requires command");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("capture (browser mode)", () => {
|
|
110
|
+
test("throws if testFile is missing", async () => {
|
|
111
|
+
const proof = new Proof({
|
|
112
|
+
appName: "test-app",
|
|
113
|
+
proofDir: tempDir,
|
|
114
|
+
run: "test-run",
|
|
115
|
+
});
|
|
116
|
+
expect(proof.capture({ mode: "browser", label: "fail" })).rejects.toThrow("browser mode requires testFile");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("manifest", () => {
|
|
120
|
+
test("creates proof.json with entries on capture", async () => {
|
|
121
|
+
const proof = new Proof({
|
|
122
|
+
appName: "test-app",
|
|
123
|
+
proofDir: tempDir,
|
|
124
|
+
run: "test-run",
|
|
125
|
+
});
|
|
126
|
+
await proof.capture({
|
|
127
|
+
command: `bun test ${join(import.meta.dir, "../test-app/cli/app.test.ts")}`,
|
|
128
|
+
mode: "terminal",
|
|
129
|
+
label: "first",
|
|
130
|
+
description: "First capture",
|
|
131
|
+
});
|
|
132
|
+
const today = new Date();
|
|
133
|
+
const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
|
134
|
+
const manifestPath = join(tempDir, "test-app", dateStr, "test-run", "proof.json");
|
|
135
|
+
expect(existsSync(manifestPath)).toBe(true);
|
|
136
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
137
|
+
expect(manifest.version).toBe(1);
|
|
138
|
+
expect(manifest.appName).toBe("test-app");
|
|
139
|
+
expect(manifest.run).toBe("test-run");
|
|
140
|
+
expect(manifest.entries).toHaveLength(1);
|
|
141
|
+
expect(manifest.entries[0].label).toBe("first");
|
|
142
|
+
expect(manifest.entries[0].description).toBe("First capture");
|
|
143
|
+
expect(manifest.entries[0].mode).toBe("terminal");
|
|
144
|
+
expect(manifest.entries[0].command).toContain("bun test");
|
|
145
|
+
});
|
|
146
|
+
test("appends entries on subsequent captures", async () => {
|
|
147
|
+
const proof = new Proof({
|
|
148
|
+
appName: "test-app",
|
|
149
|
+
proofDir: tempDir,
|
|
150
|
+
run: "test-run",
|
|
151
|
+
});
|
|
152
|
+
const testCmd = `bun test ${join(import.meta.dir, "../test-app/cli/app.test.ts")}`;
|
|
153
|
+
await proof.capture({
|
|
154
|
+
command: testCmd,
|
|
155
|
+
mode: "terminal",
|
|
156
|
+
label: "first",
|
|
157
|
+
});
|
|
158
|
+
await proof.capture({
|
|
159
|
+
command: testCmd,
|
|
160
|
+
mode: "terminal",
|
|
161
|
+
label: "second",
|
|
162
|
+
});
|
|
163
|
+
const today = new Date();
|
|
164
|
+
const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
|
165
|
+
const manifestPath = join(tempDir, "test-app", dateStr, "test-run", "proof.json");
|
|
166
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
167
|
+
expect(manifest.entries).toHaveLength(2);
|
|
168
|
+
expect(manifest.entries[0].label).toBe("first");
|
|
169
|
+
expect(manifest.entries[1].label).toBe("second");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe("report", () => {
|
|
173
|
+
test("generates report.md from manifest", async () => {
|
|
174
|
+
const proof = new Proof({
|
|
175
|
+
appName: "test-app",
|
|
176
|
+
proofDir: tempDir,
|
|
177
|
+
run: "test-run",
|
|
178
|
+
});
|
|
179
|
+
await proof.capture({
|
|
180
|
+
command: `bun test ${join(import.meta.dir, "../test-app/cli/app.test.ts")}`,
|
|
181
|
+
mode: "terminal",
|
|
182
|
+
label: "cli-tests",
|
|
183
|
+
description: "CLI test suite",
|
|
184
|
+
});
|
|
185
|
+
const reportPath = await proof.report();
|
|
186
|
+
expect(reportPath).toEndWith("report.md");
|
|
187
|
+
expect(existsSync(reportPath)).toBe(true);
|
|
188
|
+
const report = await readFile(reportPath, "utf-8");
|
|
189
|
+
expect(report).toContain("# Proof Report");
|
|
190
|
+
expect(report).toContain("test-app");
|
|
191
|
+
expect(report).toContain("test-run");
|
|
192
|
+
expect(report).toContain("CLI test suite");
|
|
193
|
+
expect(report).toContain("cli-tests");
|
|
194
|
+
expect(report).toContain("@automaze/proof");
|
|
195
|
+
});
|
|
196
|
+
test("generates html report", async () => {
|
|
197
|
+
const proof = new Proof({
|
|
198
|
+
appName: "test-app",
|
|
199
|
+
proofDir: tempDir,
|
|
200
|
+
run: "test-run",
|
|
201
|
+
});
|
|
202
|
+
await proof.capture({
|
|
203
|
+
command: `bun test ${join(import.meta.dir, "../test-app/cli/app.test.ts")}`,
|
|
204
|
+
mode: "terminal",
|
|
205
|
+
label: "cli-tests",
|
|
206
|
+
description: "CLI test suite",
|
|
207
|
+
});
|
|
208
|
+
const reportPath = await proof.report({ format: "html" });
|
|
209
|
+
expect(reportPath).toEndWith("report.html");
|
|
210
|
+
expect(existsSync(reportPath)).toBe(true);
|
|
211
|
+
const html = await readFile(reportPath, "utf-8");
|
|
212
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
213
|
+
expect(html).toContain("Proof Report");
|
|
214
|
+
expect(html).toContain("test-app");
|
|
215
|
+
expect(html).toContain("CLI test suite");
|
|
216
|
+
expect(html).toContain("<iframe");
|
|
217
|
+
});
|
|
218
|
+
test("generates archive report with inlined content", async () => {
|
|
219
|
+
const proof = new Proof({
|
|
220
|
+
appName: "test-app",
|
|
221
|
+
proofDir: tempDir,
|
|
222
|
+
run: "test-run",
|
|
223
|
+
});
|
|
224
|
+
await proof.capture({
|
|
225
|
+
command: "echo hello",
|
|
226
|
+
mode: "terminal",
|
|
227
|
+
label: "echo",
|
|
228
|
+
});
|
|
229
|
+
const reportPath = await proof.report({ format: "archive" });
|
|
230
|
+
expect(reportPath).toEndWith("archive.html");
|
|
231
|
+
expect(existsSync(reportPath)).toBe(true);
|
|
232
|
+
const html = await readFile(reportPath, "utf-8");
|
|
233
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
234
|
+
expect(html).toContain("srcdoc=");
|
|
235
|
+
});
|
|
236
|
+
test("generates multiple formats as array", async () => {
|
|
237
|
+
const proof = new Proof({
|
|
238
|
+
appName: "test-app",
|
|
239
|
+
proofDir: tempDir,
|
|
240
|
+
run: "test-run",
|
|
241
|
+
});
|
|
242
|
+
await proof.capture({
|
|
243
|
+
command: "echo hello",
|
|
244
|
+
mode: "terminal",
|
|
245
|
+
label: "echo",
|
|
246
|
+
});
|
|
247
|
+
const paths = await proof.report({ format: ["md", "html"] });
|
|
248
|
+
expect(paths).toHaveLength(2);
|
|
249
|
+
expect(paths[0]).toEndWith("report.md");
|
|
250
|
+
expect(paths[1]).toEndWith("report.html");
|
|
251
|
+
});
|
|
252
|
+
test("throws if no captures have been made", async () => {
|
|
253
|
+
const proof = new Proof({
|
|
254
|
+
appName: "test-app",
|
|
255
|
+
proofDir: tempDir,
|
|
256
|
+
run: "empty-run",
|
|
257
|
+
});
|
|
258
|
+
expect(proof.report()).rejects.toThrow("No proof.json found");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
export async function captureTerminal(options, runDir, filePrefix, command, config) {
|
|
5
|
+
const label = options.label ?? "terminal";
|
|
6
|
+
const castPath = join(runDir, `${filePrefix}.cast`);
|
|
7
|
+
const htmlPath = join(runDir, `${filePrefix}.html`);
|
|
8
|
+
const cols = config.cols ?? 120;
|
|
9
|
+
const rows = config.rows ?? 30;
|
|
10
|
+
const events = [];
|
|
11
|
+
const startTime = Date.now();
|
|
12
|
+
let exitCode = null;
|
|
13
|
+
await new Promise((resolve) => {
|
|
14
|
+
const proc = spawn("/bin/sh", ["-c", command], {
|
|
15
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
16
|
+
env: {
|
|
17
|
+
...process.env,
|
|
18
|
+
COLUMNS: String(cols),
|
|
19
|
+
LINES: String(rows),
|
|
20
|
+
FORCE_COLOR: "1",
|
|
21
|
+
TERM: "xterm-256color",
|
|
22
|
+
NO_COLOR: undefined,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
proc.stdout.on("data", (chunk) => {
|
|
26
|
+
events.push({
|
|
27
|
+
time: (Date.now() - startTime) / 1000,
|
|
28
|
+
data: chunk.toString(),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
proc.stderr.on("data", (chunk) => {
|
|
32
|
+
events.push({
|
|
33
|
+
time: (Date.now() - startTime) / 1000,
|
|
34
|
+
data: chunk.toString(),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
proc.on("close", (code) => { exitCode = code; resolve(); });
|
|
38
|
+
proc.on("error", (err) => {
|
|
39
|
+
events.push({
|
|
40
|
+
time: (Date.now() - startTime) / 1000,
|
|
41
|
+
data: `\x1b[31mError: ${err.message}\x1b[0m\n`,
|
|
42
|
+
});
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
const duration = Date.now() - startTime;
|
|
47
|
+
const realDurationSec = duration / 1000;
|
|
48
|
+
// Pick default speed so playback lasts at least ~2s
|
|
49
|
+
const initialSpeed = realDurationSec < 0.2 ? 0.1 :
|
|
50
|
+
realDurationSec < 0.5 ? 0.25 :
|
|
51
|
+
realDurationSec < 1 ? 0.5 :
|
|
52
|
+
realDurationSec < 2 ? 0.5 :
|
|
53
|
+
1;
|
|
54
|
+
// Write asciicast v2 file
|
|
55
|
+
const header = JSON.stringify({
|
|
56
|
+
version: 2,
|
|
57
|
+
width: cols,
|
|
58
|
+
height: rows,
|
|
59
|
+
timestamp: Math.floor(startTime / 1000),
|
|
60
|
+
env: { SHELL: "/bin/sh", TERM: "xterm-256color" },
|
|
61
|
+
});
|
|
62
|
+
const castLines = [header];
|
|
63
|
+
for (const evt of events) {
|
|
64
|
+
castLines.push(JSON.stringify([evt.time, "o", evt.data]));
|
|
65
|
+
}
|
|
66
|
+
await writeFile(castPath, castLines.join("\n") + "\n", "utf-8");
|
|
67
|
+
// Write self-contained HTML player
|
|
68
|
+
const castDataJson = JSON.stringify(events);
|
|
69
|
+
const html = buildPlayerHtml(label, cols, rows, castDataJson, realDurationSec, initialSpeed);
|
|
70
|
+
await writeFile(htmlPath, html, "utf-8");
|
|
71
|
+
return {
|
|
72
|
+
path: htmlPath,
|
|
73
|
+
mode: "terminal",
|
|
74
|
+
duration,
|
|
75
|
+
label,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function buildPlayerHtml(label, cols, rows, castDataJson, durationSec, initialSpeed) {
|
|
79
|
+
const speeds = [0.1, 0.25, 0.5, 1, 1.25, 1.5, 1.75, 2, 4];
|
|
80
|
+
const speedOptions = speeds.map(s => `<option value="${s}"${s === initialSpeed ? " selected" : ""}>${s}x</option>`).join("");
|
|
81
|
+
return `<!DOCTYPE html>
|
|
82
|
+
<html>
|
|
83
|
+
<head>
|
|
84
|
+
<meta charset="utf-8">
|
|
85
|
+
<title>${label} — proof terminal recording</title>
|
|
86
|
+
<style>
|
|
87
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
88
|
+
html, body { margin: 0; height: 100%; }
|
|
89
|
+
body { background: transparent; -webkit-font-smoothing: antialiased; font-family: system-ui, sans-serif; }
|
|
90
|
+
.player {
|
|
91
|
+
background: #0d1117;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
}
|
|
96
|
+
#terminal {
|
|
97
|
+
height: calc(100% - 40px);
|
|
98
|
+
padding: 14px;
|
|
99
|
+
font-family: ui-monospace, monospace;
|
|
100
|
+
font-size: 12px;
|
|
101
|
+
line-height: 1.4;
|
|
102
|
+
color: #fff;
|
|
103
|
+
white-space: pre-wrap;
|
|
104
|
+
word-wrap: break-word;
|
|
105
|
+
overflow-y: auto;
|
|
106
|
+
opacity: .9;
|
|
107
|
+
}
|
|
108
|
+
.controls {
|
|
109
|
+
padding: 6px 10px;
|
|
110
|
+
border-top: 1px solid #30363d;
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 10px;
|
|
114
|
+
height: 40px;
|
|
115
|
+
}
|
|
116
|
+
.btn {
|
|
117
|
+
background: none;
|
|
118
|
+
border: 1px solid #30363d;
|
|
119
|
+
color: #e6edf3;
|
|
120
|
+
padding: 4px 12px;
|
|
121
|
+
border-radius: 4px;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
font-size: 13px;
|
|
124
|
+
font-family: system-ui;
|
|
125
|
+
}
|
|
126
|
+
.btn:hover { background: #21262d; }
|
|
127
|
+
.speed-select {
|
|
128
|
+
background: #0d1117;
|
|
129
|
+
border: 1px solid #30363d;
|
|
130
|
+
color: #e6edf3;
|
|
131
|
+
padding: 4px 8px;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
font-family: system-ui;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
.speed-select:hover { background: #21262d; }
|
|
138
|
+
.progress {
|
|
139
|
+
flex: 1;
|
|
140
|
+
height: 4px;
|
|
141
|
+
background: #21262d;
|
|
142
|
+
border-radius: 2px;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
}
|
|
146
|
+
.progress-bar {
|
|
147
|
+
height: 100%;
|
|
148
|
+
background: #58a6ff;
|
|
149
|
+
width: 0%;
|
|
150
|
+
transition: width 0.1s linear;
|
|
151
|
+
}
|
|
152
|
+
.time { color: #8b949e; font-size: 12px; font-family: monospace; min-width: 40px; text-align: right; line-height: 27px; }
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<div class="player">
|
|
157
|
+
<div id="terminal"></div>
|
|
158
|
+
<div class="controls">
|
|
159
|
+
<button class="btn" id="playBtn" onclick="toggle()">Play</button>
|
|
160
|
+
<select class="speed-select" id="speedSelect" onchange="changeSpeed(this.value)">${speedOptions}</select>
|
|
161
|
+
<div class="progress" onclick="seek(event)"><div class="progress-bar" id="bar"></div></div>
|
|
162
|
+
<span class="time" id="time">0.0s / ${durationSec < 1 ? (durationSec * 1000).toFixed(0) + "ms" : durationSec.toFixed(1) + "s"}</span>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<script>
|
|
166
|
+
const events = ${castDataJson};
|
|
167
|
+
const totalDuration = ${durationSec.toFixed(3)};
|
|
168
|
+
const realDuration = ${durationSec.toFixed(3)};
|
|
169
|
+
const term = document.getElementById('terminal');
|
|
170
|
+
const bar = document.getElementById('bar');
|
|
171
|
+
const timeEl = document.getElementById('time');
|
|
172
|
+
const playBtn = document.getElementById('playBtn');
|
|
173
|
+
|
|
174
|
+
let playing = false;
|
|
175
|
+
let currentIdx = 0;
|
|
176
|
+
let startTs = 0;
|
|
177
|
+
let pausedAt = 0;
|
|
178
|
+
let speed = ${initialSpeed};
|
|
179
|
+
let raf = null;
|
|
180
|
+
|
|
181
|
+
// Convert ANSI to styled HTML spans
|
|
182
|
+
function ansiToHtml(str) {
|
|
183
|
+
const colors = ['#0d1117','#ff7b72','#3fb950','#d29922','#58a6ff','#bc8cff','#39d2e0','#e6edf3'];
|
|
184
|
+
const brights = ['#484f58','#ffa198','#56d364','#e3b341','#79c0ff','#d2a8ff','#56d4dd','#f0f6fc'];
|
|
185
|
+
let out = '';
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < str.length) {
|
|
188
|
+
if (str[i] === '\\x1b' || str.charCodeAt(i) === 0x1b) {
|
|
189
|
+
const m = str.slice(i).match(/^(?:\\x1b|\\u001b|\\033|\\x1B)\\[([0-9;]*)m/);
|
|
190
|
+
if (m) {
|
|
191
|
+
const codes = m[1].split(';').map(Number);
|
|
192
|
+
let style = '';
|
|
193
|
+
for (const c of codes) {
|
|
194
|
+
if (c === 0) style += 'color:#e6edf3;font-weight:normal;font-style:normal;text-decoration:none;';
|
|
195
|
+
else if (c === 1) style += 'font-weight:bold;';
|
|
196
|
+
else if (c === 2) style += 'opacity:0.6;';
|
|
197
|
+
else if (c === 3) style += 'font-style:italic;';
|
|
198
|
+
else if (c === 4) style += 'text-decoration:underline;';
|
|
199
|
+
else if (c >= 30 && c <= 37) style += 'color:' + colors[c - 30] + ';';
|
|
200
|
+
else if (c >= 90 && c <= 97) style += 'color:' + brights[c - 90] + ';';
|
|
201
|
+
}
|
|
202
|
+
if (style) out += '</span><span style="' + style + '">';
|
|
203
|
+
i += m[0].length;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const ch = str[i];
|
|
208
|
+
if (ch === '<') out += '<';
|
|
209
|
+
else if (ch === '>') out += '>';
|
|
210
|
+
else if (ch === '&') out += '&';
|
|
211
|
+
else out += ch;
|
|
212
|
+
i++;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderUpTo(elapsed) {
|
|
218
|
+
let html = '<span>';
|
|
219
|
+
for (let j = 0; j < events.length; j++) {
|
|
220
|
+
if (events[j].time > elapsed) { currentIdx = j; break; }
|
|
221
|
+
html += ansiToHtml(events[j].data);
|
|
222
|
+
if (j === events.length - 1) currentIdx = events.length;
|
|
223
|
+
}
|
|
224
|
+
html += '</span>';
|
|
225
|
+
term.innerHTML = html;
|
|
226
|
+
term.scrollTop = term.scrollHeight;
|
|
227
|
+
const pct = totalDuration > 0 ? Math.min(100, (elapsed / totalDuration) * 100) : 100;
|
|
228
|
+
bar.style.width = pct + '%';
|
|
229
|
+
timeEl.textContent = elapsed.toFixed(1) + 's';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function tick() {
|
|
233
|
+
if (!playing) return;
|
|
234
|
+
const elapsed = ((Date.now() - startTs) / 1000) * speed;
|
|
235
|
+
if (elapsed >= totalDuration) {
|
|
236
|
+
renderUpTo(totalDuration);
|
|
237
|
+
playing = false;
|
|
238
|
+
playBtn.textContent = 'Replay';
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
renderUpTo(elapsed);
|
|
242
|
+
raf = requestAnimationFrame(tick);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function toggle() {
|
|
246
|
+
if (playing) {
|
|
247
|
+
playing = false;
|
|
248
|
+
pausedAt = ((Date.now() - startTs) / 1000) * speed;
|
|
249
|
+
playBtn.textContent = 'Play';
|
|
250
|
+
if (raf) cancelAnimationFrame(raf);
|
|
251
|
+
} else {
|
|
252
|
+
if (currentIdx >= events.length) {
|
|
253
|
+
pausedAt = 0;
|
|
254
|
+
term.innerHTML = '';
|
|
255
|
+
currentIdx = 0;
|
|
256
|
+
}
|
|
257
|
+
playing = true;
|
|
258
|
+
playBtn.textContent = 'Pause';
|
|
259
|
+
startTs = Date.now() - (pausedAt / speed) * 1000;
|
|
260
|
+
tick();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function changeSpeed(val) {
|
|
265
|
+
if (playing) pausedAt = ((Date.now() - startTs) / 1000) * speed;
|
|
266
|
+
speed = parseFloat(val);
|
|
267
|
+
if (playing) {
|
|
268
|
+
startTs = Date.now() - (pausedAt / speed) * 1000;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function seek(e) {
|
|
273
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
274
|
+
const pct = (e.clientX - rect.left) / rect.width;
|
|
275
|
+
pausedAt = pct * totalDuration;
|
|
276
|
+
renderUpTo(pausedAt);
|
|
277
|
+
if (playing) {
|
|
278
|
+
startTs = Date.now() - (pausedAt / speed) * 1000;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Render first frame
|
|
283
|
+
renderUpTo(0);
|
|
284
|
+
</script>
|
|
285
|
+
</body>
|
|
286
|
+
</html>`;
|
|
287
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CaptureOptions, Recording } from "../types";
|
|
2
|
+
export declare function captureVisual(options: CaptureOptions & {
|
|
3
|
+
testFile: string;
|
|
4
|
+
}, runDir: string, filePrefix: string, config: {
|
|
5
|
+
viewport?: {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
}): Promise<Recording>;
|
|
10
|
+
export declare function getCursorHighlightScript(): string;
|