diff --git a/.gitignore b/.gitignore index 79a538f..b99ee86 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ data/ test-results/ playwright-report/ +# Demo recorder (dev-only, not part of the workspace) +scripts/record-demo/node_modules/ +scripts/record-demo/out/ +scripts/record-demo/.tmp/ + # macOS .DS_Store diff --git a/AGENTS.md b/AGENTS.md index ef81ff2..1c8bab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,16 @@ CI (`.github/workflows/ci.yml`) and CHANGELOG.md's compare links must target `ma `main`/`master` mismatch left CI structurally unable to fire on any PR. Check this whenever adding or touching a workflow trigger or a branch-referencing doc link. +## Regenerating the README demo (`docs/assets/demo.mp4`/`demo.gif`) + +`make demo` (or `scripts/record-demo/`'s own README) boots a disposable Postgres + the +dashboard + `examples/demo-site`, drives the real product loop with Playwright, and converts +the recording with ffmpeg. It runs the dashboard via `next start` against a production +build, not `next dev` - the dev-mode build indicator overlay (`nextjs-portal`) intercepts +Playwright's clicks and made the recorder flaky. `scripts/record-demo` is a standalone +package outside `pnpm-workspace.yaml`'s `packages/*` glob (dev-only, never a product +dependency). + ## Maintaining this file Keep this file for knowledge useful to almost every future agent session in this project. diff --git a/Makefile b/Makefile index ddf7777..558e6c5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dev test lint tidy clean +.PHONY: build dev test lint tidy clean demo build: pnpm --filter @diffboard/core run build @@ -23,3 +23,10 @@ tidy: clean: rm -rf packages/*/dist packages/server/.next node_modules packages/*/node_modules + +# Boots the real stack, records a genuine walkthrough, and converts it into +# docs/assets/demo.mp4 + demo.gif for the README. See scripts/record-demo/README.md. +demo: + cd scripts/record-demo && npm install + cd scripts/record-demo && npm run record + cd scripts/record-demo && npm run convert diff --git a/README.md b/README.md index af87ca7..607c2d3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,15 @@ each change before the build goes green. +## Demo + +![diffboard demo](docs/assets/demo.gif) + +A real run against the demo fixture in this repo: sign in, capture a baseline, ship a real CSS +change to the CTA button, then triage the flagged diff in the review dashboard - approving the +desktop screenshot (the intended rebrand) and rejecting the mobile one, where the same change +shrinks the button below a usable tap target. Full quality: [docs/assets/demo.mp4](docs/assets/demo.mp4). + ## What it does - Captures screenshots of any URL or Storybook story across a viewport matrix, driven by a @@ -36,12 +45,6 @@ each change before the build goes green. - Runs entirely on infrastructure you already have: `docker compose up` gets you Postgres + the dashboard, with no per-snapshot billing and no third party watching your screenshots -Reviewing a visual diff on the diffboard dashboard: a CTA button's padding and color changed, shown with a slider comparison, then approved - -*A real recording against the running app in this repo: a CSS change shrinks and washes out -a button, `diffboard test` flags it as changed in CI, and the reviewer compares it on the -dashboard before approving.* - ## Why not Percy or Chromatic? Percy and Chromatic are good products, billed per screenshot. Chromatic's free tier caps out diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 95ffe7e..d375d22 100644 Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ diff --git a/docs/assets/demo.mp4 b/docs/assets/demo.mp4 new file mode 100644 index 0000000..f72299f Binary files /dev/null and b/docs/assets/demo.mp4 differ diff --git a/scripts/record-demo/README.md b/scripts/record-demo/README.md new file mode 100644 index 0000000..d82cbbc --- /dev/null +++ b/scripts/record-demo/README.md @@ -0,0 +1,61 @@ +# Demo recorder + +Boots the real diffboard stack from source and records a genuine walkthrough of the product +loop: sign in, create a project, capture a baseline against `examples/demo-site`, ship a real +visual change, and triage the resulting diff in the review dashboard (approve one screenshot, +reject another). Produces `docs/assets/demo.mp4` and `docs/assets/demo.gif` for the README. + +This package is dev-only - it lives outside `pnpm-workspace.yaml`'s `packages/*` glob, so it +is never pulled into the product build or `pnpm install` at the repo root. + +## Run it + +From the repo root: + +```bash +make demo +``` + +Or step by step from this directory: + +```bash +npm install +npm run record # boots Postgres (docker), the dashboard, and demo-site; drives the browser; saves out/demo.raw.webm +npm run convert # ffmpeg: out/demo.raw.webm -> docs/assets/demo.mp4 + demo.gif +``` + +## What `record.mjs` does + +1. Builds `@diffboard/core`, the CLI, and the server (`next build` - a **production** build; + `next dev`'s dev-mode overlay intercepts clicks and was flaky under this recorder). +2. Starts a fresh, disposable Postgres container (`diffboard-demo-pg`, host port `5434` so it + doesn't collide with any dev Postgres on `5432`/`5433`), runs migrations, and seeds the + admin account. +3. Starts the dashboard (`next start`, port `4300`) and `examples/demo-site` (port `3100`, + `DEMO_VARIANT=v1`). +4. Drives a headless Chromium via Playwright (`recordVideo`, 1280x800, + `deviceScaleFactor: 2`) through the real UI: sign in, create a project, capture its token + from the page. +5. Shells out to the real `diffboard` CLI twice - once against `v1` (baseline) and once + against `v2` (a real CSS change already in the fixture: a shrunk, washed-out CTA button) - + and navigates the browser to each resulting build. +6. Reviews the second build's real diff in the dashboard: drags the slider, switches to + side-by-side and diff-overlay, approves the desktop screenshot (the rebrand is the + intended change) and rejects the mobile one (the same CSS change shrinks the button below a + usable tap target on a small screen - a genuine regression, not a fabricated one). +7. Stops every child process and removes the Postgres container so the next run starts clean. + +Re-running against a freshly seeded stack produces the same walkthrough - there's no random +data and no reliance on state left over from a previous run. + +## Ports + +Only `:4300` is a fixed requirement (the app). If `:4300` is already taken by something else, +this fails fast rather than rewriting the committed port - free it and re-run. `:3100` (demo +site) and `:5434` (recorder's own throwaway Postgres) can be changed at the top of +`record.mjs` if they collide with something on your machine. + +## Debugging + +Child process output (the dashboard and demo-site) is captured to `.tmp/.log` instead +of being discarded, since Next's production server is otherwise silent on success. diff --git a/scripts/record-demo/convert.sh b/scripts/record-demo/convert.sh new file mode 100755 index 0000000..a6c6bbd --- /dev/null +++ b/scripts/record-demo/convert.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Converts the raw Playwright recording (out/demo.raw.webm) into the two +# assets the README embeds: docs/assets/demo.mp4 (full quality, linked) and +# docs/assets/demo.gif (embedded inline - must stay under 10 MB). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +RAW="$HERE/out/demo.raw.webm" +MP4="$REPO_ROOT/docs/assets/demo.mp4" +GIF="$REPO_ROOT/docs/assets/demo.gif" +PALETTE="$HERE/out/palette.png" + +if [ ! -f "$RAW" ]; then + echo "No raw recording at $RAW - run 'npm run record' first." >&2 + exit 1 +fi + +mkdir -p "$REPO_ROOT/docs/assets" + +echo "Encoding $MP4 ..." +ffmpeg -y -i "$RAW" -vf "scale=1280:-2" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$MP4" + +echo "Building palette for the GIF ..." +ffmpeg -y -i "$RAW" -vf "fps=12,scale=960:-2:flags=lanczos,palettegen" "$PALETTE" + +echo "Encoding $GIF ..." +ffmpeg -y -i "$RAW" -i "$PALETTE" \ + -lavfi "fps=12,scale=960:-2:flags=lanczos[x];[x][1:v]paletteuse" \ + "$GIF" + +rm -f "$PALETTE" + +MP4_SIZE=$(du -h "$MP4" | cut -f1) +GIF_SIZE=$(du -h "$GIF" | cut -f1) +echo "Done: $MP4 ($MP4_SIZE), $GIF ($GIF_SIZE)" + +GIF_BYTES=$(wc -c < "$GIF") +if [ "$GIF_BYTES" -gt 10485760 ]; then + echo "WARNING: $GIF is over 10 MB ($GIF_BYTES bytes) - drop fps or shorten the walkthrough." >&2 + exit 1 +fi diff --git a/scripts/record-demo/package-lock.json b/scripts/record-demo/package-lock.json new file mode 100644 index 0000000..f443959 --- /dev/null +++ b/scripts/record-demo/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "diffboard-record-demo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "diffboard-record-demo", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.62.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/scripts/record-demo/package.json b/scripts/record-demo/package.json new file mode 100644 index 0000000..d19997e --- /dev/null +++ b/scripts/record-demo/package.json @@ -0,0 +1,14 @@ +{ + "name": "diffboard-record-demo", + "private": true, + "version": "0.0.0", + "description": "Dev-only recorder that boots the real diffboard stack and captures docs/assets/demo.mp4 + demo.gif. Not part of the pnpm workspace or product build.", + "type": "module", + "scripts": { + "record": "node record.mjs", + "convert": "bash convert.sh" + }, + "devDependencies": { + "@playwright/test": "^1.62.1" + } +} diff --git a/scripts/record-demo/record.mjs b/scripts/record-demo/record.mjs new file mode 100644 index 0000000..c88e44b --- /dev/null +++ b/scripts/record-demo/record.mjs @@ -0,0 +1,361 @@ +#!/usr/bin/env node +// Boots the real diffboard stack (fresh Postgres, dashboard, demo-site fixture), +// drives it through the real product loop with Playwright, and records a video. +// Re-running this against a freshly seeded stack produces the same walkthrough. +import { chromium } from "@playwright/test"; +import { spawn, execSync, execFileSync } from "node:child_process"; +import { + mkdirSync, + writeFileSync, + renameSync, + readdirSync, + rmSync, + existsSync, + openSync, +} from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, "..", ".."); +const OUT_DIR = join(HERE, "out"); +const TMP_DIR = join(HERE, ".tmp"); + +const PG_CONTAINER = "diffboard-demo-pg"; +const PG_PORT = 5434; +const APP_PORT = 4300; +const DEMO_PORT = 3100; + +const ADMIN_EMAIL = "admin@diffboard.dev"; +const ADMIN_PASSWORD = "DiffboardDemo123!"; +const SESSION_SECRET = "demo-recording-session-secret-not-for-production"; +const DATABASE_URL = `postgresql://diffboard:diffboard@localhost:${PG_PORT}/diffboard`; + +const children = []; + +function log(msg) { + console.log(`[record-demo] ${msg}`); +} + +function sh(cmd, opts = {}) { + log(`$ ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd: REPO_ROOT, ...opts }); +} + +function shOut(cmd, opts = {}) { + return execSync(cmd, { encoding: "utf8", cwd: REPO_ROOT, ...opts }); +} + +function spawnChild(name, cmd, args, opts = {}) { + const logPath = join(TMP_DIR, `${name}.log`); + const fd = openSync(logPath, "a"); + const child = spawn(cmd, args, { stdio: ["ignore", fd, fd], ...opts }); + children.push(child); + return child; +} + +async function waitForHttp(url, timeoutMs = 60_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url, { redirect: "manual" }); + if (res.status < 500) return; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +async function waitForDown(url, timeoutMs = 10_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await fetch(url, { redirect: "manual" }); + } catch { + return; + } + await new Promise((r) => setTimeout(r, 200)); + } +} + +function buildWorkspace() { + log("building @diffboard/core, the CLI, and the server (production build)"); + sh("pnpm --filter @diffboard/core run build"); + sh("pnpm --filter diffboard run build"); + sh("pnpm --filter @diffboard/server run build", { env: { ...process.env, DATABASE_URL } }); +} + +function bootPostgres() { + log("booting fresh Postgres container for the recording"); + try { + execSync(`docker rm -f ${PG_CONTAINER}`, { stdio: "ignore" }); + } catch { + // didn't exist + } + sh( + `docker run -d --name ${PG_CONTAINER} -e POSTGRES_USER=diffboard -e POSTGRES_PASSWORD=diffboard -e POSTGRES_DB=diffboard -p ${PG_PORT}:5432 postgres:16-alpine`, + ); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + execSync(`docker exec ${PG_CONTAINER} pg_isready -U diffboard`, { stdio: "ignore" }); + return; + } catch { + // not ready yet + } + } + throw new Error("Postgres never became ready"); +} + +function migrateAndSeed() { + log("running prisma migrate deploy + seed"); + const serverDir = join(REPO_ROOT, "packages", "server"); + const env = { ...process.env, DATABASE_URL }; + execSync("npx prisma migrate deploy", { cwd: serverDir, env, stdio: "inherit" }); + execSync("npx tsx prisma/seed.ts", { + cwd: serverDir, + env: { ...env, ADMIN_EMAIL, ADMIN_PASSWORD }, + stdio: "inherit", + }); +} + +async function startServer() { + log(`starting dashboard on :${APP_PORT}`); + const serverDir = join(REPO_ROOT, "packages", "server"); + spawnChild("server", "npx", ["next", "start", "-p", String(APP_PORT)], { + cwd: serverDir, + env: { + ...process.env, + DATABASE_URL, + SESSION_SECRET, + ADMIN_EMAIL, + ADMIN_PASSWORD, + STORAGE_DIR: join(TMP_DIR, "storage"), + PUBLIC_URL: `http://localhost:${APP_PORT}`, + PORT: String(APP_PORT), + }, + }); + await waitForHttp(`http://localhost:${APP_PORT}/login`); +} + +function startDemoSite(variant) { + log(`starting demo-site (${variant}) on :${DEMO_PORT}`); + const demoDir = join(REPO_ROOT, "examples", "demo-site"); + return spawnChild(`demo-site-${variant}`, "node", ["server.js"], { + cwd: demoDir, + env: { ...process.env, DEMO_VARIANT: variant, PORT: String(DEMO_PORT) }, + }); +} + +function stopChild(child) { + if (!child || child.killed) return; + try { + child.kill("SIGTERM"); + } catch { + // already gone + } +} + +function writeCliConfig(token) { + const configPath = join(TMP_DIR, "diffboard.config.mjs"); + writeFileSync( + configPath, + `export default ${JSON.stringify( + { + serverUrl: `http://localhost:${APP_PORT}`, + token, + baseBranch: "main", + threshold: 0.1, + viewports: [ + { name: "desktop", width: 1280, height: 800 }, + { name: "mobile", width: 390, height: 844 }, + ], + targets: [{ name: "home", url: `http://localhost:${DEMO_PORT}/` }], + }, + null, + 2, + )};\n`, + ); + return configPath; +} + +function runCli(configPath) { + const cliBin = join(REPO_ROOT, "packages", "cli", "dist", "bin.js"); + log("running `diffboard test` against the running demo-site"); + try { + return shOut(`node ${JSON.stringify(cliBin)} test --config ${JSON.stringify(configPath)}`); + } catch (err) { + // Exit code 1 just means "new or changed screenshots need review" - expected. + return err.stdout?.toString() ?? ""; + } +} + +function buildUrlFromCliOutput(output) { + const match = output.match(/http:\/\/localhost:\d+\/builds\/[^\s]+/); + if (!match) throw new Error(`Could not find build URL in CLI output:\n${output}`); + return match[0]; +} + +async function dragSlider(page) { + const frame = page.locator(".slider-wrap"); + const box = await frame.boundingBox(); + if (!box) return; + await page.mouse.move(box.x + box.width * 0.15, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.85, box.y + box.height / 2, { steps: 24 }); + await page.waitForTimeout(1100); + await page.mouse.move(box.x + box.width * 0.4, box.y + box.height / 2, { steps: 16 }); + await page.waitForTimeout(900); + await page.mouse.up(); +} + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + mkdirSync(TMP_DIR, { recursive: true }); + + buildWorkspace(); + bootPostgres(); + migrateAndSeed(); + await startServer(); + startDemoSite("v1"); + await waitForHttp(`http://localhost:${DEMO_PORT}/`); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + recordVideo: { dir: OUT_DIR, size: { width: 1280, height: 800 } }, + }); + const page = await context.newPage(); + + try { + // --- Sign in --- + await page.goto(`http://localhost:${APP_PORT}/login`); + await page.waitForTimeout(2650); + await page.locator("#email").fill(ADMIN_EMAIL); + await page.waitForTimeout(650); + await page.locator("#password").fill(ADMIN_PASSWORD); + await page.waitForTimeout(650); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(`http://localhost:${APP_PORT}/`); + await page.waitForTimeout(1750); + + // --- Create the project (first-run form is shown automatically) --- + await page.locator("#name").fill("demo-site"); + await page.waitForTimeout(1100); + await page.locator('button:has-text("Create project")').click(); + await page.waitForSelector(".token-box"); + await page.waitForTimeout(3950); + + const tokenText = (await page.locator(".token-box").textContent())?.trim(); + if (!tokenText) throw new Error("Could not read project token from dashboard"); + + await page.locator('button:has-text("Done")').click(); + await page.waitForURL(`http://localhost:${APP_PORT}/`); + await page.waitForTimeout(1750); + + await page.locator('a:has-text("demo-site")').click(); + await page.waitForURL(/\/projects\/demo-site/); + await page.waitForTimeout(2200); + + // --- Baseline run: capture the demo-site as it stands today --- + const configPath = writeCliConfig(tokenText); + const baselineOutput = runCli(configPath); + const baselineBuildUrl = buildUrlFromCliOutput(baselineOutput); + + await page.goto(baselineBuildUrl); + await page.waitForSelector(".snapshot-row"); + await page.waitForTimeout(2650); + + // First run has no baseline yet - approve both to establish one. + for (const viewport of ["desktop", "mobile"]) { + await page.locator(`.snapshot-row:has-text("${viewport}")`).click(); + await page.waitForTimeout(1300); + await Promise.all([ + page.waitForResponse((r) => r.url().includes("/approve")), + page.locator('button:has-text("Approve")').click(), + ]); + await page.waitForTimeout(2000); + } + + await page.goto(`http://localhost:${APP_PORT}/projects/demo-site`); + await page.waitForTimeout(2200); + + // --- Make a real visual change to the demo site and check again --- + const v1 = children.pop(); + stopChild(v1); + await waitForDown(`http://localhost:${DEMO_PORT}/`); + startDemoSite("v2"); + await waitForHttp(`http://localhost:${DEMO_PORT}/`); + + const changedOutput = runCli(configPath); + const changedBuildUrl = buildUrlFromCliOutput(changedOutput); + + await page.goto(changedBuildUrl); + await page.waitForSelector(".snapshot-row"); + await page.waitForTimeout(2650); + + // Desktop: review the diff, then approve as the intended rebrand. + await page.locator('.snapshot-row:has-text("desktop")').click(); + await page.waitForTimeout(1750); + await dragSlider(page); + await page.locator('.diff-toggle button:has-text("Side by side")').click(); + await page.waitForTimeout(3100); + await page.locator('.diff-toggle button:has-text("Diff overlay")').click(); + await page.waitForTimeout(3500); + await Promise.all([ + page.waitForResponse((r) => r.url().includes("/approve")), + page.locator('button:has-text("Approve")').click(), + ]); + await page.waitForTimeout(2000); + + // Mobile: the same CSS change shrinks the CTA below a usable tap target - + // flag it as a genuine regression instead of rubber-stamping it. + await page.locator('.snapshot-row:has-text("mobile")').click(); + await page.waitForTimeout(1750); + await page.locator('.diff-toggle button:has-text("Slider")').click(); + await page.waitForTimeout(650); + await dragSlider(page); + await page.locator('.diff-toggle button:has-text("Diff overlay")').click(); + await page.waitForTimeout(3100); + await Promise.all([ + page.waitForResponse((r) => r.url().includes("/reject")), + page.locator('button:has-text("Reject")').click(), + ]); + await page.waitForTimeout(2200); + + await page.goto(`http://localhost:${APP_PORT}/projects/demo-site`); + await page.waitForTimeout(3950); + } finally { + await context.close(); + await browser.close(); + } + + const rawPath = join(OUT_DIR, "demo.raw.webm"); + if (existsSync(rawPath)) rmSync(rawPath); + const videoFiles = readdirSync(OUT_DIR).filter((f) => f.endsWith(".webm")); + if (videoFiles.length === 0) throw new Error("Playwright did not produce a video file"); + renameSync(join(OUT_DIR, videoFiles[0]), rawPath); + for (const f of videoFiles.slice(1)) rmSync(join(OUT_DIR, f)); + log(`raw recording saved to ${rawPath}`); + + for (const child of children) stopChild(child); + try { + execSync(`docker rm -f ${PG_CONTAINER}`, { stdio: "ignore" }); + } catch { + // already gone + } +} + +main().catch((err) => { + console.error(err); + for (const child of children) stopChild(child); + try { + execFileSync("docker", ["rm", "-f", PG_CONTAINER], { stdio: "ignore" }); + } catch { + // already gone + } + process.exitCode = 1; +});