@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/dist/cli.js ADDED
@@ -0,0 +1,934 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
5
+ import { join as join5, basename as basename2 } from "path";
6
+ import { tmpdir } from "os";
7
+ import { existsSync as existsSync3 } from "fs";
8
+
9
+ // src/detect.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import { join } from "path";
13
+ var BROWSER_CONFIG_PATTERNS = [
14
+ "playwright.config.ts",
15
+ "playwright.config.js",
16
+ "playwright.config.mjs"
17
+ ];
18
+ async function detectMode(projectDir) {
19
+ for (const config of BROWSER_CONFIG_PATTERNS) {
20
+ if (existsSync(join(projectDir, config))) {
21
+ return "browser";
22
+ }
23
+ }
24
+ const pkgPath = join(projectDir, "package.json");
25
+ if (existsSync(pkgPath)) {
26
+ try {
27
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
28
+ const allDeps = {
29
+ ...pkg.dependencies,
30
+ ...pkg.devDependencies,
31
+ ...pkg.peerDependencies
32
+ };
33
+ if (allDeps["@playwright/test"] || allDeps["playwright"]) {
34
+ return "browser";
35
+ }
36
+ } catch {}
37
+ }
38
+ return "terminal";
39
+ }
40
+
41
+ // src/modes/visual.ts
42
+ import { readdir, rm, copyFile, mkdir } from "fs/promises";
43
+ import { existsSync as existsSync2 } from "fs";
44
+ import { join as join2, dirname } from "path";
45
+ import { spawn as spawn2 } from "child_process";
46
+
47
+ // src/duration.ts
48
+ import { spawn } from "child_process";
49
+ async function getVideoDuration(filePath) {
50
+ try {
51
+ const output = await new Promise((resolve, reject) => {
52
+ const proc = spawn("ffprobe", [
53
+ "-v",
54
+ "quiet",
55
+ "-print_format",
56
+ "json",
57
+ "-show_format",
58
+ filePath
59
+ ], { stdio: ["ignore", "pipe", "pipe"] });
60
+ let stdout = "";
61
+ proc.stdout.on("data", (data) => {
62
+ stdout += data.toString();
63
+ });
64
+ proc.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error("ffprobe failed")));
65
+ proc.on("error", reject);
66
+ });
67
+ const parsed = JSON.parse(output);
68
+ const seconds = parseFloat(parsed.format?.duration ?? "0");
69
+ return Math.round(seconds * 1000);
70
+ } catch {
71
+ return 0;
72
+ }
73
+ }
74
+
75
+ // src/modes/visual.ts
76
+ async function captureVisual(options, runDir, filePrefix, config) {
77
+ const label = options.label ?? "recording";
78
+ const testFile = options.testFile;
79
+ const testDir = dirname(testFile);
80
+ const configCandidates = [
81
+ join2(testDir, "playwright.config.ts"),
82
+ join2(testDir, "playwright.config.js"),
83
+ join2(testDir, "playwright.config.mjs")
84
+ ];
85
+ const existingConfig = configCandidates.find((c) => existsSync2(c));
86
+ const testResultsDir = join2(runDir, "pw-results");
87
+ await mkdir(testResultsDir, { recursive: true });
88
+ const testArgs = [
89
+ "playwright",
90
+ "test",
91
+ testFile,
92
+ "--reporter=list",
93
+ "--output",
94
+ testResultsDir
95
+ ];
96
+ if (options.testName) {
97
+ testArgs.push("-g", options.testName);
98
+ }
99
+ if (existingConfig) {
100
+ testArgs.push("--config", existingConfig);
101
+ }
102
+ const env = {
103
+ ...process.env,
104
+ PWDEBUG: "0",
105
+ PLAYWRIGHT_VIDEO: "on"
106
+ };
107
+ let exitCode = null;
108
+ let stderr = "";
109
+ await new Promise((resolve) => {
110
+ const proc = spawn2("npx", testArgs, {
111
+ stdio: "pipe",
112
+ env,
113
+ cwd: testDir
114
+ });
115
+ proc.stderr.on("data", (chunk) => {
116
+ stderr += chunk.toString();
117
+ });
118
+ proc.on("close", (code) => {
119
+ exitCode = code;
120
+ resolve();
121
+ });
122
+ proc.on("error", (err) => {
123
+ throw new Error(`Failed to run Playwright: ${err.message}. Is @playwright/test installed?`);
124
+ });
125
+ });
126
+ const videoFile = await findVideo(testResultsDir);
127
+ if (!videoFile) {
128
+ const hint = stderr.trim() ? `
129
+ Playwright output:
130
+ ${stderr.trim().split(`
131
+ `).slice(-5).join(`
132
+ `)}` : "";
133
+ throw new Error(`No video file found after Playwright test run. Check that video: 'on' is set in playwright config.${hint}`);
134
+ }
135
+ const ext = videoFile.endsWith(".webm") ? ".webm" : ".mp4";
136
+ const finalPath = join2(runDir, `${filePrefix}${ext}`);
137
+ await copyFile(videoFile, finalPath);
138
+ await rm(testResultsDir, { recursive: true, force: true });
139
+ const duration = await getVideoDuration(finalPath);
140
+ return {
141
+ path: finalPath,
142
+ mode: "browser",
143
+ duration,
144
+ label
145
+ };
146
+ }
147
+ async function findVideo(resultsDir) {
148
+ if (!existsSync2(resultsDir))
149
+ return null;
150
+ const entries = await readdir(resultsDir, { withFileTypes: true });
151
+ for (const entry of entries) {
152
+ if (!entry.isDirectory() && (entry.name.endsWith(".webm") || entry.name.endsWith(".mp4"))) {
153
+ return join2(resultsDir, entry.name);
154
+ }
155
+ }
156
+ for (const entry of entries) {
157
+ if (!entry.isDirectory())
158
+ continue;
159
+ const subDir = join2(resultsDir, entry.name);
160
+ const files = await readdir(subDir);
161
+ const video = files.find((f) => f.endsWith(".webm") || f.endsWith(".mp4"));
162
+ if (video)
163
+ return join2(subDir, video);
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // src/modes/terminal.ts
169
+ import { spawn as spawn3 } from "child_process";
170
+ import { writeFile } from "fs/promises";
171
+ import { join as join3 } from "path";
172
+ async function captureTerminal(options, runDir, filePrefix, command, config) {
173
+ const label = options.label ?? "terminal";
174
+ const castPath = join3(runDir, `${filePrefix}.cast`);
175
+ const htmlPath = join3(runDir, `${filePrefix}.html`);
176
+ const cols = config.cols ?? 120;
177
+ const rows = config.rows ?? 30;
178
+ const events = [];
179
+ const startTime = Date.now();
180
+ let exitCode = null;
181
+ await new Promise((resolve) => {
182
+ const proc = spawn3("/bin/sh", ["-c", command], {
183
+ stdio: ["ignore", "pipe", "pipe"],
184
+ env: {
185
+ ...process.env,
186
+ COLUMNS: String(cols),
187
+ LINES: String(rows),
188
+ FORCE_COLOR: "1",
189
+ TERM: "xterm-256color",
190
+ NO_COLOR: undefined
191
+ }
192
+ });
193
+ proc.stdout.on("data", (chunk) => {
194
+ events.push({
195
+ time: (Date.now() - startTime) / 1000,
196
+ data: chunk.toString()
197
+ });
198
+ });
199
+ proc.stderr.on("data", (chunk) => {
200
+ events.push({
201
+ time: (Date.now() - startTime) / 1000,
202
+ data: chunk.toString()
203
+ });
204
+ });
205
+ proc.on("close", (code) => {
206
+ exitCode = code;
207
+ resolve();
208
+ });
209
+ proc.on("error", (err) => {
210
+ events.push({
211
+ time: (Date.now() - startTime) / 1000,
212
+ data: `\x1B[31mError: ${err.message}\x1B[0m
213
+ `
214
+ });
215
+ resolve();
216
+ });
217
+ });
218
+ const duration = Date.now() - startTime;
219
+ const realDurationSec = duration / 1000;
220
+ const initialSpeed = realDurationSec < 0.2 ? 0.1 : realDurationSec < 0.5 ? 0.25 : realDurationSec < 1 ? 0.5 : realDurationSec < 2 ? 0.5 : 1;
221
+ const header = JSON.stringify({
222
+ version: 2,
223
+ width: cols,
224
+ height: rows,
225
+ timestamp: Math.floor(startTime / 1000),
226
+ env: { SHELL: "/bin/sh", TERM: "xterm-256color" }
227
+ });
228
+ const castLines = [header];
229
+ for (const evt of events) {
230
+ castLines.push(JSON.stringify([evt.time, "o", evt.data]));
231
+ }
232
+ await writeFile(castPath, castLines.join(`
233
+ `) + `
234
+ `, "utf-8");
235
+ const castDataJson = JSON.stringify(events);
236
+ const html = buildPlayerHtml(label, cols, rows, castDataJson, realDurationSec, initialSpeed);
237
+ await writeFile(htmlPath, html, "utf-8");
238
+ return {
239
+ path: htmlPath,
240
+ mode: "terminal",
241
+ duration,
242
+ label
243
+ };
244
+ }
245
+ function buildPlayerHtml(label, cols, rows, castDataJson, durationSec, initialSpeed) {
246
+ const speeds = [0.1, 0.25, 0.5, 1, 1.25, 1.5, 1.75, 2, 4];
247
+ const speedOptions = speeds.map((s) => `<option value="${s}"${s === initialSpeed ? " selected" : ""}>${s}x</option>`).join("");
248
+ return `<!DOCTYPE html>
249
+ <html>
250
+ <head>
251
+ <meta charset="utf-8">
252
+ <title>${label} — proof terminal recording</title>
253
+ <style>
254
+ * { box-sizing: border-box; margin: 0; padding: 0; }
255
+ html, body { margin: 0; height: 100%; }
256
+ body { background: transparent; -webkit-font-smoothing: antialiased; font-family: system-ui, sans-serif; }
257
+ .player {
258
+ background: #0d1117;
259
+ overflow: hidden;
260
+ width: 100%;
261
+ height: 100%;
262
+ }
263
+ #terminal {
264
+ height: calc(100% - 40px);
265
+ padding: 14px;
266
+ font-family: ui-monospace, monospace;
267
+ font-size: 12px;
268
+ line-height: 1.4;
269
+ color: #fff;
270
+ white-space: pre-wrap;
271
+ word-wrap: break-word;
272
+ overflow-y: auto;
273
+ opacity: .9;
274
+ }
275
+ .controls {
276
+ padding: 6px 10px;
277
+ border-top: 1px solid #30363d;
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 10px;
281
+ height: 40px;
282
+ }
283
+ .btn {
284
+ background: none;
285
+ border: 1px solid #30363d;
286
+ color: #e6edf3;
287
+ padding: 4px 12px;
288
+ border-radius: 4px;
289
+ cursor: pointer;
290
+ font-size: 13px;
291
+ font-family: system-ui;
292
+ }
293
+ .btn:hover { background: #21262d; }
294
+ .speed-select {
295
+ background: #0d1117;
296
+ border: 1px solid #30363d;
297
+ color: #e6edf3;
298
+ padding: 4px 8px;
299
+ border-radius: 4px;
300
+ font-size: 13px;
301
+ font-family: system-ui;
302
+ cursor: pointer;
303
+ }
304
+ .speed-select:hover { background: #21262d; }
305
+ .progress {
306
+ flex: 1;
307
+ height: 4px;
308
+ background: #21262d;
309
+ border-radius: 2px;
310
+ overflow: hidden;
311
+ cursor: pointer;
312
+ }
313
+ .progress-bar {
314
+ height: 100%;
315
+ background: #58a6ff;
316
+ width: 0%;
317
+ transition: width 0.1s linear;
318
+ }
319
+ .time { color: #8b949e; font-size: 12px; font-family: monospace; min-width: 40px; text-align: right; line-height: 27px; }
320
+ </style>
321
+ </head>
322
+ <body>
323
+ <div class="player">
324
+ <div id="terminal"></div>
325
+ <div class="controls">
326
+ <button class="btn" id="playBtn" onclick="toggle()">Play</button>
327
+ <select class="speed-select" id="speedSelect" onchange="changeSpeed(this.value)">${speedOptions}</select>
328
+ <div class="progress" onclick="seek(event)"><div class="progress-bar" id="bar"></div></div>
329
+ <span class="time" id="time">0.0s / ${durationSec < 1 ? (durationSec * 1000).toFixed(0) + "ms" : durationSec.toFixed(1) + "s"}</span>
330
+ </div>
331
+ </div>
332
+ <script>
333
+ const events = ${castDataJson};
334
+ const totalDuration = ${durationSec.toFixed(3)};
335
+ const realDuration = ${durationSec.toFixed(3)};
336
+ const term = document.getElementById('terminal');
337
+ const bar = document.getElementById('bar');
338
+ const timeEl = document.getElementById('time');
339
+ const playBtn = document.getElementById('playBtn');
340
+
341
+ let playing = false;
342
+ let currentIdx = 0;
343
+ let startTs = 0;
344
+ let pausedAt = 0;
345
+ let speed = ${initialSpeed};
346
+ let raf = null;
347
+
348
+ // Convert ANSI to styled HTML spans
349
+ function ansiToHtml(str) {
350
+ const colors = ['#0d1117','#ff7b72','#3fb950','#d29922','#58a6ff','#bc8cff','#39d2e0','#e6edf3'];
351
+ const brights = ['#484f58','#ffa198','#56d364','#e3b341','#79c0ff','#d2a8ff','#56d4dd','#f0f6fc'];
352
+ let out = '';
353
+ let i = 0;
354
+ while (i < str.length) {
355
+ if (str[i] === '\\x1b' || str.charCodeAt(i) === 0x1b) {
356
+ const m = str.slice(i).match(/^(?:\\x1b|\\u001b|\\033|\\x1B)\\[([0-9;]*)m/);
357
+ if (m) {
358
+ const codes = m[1].split(';').map(Number);
359
+ let style = '';
360
+ for (const c of codes) {
361
+ if (c === 0) style += 'color:#e6edf3;font-weight:normal;font-style:normal;text-decoration:none;';
362
+ else if (c === 1) style += 'font-weight:bold;';
363
+ else if (c === 2) style += 'opacity:0.6;';
364
+ else if (c === 3) style += 'font-style:italic;';
365
+ else if (c === 4) style += 'text-decoration:underline;';
366
+ else if (c >= 30 && c <= 37) style += 'color:' + colors[c - 30] + ';';
367
+ else if (c >= 90 && c <= 97) style += 'color:' + brights[c - 90] + ';';
368
+ }
369
+ if (style) out += '</span><span style="' + style + '">';
370
+ i += m[0].length;
371
+ continue;
372
+ }
373
+ }
374
+ const ch = str[i];
375
+ if (ch === '<') out += '&lt;';
376
+ else if (ch === '>') out += '&gt;';
377
+ else if (ch === '&') out += '&amp;';
378
+ else out += ch;
379
+ i++;
380
+ }
381
+ return out;
382
+ }
383
+
384
+ function renderUpTo(elapsed) {
385
+ let html = '<span>';
386
+ for (let j = 0; j < events.length; j++) {
387
+ if (events[j].time > elapsed) { currentIdx = j; break; }
388
+ html += ansiToHtml(events[j].data);
389
+ if (j === events.length - 1) currentIdx = events.length;
390
+ }
391
+ html += '</span>';
392
+ term.innerHTML = html;
393
+ term.scrollTop = term.scrollHeight;
394
+ const pct = totalDuration > 0 ? Math.min(100, (elapsed / totalDuration) * 100) : 100;
395
+ bar.style.width = pct + '%';
396
+ timeEl.textContent = elapsed.toFixed(1) + 's';
397
+ }
398
+
399
+ function tick() {
400
+ if (!playing) return;
401
+ const elapsed = ((Date.now() - startTs) / 1000) * speed;
402
+ if (elapsed >= totalDuration) {
403
+ renderUpTo(totalDuration);
404
+ playing = false;
405
+ playBtn.textContent = 'Replay';
406
+ return;
407
+ }
408
+ renderUpTo(elapsed);
409
+ raf = requestAnimationFrame(tick);
410
+ }
411
+
412
+ function toggle() {
413
+ if (playing) {
414
+ playing = false;
415
+ pausedAt = ((Date.now() - startTs) / 1000) * speed;
416
+ playBtn.textContent = 'Play';
417
+ if (raf) cancelAnimationFrame(raf);
418
+ } else {
419
+ if (currentIdx >= events.length) {
420
+ pausedAt = 0;
421
+ term.innerHTML = '';
422
+ currentIdx = 0;
423
+ }
424
+ playing = true;
425
+ playBtn.textContent = 'Pause';
426
+ startTs = Date.now() - (pausedAt / speed) * 1000;
427
+ tick();
428
+ }
429
+ }
430
+
431
+ function changeSpeed(val) {
432
+ if (playing) pausedAt = ((Date.now() - startTs) / 1000) * speed;
433
+ speed = parseFloat(val);
434
+ if (playing) {
435
+ startTs = Date.now() - (pausedAt / speed) * 1000;
436
+ }
437
+ }
438
+
439
+ function seek(e) {
440
+ const rect = e.currentTarget.getBoundingClientRect();
441
+ const pct = (e.clientX - rect.left) / rect.width;
442
+ pausedAt = pct * totalDuration;
443
+ renderUpTo(pausedAt);
444
+ if (playing) {
445
+ startTs = Date.now() - (pausedAt / speed) * 1000;
446
+ }
447
+ }
448
+
449
+ // Render first frame
450
+ renderUpTo(0);
451
+ </script>
452
+ </body>
453
+ </html>`;
454
+ }
455
+
456
+ // src/report.ts
457
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
458
+ import { join as join4, basename } from "path";
459
+ async function generateReport(manifest, runDir, format) {
460
+ switch (format) {
461
+ case "md":
462
+ return generateMd(manifest, runDir);
463
+ case "html":
464
+ return generateHtml(manifest, runDir, false);
465
+ case "archive":
466
+ return generateHtml(manifest, runDir, true);
467
+ }
468
+ }
469
+ function formatTime(iso) {
470
+ return new Date(iso).toLocaleTimeString("en-US", { hour12: false });
471
+ }
472
+ function formatDate(iso) {
473
+ return new Date(iso).toLocaleDateString("en-US", {
474
+ year: "numeric",
475
+ month: "long",
476
+ day: "numeric"
477
+ });
478
+ }
479
+ function formatDuration(ms) {
480
+ if (ms < 1000)
481
+ return `${ms}ms`;
482
+ return `${(ms / 1000).toFixed(1)}s`;
483
+ }
484
+ function modeIcon(mode) {
485
+ return mode === "browser" ? "\uD83C\uDF10" : "\uD83D\uDDA5";
486
+ }
487
+ function generateMd(manifest, runDir) {
488
+ const lines = [];
489
+ const totalDuration = manifest.entries.reduce((sum, e) => sum + e.duration, 0);
490
+ lines.push(`# Proof Report`);
491
+ lines.push(``);
492
+ lines.push(`**App:** ${manifest.appName}`);
493
+ lines.push(`**Run:** ${manifest.run}`);
494
+ lines.push(`**Date:** ${formatDate(manifest.createdAt)}`);
495
+ lines.push(`**Entries:** ${manifest.entries.length}`);
496
+ if (manifest.description) {
497
+ lines.push(``);
498
+ lines.push(manifest.description);
499
+ }
500
+ lines.push(``);
501
+ lines.push(`| # | Label | Mode | Duration | Artifact |`);
502
+ lines.push(`|---|-------|------|----------|----------|`);
503
+ manifest.entries.forEach((entry, i) => {
504
+ const label = entry.label ?? entry.mode;
505
+ lines.push(`| ${i + 1} | ${modeIcon(entry.mode)} ${label} | ${entry.mode} | ${formatDuration(entry.duration)} | [${entry.artifact}](./${entry.artifact}) |`);
506
+ });
507
+ lines.push(``);
508
+ lines.push(`---`);
509
+ lines.push(``);
510
+ for (const entry of manifest.entries) {
511
+ lines.push(`### ${modeIcon(entry.mode)} ${entry.label ?? entry.mode}`);
512
+ lines.push(``);
513
+ lines.push(`${entry.description}`);
514
+ lines.push(``);
515
+ lines.push(`- **Time:** ${formatTime(entry.timestamp)}`);
516
+ lines.push(`- **Duration:** ${formatDuration(entry.duration)}`);
517
+ if (entry.command)
518
+ lines.push(`- **Command:** \`${entry.command}\``);
519
+ if (entry.testFile)
520
+ lines.push(`- **Test file:** \`${basename(entry.testFile)}\``);
521
+ if (entry.testName)
522
+ lines.push(`- **Test name:** ${entry.testName}`);
523
+ lines.push(`- **Artifact:** [${entry.artifact}](./${entry.artifact})`);
524
+ lines.push(``);
525
+ }
526
+ lines.push(`---`);
527
+ lines.push(`*Generated by [@automaze/proof](https://www.npmjs.com/package/@automaze/proof)*`);
528
+ const md = lines.join(`
529
+ `);
530
+ const outPath = join4(runDir, "report.md");
531
+ return writeFile2(outPath, md, "utf-8").then(() => outPath);
532
+ }
533
+ async function generateHtml(manifest, runDir, inline) {
534
+ const totalDuration = manifest.entries.reduce((sum, e) => sum + e.duration, 0);
535
+ const terminalCount = manifest.entries.filter((e) => e.mode === "terminal").length;
536
+ const browserCount = manifest.entries.filter((e) => e.mode === "browser").length;
537
+ const sections = [];
538
+ for (const entry of manifest.entries) {
539
+ const mediaHtml = await buildMediaEmbed(entry, runDir, inline);
540
+ const label = entry.label ?? entry.mode;
541
+ const isTerminal = entry.mode === "terminal";
542
+ const badgeClass = isTerminal ? "text-green-800 bg-emerald-100/80" : "text-blue-800 bg-sky-100";
543
+ const modeLabel = isTerminal ? "Terminal" : "Browser";
544
+ let sourceHtml = "";
545
+ if (entry.command) {
546
+ sourceHtml = `
547
+ <p class="font-medium text-sm mt-4 mb-1">Command:</p>
548
+ <div class="font-mono text-xs border px-1 rounded bg-neutral-50 border-neutral-200 subpixel-antialiased w-fit">${esc(entry.command)}</div>`;
549
+ } else if (entry.testFile) {
550
+ sourceHtml = `
551
+ <p class="font-medium text-sm mt-4 mb-1">Test:</p>
552
+ <div class="font-mono text-xs border px-1 rounded bg-neutral-50 border-neutral-200 subpixel-antialiased w-fit">${esc(basename(entry.testFile))}</div>`;
553
+ }
554
+ sections.push(`
555
+ <section class="mt-12 grid-cols-12 gap-x-6 border-t border-neutral-200 pt-12 md:grid">
556
+ <div class="col-span-5">
557
+ <h2 class="text-2xl mb-2">${esc(label)}</h2>
558
+ <div class="flex items-center space-x-4 subpixel-antialiased">
559
+ <span class="rounded ${badgeClass} px-2 py-0.5 text-sm">${modeLabel}</span>
560
+ <time class="font-mono text-[13px]">${formatTime(entry.timestamp)} (${formatDuration(entry.duration)})</time>
561
+ </div>
562
+ <p class="opacity-80 my-4">${esc(entry.description)}</p>
563
+ </div>
564
+ <div class="col-span-7">
565
+ ${mediaHtml}
566
+ ${sourceHtml}
567
+ </div>
568
+ </section>`);
569
+ }
570
+ const descriptionHtml = manifest.description ? `<p class="max-w-2xl leading-relaxed opacity-80">${esc(manifest.description)}</p>` : "";
571
+ const badgesHtml = [
572
+ terminalCount > 0 ? `<span class="whitespace-nowrap rounded font-medium text-green-800 bg-emerald-100/80 px-2 py-0.5 text-sm">${terminalCount} Terminal</span>` : "",
573
+ browserCount > 0 ? `<span class="whitespace-nowrap rounded font-medium text-blue-800 bg-sky-100 px-2 py-0.5 text-sm">${browserCount} Browser</span>` : ""
574
+ ].filter(Boolean).join(`
575
+ `);
576
+ const tailwindScript = inline ? `<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>` : `<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>`;
577
+ const html = `<!DOCTYPE html>
578
+ <html lang="en">
579
+ <head>
580
+ <meta charset="utf-8">
581
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
582
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover">
583
+ <meta http-equiv="cleartype" content="on">
584
+ <title>Proof Report - ${esc(manifest.run)}</title>
585
+ ${tailwindScript}
586
+ </head>
587
+ <body>
588
+
589
+ <div class="container mx-auto px-6 my-14 w-full max-w-5xl antialiased">
590
+ <header>
591
+ <div class="flex items-center space-x-4 mb-6">
592
+ <h1 class="text-4xl leading-none font-medium">Proof report</h1>
593
+ <span class="mt-2 rounded bg-neutral-100 px-2 py-1 font-mono text-sm leading-none">${esc(manifest.run)}</span>
594
+ </div>
595
+
596
+ ${descriptionHtml}
597
+
598
+ <div class="mt-6 md:flex md:items-center md:space-x-4 leading-none">
599
+ <span class="block mb-2 md:inline md:m-0">${formatDate(manifest.createdAt)} &middot; ${formatTime(manifest.createdAt)} (${formatDuration(totalDuration)})</span>
600
+ ${badgesHtml}
601
+ </div>
602
+
603
+ <p class="my-6">Generated by <a href="https://www.npmjs.com/package/@automaze/proof" class="font-medium text-blue-600">@automaze/proof</a></p>
604
+ </header>
605
+
606
+ <main>
607
+ ${sections.join(`
608
+ `)}
609
+ </main>
610
+
611
+ <footer></footer>
612
+ </div>
613
+
614
+ </body>
615
+ </html>`;
616
+ const ext = inline ? "archive.html" : "report.html";
617
+ const outPath = join4(runDir, ext);
618
+ await writeFile2(outPath, html, "utf-8");
619
+ return outPath;
620
+ }
621
+ async function buildMediaEmbed(entry, runDir, inline) {
622
+ const artifactPath = join4(runDir, entry.artifact);
623
+ if (entry.mode === "browser") {
624
+ if (inline) {
625
+ const videoData = await readFile2(artifactPath);
626
+ const ext = entry.artifact.endsWith(".mp4") ? "mp4" : "webm";
627
+ const b64 = videoData.toString("base64");
628
+ return `<video class="bg-black w-full shadow-xs rounded-md aspect-3/2" controls muted loop src="data:video/${ext};base64,${b64}"></video>`;
629
+ }
630
+ return `<video class="bg-neutral-200 w-full rounded-md aspect-3/2" controls muted loop src="./${esc(entry.artifact)}"></video>`;
631
+ }
632
+ if (entry.mode === "terminal") {
633
+ if (inline) {
634
+ const playerHtml = await readFile2(artifactPath, "utf-8");
635
+ return `<iframe class="w-full aspect-3/2 rounded-lg bg-black" allowtransparency="true" srcdoc="${escAttr(playerHtml)}"></iframe>`;
636
+ }
637
+ return `<iframe class="w-full aspect-3/2 rounded-lg bg-black" allowtransparency="true" src="./${esc(entry.artifact)}"></iframe>`;
638
+ }
639
+ return `<a href="./${esc(entry.artifact)}">${esc(entry.artifact)}</a>`;
640
+ }
641
+ function esc(s) {
642
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
643
+ }
644
+ function escAttr(s) {
645
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
646
+ }
647
+
648
+ // src/index.ts
649
+ class Proof {
650
+ config;
651
+ runDir;
652
+ runName;
653
+ initTime;
654
+ constructor(config) {
655
+ this.config = config;
656
+ this.initTime = new Date;
657
+ const root = config.proofDir ?? process.env.PROOF_DIR ?? join5(tmpdir(), "proof");
658
+ const dateStr = this.formatDate(this.initTime);
659
+ this.runName = config.run ?? this.formatTime(this.initTime);
660
+ this.runDir = join5(root, config.appName, dateStr, this.runName);
661
+ }
662
+ formatDate(d) {
663
+ const pad = (n) => String(n).padStart(2, "0");
664
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
665
+ }
666
+ formatTime(d) {
667
+ const pad = (n) => String(n).padStart(2, "0");
668
+ return `${pad(d.getHours())}${pad(d.getMinutes())}`;
669
+ }
670
+ formatTimestamp(d) {
671
+ const pad = (n) => String(n).padStart(2, "0");
672
+ return `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
673
+ }
674
+ async ensureRunDir() {
675
+ await mkdir2(this.runDir, { recursive: true });
676
+ return this.runDir;
677
+ }
678
+ async resolveMode(optMode) {
679
+ const envMode = process.env.PROOF_MODE;
680
+ const mode = optMode ?? envMode ?? "auto";
681
+ if (mode !== "auto")
682
+ return mode;
683
+ return detectMode(process.cwd());
684
+ }
685
+ async appendToManifest(entry) {
686
+ const manifestPath = join5(this.runDir, "proof.json");
687
+ let manifest;
688
+ if (existsSync3(manifestPath)) {
689
+ const raw = await readFile3(manifestPath, "utf-8");
690
+ manifest = JSON.parse(raw);
691
+ manifest.entries.push(entry);
692
+ } else {
693
+ manifest = {
694
+ version: 1,
695
+ appName: this.config.appName,
696
+ description: this.config.description,
697
+ run: this.runName,
698
+ createdAt: this.initTime.toISOString(),
699
+ entries: [entry]
700
+ };
701
+ }
702
+ await writeFile3(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
703
+ }
704
+ async capture(options) {
705
+ const runDir = await this.ensureRunDir();
706
+ const mode = await this.resolveMode(options.mode);
707
+ const ts = this.formatTimestamp(new Date);
708
+ const label = options.label ?? mode;
709
+ const filePrefix = `${label}-${ts}`;
710
+ let recording;
711
+ switch (mode) {
712
+ case "browser": {
713
+ if (!options.testFile) {
714
+ throw new Error("browser mode requires testFile");
715
+ }
716
+ recording = await captureVisual(options, runDir, filePrefix, {
717
+ viewport: this.config.browser?.viewport
718
+ });
719
+ break;
720
+ }
721
+ case "terminal": {
722
+ if (!options.command) {
723
+ throw new Error("terminal mode requires command");
724
+ }
725
+ recording = await captureTerminal(options, runDir, filePrefix, options.command, this.config.terminal ?? {});
726
+ break;
727
+ }
728
+ }
729
+ const source = options.testFile ? basename2(options.testFile) : options.command ?? mode;
730
+ const fallbackDescriptions = {
731
+ browser: `Playwright browser test recording of ${source}${options.testName ? ` — test: "${options.testName}"` : ""}`,
732
+ terminal: `Terminal capture: ${source}`
733
+ };
734
+ const entry = {
735
+ timestamp: new Date().toISOString(),
736
+ mode,
737
+ label,
738
+ command: options.command,
739
+ testFile: options.testFile,
740
+ testName: options.testName,
741
+ duration: recording.duration,
742
+ artifact: basename2(recording.path),
743
+ description: options.description ?? fallbackDescriptions[mode]
744
+ };
745
+ await this.appendToManifest(entry);
746
+ return recording;
747
+ }
748
+ async report(options) {
749
+ const manifestPath = join5(this.runDir, "proof.json");
750
+ if (!existsSync3(manifestPath)) {
751
+ throw new Error("No proof.json found — run capture() first");
752
+ }
753
+ const manifest = JSON.parse(await readFile3(manifestPath, "utf-8"));
754
+ const formatInput = options?.format ?? "md";
755
+ const formats = Array.isArray(formatInput) ? formatInput : [formatInput];
756
+ const paths = [];
757
+ for (const format of formats) {
758
+ const path = await generateReport(manifest, this.runDir, format);
759
+ paths.push(path);
760
+ }
761
+ return Array.isArray(formatInput) ? paths : paths[0];
762
+ }
763
+ }
764
+
765
+ // src/cli.ts
766
+ import { readFile as readFile4 } from "fs/promises";
767
+ import { resolve } from "path";
768
+ var __dirname = "/Users/ran/Projects/proof/src";
769
+ async function main() {
770
+ const args = process.argv.slice(2);
771
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
772
+ printUsage();
773
+ process.exit(0);
774
+ }
775
+ if (args.includes("--version") || args.includes("-v")) {
776
+ const pkg = JSON.parse(await readFile4(resolve(__dirname, "../package.json"), "utf-8"));
777
+ console.log(pkg.version);
778
+ process.exit(0);
779
+ }
780
+ if (args[0] === "--json" || args[0] === "-") {
781
+ const input = await readStdin();
782
+ const config = JSON.parse(input);
783
+ await runFromConfig(config);
784
+ return;
785
+ }
786
+ const action = args[0];
787
+ if (action !== "capture" && action !== "report") {
788
+ console.error(`Unknown action: ${action}. Use "capture" or "report".`);
789
+ process.exit(1);
790
+ }
791
+ const parsed = parseArgs(args.slice(1));
792
+ if (!parsed.appName) {
793
+ console.error("--app is required");
794
+ process.exit(1);
795
+ }
796
+ if (action === "capture") {
797
+ if (!parsed.command && !parsed.testFile) {
798
+ console.error("--command or --test-file is required for capture");
799
+ process.exit(1);
800
+ }
801
+ const config = {
802
+ action: "capture",
803
+ appName: parsed.appName,
804
+ proofDir: parsed.proofDir,
805
+ run: parsed.run,
806
+ command: parsed.command,
807
+ testFile: parsed.testFile,
808
+ testName: parsed.testName,
809
+ label: parsed.label,
810
+ mode: parsed.mode,
811
+ description: parsed.description
812
+ };
813
+ await runFromConfig(config);
814
+ } else {
815
+ const config = {
816
+ action: "report",
817
+ appName: parsed.appName,
818
+ proofDir: parsed.proofDir,
819
+ run: parsed.run
820
+ };
821
+ await runFromConfig(config);
822
+ }
823
+ }
824
+ async function runFromConfig(config) {
825
+ const proof = new Proof({
826
+ appName: config.appName,
827
+ proofDir: config.proofDir,
828
+ run: config.run,
829
+ browser: config.browser,
830
+ terminal: config.terminal
831
+ });
832
+ if (config.action === "report") {
833
+ const reportPath = await proof.report();
834
+ const result = { action: "report", path: reportPath };
835
+ console.log(JSON.stringify(result));
836
+ return;
837
+ }
838
+ const captures = config.captures ?? [
839
+ {
840
+ command: config.command,
841
+ testFile: config.testFile,
842
+ testName: config.testName,
843
+ label: config.label,
844
+ mode: config.mode,
845
+ description: config.description
846
+ }
847
+ ];
848
+ const results = [];
849
+ for (const cap of captures) {
850
+ const recording = await proof.capture({
851
+ command: cap.command,
852
+ testFile: cap.testFile,
853
+ testName: cap.testName,
854
+ label: cap.label,
855
+ mode: cap.mode,
856
+ description: cap.description
857
+ });
858
+ results.push({
859
+ path: recording.path,
860
+ mode: recording.mode,
861
+ duration: recording.duration,
862
+ label: recording.label
863
+ });
864
+ }
865
+ const output = {
866
+ action: "capture",
867
+ appName: config.appName,
868
+ run: config.run,
869
+ recordings: results
870
+ };
871
+ console.log(JSON.stringify(output));
872
+ }
873
+ function parseArgs(args) {
874
+ const result = {};
875
+ const map = {
876
+ "--app": "appName",
877
+ "--dir": "proofDir",
878
+ "--run": "run",
879
+ "--command": "command",
880
+ "--test-file": "testFile",
881
+ "--test-name": "testName",
882
+ "--label": "label",
883
+ "--mode": "mode",
884
+ "--description": "description"
885
+ };
886
+ for (let i = 0;i < args.length; i++) {
887
+ const key = map[args[i]];
888
+ if (key && i + 1 < args.length) {
889
+ result[key] = args[++i];
890
+ }
891
+ }
892
+ return result;
893
+ }
894
+ function readStdin() {
895
+ return new Promise((resolve2) => {
896
+ let data = "";
897
+ process.stdin.setEncoding("utf-8");
898
+ process.stdin.on("data", (chunk) => {
899
+ data += chunk;
900
+ });
901
+ process.stdin.on("end", () => resolve2(data));
902
+ process.stdin.resume();
903
+ });
904
+ }
905
+ function printUsage() {
906
+ console.log(`@automaze/proof — capture evidence of test execution
907
+
908
+ Usage:
909
+ proof capture --app <name> --command <cmd> [options]
910
+ proof capture --app <name> --test-file <file> --mode browser [options]
911
+ proof report --app <name> [options]
912
+ proof --json < config.json
913
+
914
+ Options:
915
+ --app <name> App name (required)
916
+ --dir <path> Proof directory (default: $TMPDIR/proof)
917
+ --run <name> Run name (default: HHMM)
918
+ --command <cmd> Command to run (required for terminal mode)
919
+ --test-file <file> Playwright test file (required for browser mode)
920
+ --test-name <name> Specific test name filter
921
+ --label <label> Artifact filename prefix
922
+ --mode <mode> browser | terminal | auto
923
+ --description <text> Human-readable description
924
+
925
+ JSON mode:
926
+ echo '{"action":"capture","appName":"my-app","captures":[...]}' | proof --json
927
+
928
+ Output:
929
+ JSON to stdout with recording paths and metadata.`);
930
+ }
931
+ main().catch((err) => {
932
+ console.error(JSON.stringify({ error: err.message }));
933
+ process.exit(1);
934
+ });