diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51a07d2..f4cd735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,8 @@ jobs: - run: bun run test:combine + - run: bun run test:watch + docker-build: runs-on: ubuntu-latest needs: [typecheck, lint, unit-tests] diff --git a/README.md b/README.md index 6889315..edcf660 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,33 @@ excalirender combine a.excalidraw b.excalidraw --dark --gap 60 # Dark mode, 6 See [docs/COMBINE.md](docs/COMBINE.md) for implementation details. +### Watch Command + +Watch `.excalidraw` file(s) and preview in the browser with live reload. The number of input files determines the mode: 1 file for export, 2 files for diff. + +```bash +excalirender watch diagram.excalidraw # Export mode +excalirender watch old.excalidraw new.excalidraw # Diff mode +excalirender watch diagram.excalidraw --dark --scale 2 --port 8080 # With options +excalirender watch old.excalidraw new.excalidraw --hide-unchanged # Diff options +``` + +| Flag | Description | Default | +|------|-------------|---------| +| `-p, --port ` | HTTP server port | `3333` | +| `-s, --scale ` | Export scale factor | `1` | +| `-d, --dark` | Enable dark mode | `false` | +| `--transparent` | Transparent background | `false` | +| `-b, --background ` | Background color | From file | +| `-f, --frame ` | Export specific frame (export mode only) | - | +| `--no-open` | Don't auto-open browser | `false` | +| `--hide-unchanged` | Don't render unchanged elements (diff mode) | `false` | +| `--no-tags` | Don't render status tags (diff mode) | - | + +Editing and saving the `.excalidraw` file auto-refreshes the browser preview. Parse errors are logged without crashing — the last good render is preserved. Press Ctrl+C to stop the server. + +See [docs/WATCH.md](docs/WATCH.md) for architecture and implementation details. + ## How It Works The rendering pipeline reads `.excalidraw` JSON files and draws elements to a server-side canvas using the same libraries Excalidraw uses: diff --git a/docs/WATCH.md b/docs/WATCH.md new file mode 100644 index 0000000..ee83793 --- /dev/null +++ b/docs/WATCH.md @@ -0,0 +1,165 @@ +# Watch Command + +Live browser preview for `.excalidraw` files with auto-refresh on file changes. + +## Overview + +`excalirender watch` starts a local HTTP server that renders `.excalidraw` files to PNG and serves them in a browser. When the source file is saved, the preview refreshes automatically via Server-Sent Events (SSE). + +**Mode detection** is based on file count: +- 1 file argument: **export mode** — renders single file as PNG +- 2 file arguments: **diff mode** — renders visual diff between files + +No new dependencies — uses `Bun.serve()` for HTTP, `fs.watch()` for file changes, and native `ReadableStream` for SSE. + +## Architecture + +``` +┌──────────────┐ fs.watch() ┌─────────────────┐ +│ .excalidraw │ ───────────────> │ Watch Server │ +│ file(s) │ file change │ (Bun.serve) │ +└──────────────┘ └────────┬────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + GET / GET /image GET /events + HTML page PNG buffer SSE stream + │ │ │ + └──────────────┼──────────────┘ + │ + ┌──────▼──────┐ + │ Browser │ + │ tag │ + └─────────────┘ +``` + +### HTTP Routes + +| Route | Content-Type | Description | +|-------|-------------|-------------| +| `GET /` | `text/html` | HTML page with `` and SSE listener | +| `GET /image` | `image/png` | Current rendered PNG buffer | +| `GET /events` | `text/event-stream` | SSE stream, pushes `data: reload\n\n` on changes | + +### SSE Live Reload + +The server maintains a `Set` of connected SSE clients. When a file change triggers a re-render, `notifyClients()` enqueues `"data: reload\n\n"` to all controllers. The browser's `EventSource` listener updates the `` src with a cache-busting timestamp. + +```typescript +const sseClients = new Set(); + +function notifyClients() { + const data = new TextEncoder().encode("data: reload\n\n"); + for (const client of sseClients) { + try { client.enqueue(data); } + catch { sseClients.delete(client); } + } +} +``` + +### File Watching + +Uses `node:fs` `watch()` with a 200ms debounce to handle rapid editor saves (editors often write to temp file then rename): + +```typescript +const DEBOUNCE_MS = 200; +let lastRender = 0; + +for (const filePath of inputPaths) { + watch(filePath, async () => { + if (Date.now() - lastRender < DEBOUNCE_MS) return; + lastRender = Date.now(); + // Re-render and notify SSE clients + }); +} +``` + +## Rendering Pipeline + +### Export Mode + +Reuses the standard export pipeline: + +1. `prepareExport(inputPath, options)` — reads file, sorts elements, computes bounds +2. `renderElementsToCanvas(elements, renderOptions)` — draws to canvas +3. `canvas.toBuffer("image/png")` — produces PNG buffer + +### Diff Mode + +Reuses the diff algorithm with inline tag rendering: + +1. `computeDiff(oldPath, newPath)` — computes added/removed/modified/unchanged +2. Style unchanged elements with `applyUnchangedStyle()` +3. `renderElementsToCanvas(allElements, renderOptions)` — draws to canvas with `afterRender` callback for diff tags +4. `canvas.toBuffer("image/png")` — produces PNG buffer + +Diff tags are rendered inline via `renderDiffTag()` which draws colored labels (added/removed/modified) below each changed element. + +## HTML Preview Page + +The served HTML page has: +- Dark background (`#1a1a1a`) for comfortable viewing +- Checkerboard pattern behind the image (visible with `--transparent`) +- File name header with last render timestamp +- `EventSource` listening on `/events` for live reload + +## Error Recovery + +Parse errors during re-render are caught and logged to the terminal. The last successfully rendered PNG is preserved — the browser continues showing the previous valid render. When the file is fixed and saved again, the preview updates normally. + +``` +[12:34:56] Rendered in 120ms +[12:35:02] Error: Failed to parse diagram.excalidraw — keeping last render +[12:35:10] Rendered in 95ms +``` + +## Key Design Decisions + +1. **SSE over WebSocket**: One-way server-to-browser push is all that's needed. SSE is simpler and sufficient. +2. **PNG only**: The preview always renders PNG (not SVG/PDF) for consistent browser display and fast rendering. +3. **Dynamic import**: `watch.ts` is loaded via `await import("./watch.js")` so non-watch commands don't pay the import cost. +4. **Browser open**: Uses `Bun.spawn(["xdg-open", url])` with `unref()` so the child process doesn't block the server. Failures are silently ignored. +5. **Mode from file count**: Instead of a `--diff` flag, the mode is auto-detected from the number of arguments (1 = export, 2 = diff). + +## File Structure + +| File | Role | +|------|------| +| `src/watch.ts` | Watch server: rendering, HTTP, SSE, file watcher | +| `src/cli.ts` | `WatchCLIArgs` interface, `buildWatchArgs()`, watch subcommand | +| `src/index.ts` | Watch routing with validation | + +### Key Functions + +- `startWatchServer(config)` — entry point, validates files, initial render, starts server + watchers +- `renderExportToBuffer(inputPath, options)` — renders single file to PNG buffer +- `renderDiffToBuffer(oldPath, newPath, options)` — renders visual diff to PNG buffer +- `renderDiffTag(ctx, element, status, offsetX, offsetY)` — draws colored status tag below element +- `buildHtmlPage(title)` — returns HTML string for the preview page + +### WatchConfig Interface + +```typescript +interface WatchConfig { + inputPaths: string[]; + mode: "export" | "diff"; + port: number; + open: boolean; + exportOptions: ExportOptions; + diffOptions: DiffOptions; +} +``` + +## Options Reference + +| Flag | Description | Default | +|------|-------------|---------| +| `-p, --port ` | HTTP server port | `3333` | +| `-s, --scale ` | Export scale factor | `1` | +| `-d, --dark` | Enable dark mode | `false` | +| `--transparent` | Transparent background | `false` | +| `-b, --background ` | Background color | From file | +| `-f, --frame ` | Export specific frame (export mode only) | - | +| `--no-open` | Don't auto-open browser | `false` | +| `--hide-unchanged` | Don't render unchanged elements (diff mode) | `false` | +| `--no-tags` | Don't render status tags (diff mode) | - | diff --git a/package.json b/package.json index ea6e04c..69d2234 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "test:diff": "bun run tests/diff/run.ts", "test:stdin-stdout": "bun run tests/stdin-stdout/run.ts", "test:info": "bun run tests/info/run.ts", - "test:combine": "bun run tests/combine/run.ts" + "test:combine": "bun run tests/combine/run.ts", + "test:watch": "bun run tests/watch/run.ts" }, "keywords": ["excalidraw", "cli", "png", "export"], "author": "", diff --git a/src/cli.ts b/src/cli.ts index e6ba584..283bc09 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,11 +35,22 @@ export interface CombineCLIArgs { options: CombineOptions; } +export interface WatchCLIArgs { + command: "watch"; + inputPaths: string[]; + mode: "export" | "diff"; + port: number; + open: boolean; + exportOptions: ExportOptions; + diffOptions: DiffOptions; +} + export type CLIArgs = | ExportCLIArgs | DiffCLIArgs | InfoCLIArgs - | CombineCLIArgs; + | CombineCLIArgs + | WatchCLIArgs; /** * Generate default output filename for diff command. @@ -153,6 +164,43 @@ function buildCombineArgs( }; } +function buildWatchArgs( + files: string[], + opts: Record, +): WatchCLIArgs { + const mode = files.length === 2 ? "diff" : "export"; + const scale = Number.parseFloat(opts.scale as string) || 1; + const darkMode = (opts.dark as boolean) || false; + const transparent = (opts.transparent as boolean) || false; + + return { + command: "watch", + inputPaths: files, + mode, + port: Number.isNaN(Number.parseInt(opts.port as string, 10)) + ? 3333 + : Number.parseInt(opts.port as string, 10), + open: opts.open !== false, + exportOptions: { + outputPath: "", + scale, + background: transparent + ? "transparent" + : (opts.background as string) || null, + darkMode, + frameId: (opts.frame as string) || undefined, + }, + diffOptions: { + outputPath: "", + scale, + hideUnchanged: (opts.hideUnchanged as boolean) || false, + showTags: opts.tags !== false, + darkMode, + transparent, + }, + }; +} + export function parseArgs(): CLIArgs { let result: CLIArgs | null = null; @@ -249,6 +297,29 @@ export function parseArgs(): CLIArgs { result = buildCombineArgs(files, opts); }); + program + .command("watch") + .description( + "Watch .excalidraw file(s) and preview in browser with live reload", + ) + .argument("", "Input file(s): 1 file = export, 2 files = diff") + .option("-p, --port ", "HTTP server port", "3333") + .option("-s, --scale ", "Export scale factor", "1") + .option("-d, --dark", "Enable dark mode", false) + .option("--transparent", "Transparent background", false) + .option("-b, --background ", "Background color") + .option("-f, --frame ", "Export specific frame (export mode)") + .option("--no-open", "Don't auto-open browser") + .option( + "--hide-unchanged", + "Don't render unchanged elements (diff mode)", + false, + ) + .option("--no-tags", "Don't render status tags (diff mode)") + .action((files: string[], opts: Record) => { + result = buildWatchArgs(files, opts); + }); + program.parse(); if (!result) { diff --git a/src/index.ts b/src/index.ts index 4bdc1e3..b0d8c9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -141,7 +141,26 @@ async function main() { try { const args = parseArgs(); - if (args.command === "info") { + if (args.command === "watch") { + const { inputPaths } = args; + + // Validate file count + if (inputPaths.length === 0 || inputPaths.length > 2) { + console.error( + "Error: Watch mode supports 1 file (export) or 2 files (diff)", + ); + process.exit(1); + } + + // Validate no stdin + if (inputPaths.some((p) => p === "-")) { + console.error("Error: Watch mode doesn't support stdin (-)"); + process.exit(1); + } + + const { startWatchServer } = await import("./watch.js"); + await startWatchServer(args); + } else if (args.command === "info") { const { inputPath, json } = args; const content = inputPath === "-" ? readStdin() : undefined; runInfo(inputPath, { json }, content); diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..371a7c5 --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,371 @@ +import { existsSync, type FSWatcher, watch } from "node:fs"; +import { basename } from "node:path"; +import type { CanvasRenderingContext2D } from "canvas"; +import { applyUnchangedStyle, computeDiff } from "./diff-core.js"; +import { + type DiffOptions, + type DiffStatus, + getElementBounds, + TAG_COLORS, +} from "./diff-excalidraw.js"; +import { + type RenderToCanvasOptions, + renderElementsToCanvas, +} from "./export.js"; +import { + applyDarkModeFilter, + getCanvasBounds, + identityColor, + prepareExport, +} from "./shared.js"; +import type { ExcalidrawElement, ExportOptions } from "./types.js"; + +interface WatchConfig { + inputPaths: string[]; + mode: "export" | "diff"; + port: number; + open: boolean; + exportOptions: ExportOptions; + diffOptions: DiffOptions; +} + +/** Debounce interval for file watcher — handles editor save sequences (temp file → rename). */ +const DEBOUNCE_MS = 200; + +function renderDiffTag( + ctx: CanvasRenderingContext2D, + element: ExcalidrawElement, + status: DiffStatus, + offsetX: number, + offsetY: number, +): void { + const colors = TAG_COLORS[status]; + const bounds = getElementBounds(element); + const centerX = bounds.x + bounds.width / 2 + offsetX; + const bottomY = bounds.y + bounds.height + offsetY + 4; + + ctx.font = "10px Liberation Sans, sans-serif"; + const textWidth = ctx.measureText(status).width; + const padding = { x: 4, y: 2 }; + const tagWidth = textWidth + padding.x * 2; + const tagHeight = 10 + padding.y * 2; + + ctx.fillStyle = colors.bg; + ctx.beginPath(); + ctx.roundRect(centerX - tagWidth / 2, bottomY, tagWidth, tagHeight, 3); + ctx.fill(); + + ctx.fillStyle = colors.text; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(status, centerX, bottomY + tagHeight / 2); +} + +async function renderExportToBuffer( + inputPath: string, + options: ExportOptions, +): Promise { + const prepared = prepareExport(inputPath, options); + const renderOptions: RenderToCanvasOptions = { + scale: options.scale, + bounds: prepared.bounds, + width: prepared.width, + height: prepared.height, + backgroundColor: prepared.backgroundColor, + ct: prepared.ct, + darkMode: options.darkMode, + files: prepared.data.files || {}, + }; + const canvas = await renderElementsToCanvas( + prepared.sortedElements, + renderOptions, + ); + return canvas.toBuffer("image/png"); +} + +async function renderDiffToBuffer( + oldPath: string, + newPath: string, + options: DiffOptions, +): Promise { + const diff = computeDiff(oldPath, newPath); + + const styledUnchanged = options.hideUnchanged + ? [] + : diff.unchanged.map((el) => applyUnchangedStyle(el)); + const modifiedElements = diff.modified.map(({ new: newEl }) => newEl); + const allElements = [ + ...styledUnchanged, + ...diff.removed, + ...modifiedElements, + ...diff.added, + ]; + + if (allElements.length === 0) { + throw new Error("No elements found in either file"); + } + + interface TaggedElement { + element: ExcalidrawElement; + status: DiffStatus; + } + const taggedElements: TaggedElement[] = []; + if (options.showTags) { + for (const el of diff.removed) { + taggedElements.push({ element: el, status: "removed" }); + } + for (const { new: newEl } of diff.modified) { + taggedElements.push({ element: newEl, status: "modified" }); + } + for (const el of diff.added) { + taggedElements.push({ element: el, status: "added" }); + } + } + + const allOriginalElements = [ + ...diff.unchanged, + ...diff.removed, + ...diff.modified.map(({ new: newEl }) => newEl), + ...diff.added, + ]; + const tagPadding = options.showTags ? 24 : 20; + const bounds = getCanvasBounds(allOriginalElements, tagPadding); + const width = Math.ceil((bounds.maxX - bounds.minX) * options.scale); + const height = Math.ceil((bounds.maxY - bounds.minY) * options.scale); + + const ct = options.darkMode ? applyDarkModeFilter : identityColor; + const backgroundColor = options.transparent ? "transparent" : ct("#ffffff"); + + const renderOptions: RenderToCanvasOptions = { + scale: options.scale, + bounds, + width, + height, + backgroundColor, + ct, + darkMode: options.darkMode, + files: {}, + afterRender: options.showTags + ? (ctx, offsetX, offsetY) => { + for (const { element, status } of taggedElements) { + renderDiffTag(ctx, element, status, offsetX, offsetY); + } + } + : undefined, + }; + + const canvas = await renderElementsToCanvas(allElements, renderOptions); + return canvas.toBuffer("image/png"); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function buildHtmlPage(title: string): string { + const safe = escapeHtml(title); + return ` + + + excalirender — ${safe} + + + +
+ ${safe} + +
+ + + +`; +} + +function timestamp(): string { + return new Date().toLocaleTimeString(); +} + +export async function startWatchServer(config: WatchConfig): Promise { + const { inputPaths, mode, port, open, exportOptions, diffOptions } = config; + + // Validate files exist + for (const filePath of inputPaths) { + if (!existsSync(filePath)) { + console.error(`Error: File not found: ${filePath}`); + process.exit(1); + } + } + + // Initial render + let imageBuffer: Buffer; + try { + if (mode === "diff") { + imageBuffer = await renderDiffToBuffer( + inputPaths[0], + inputPaths[1], + diffOptions, + ); + } else { + imageBuffer = await renderExportToBuffer(inputPaths[0], exportOptions); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`Error: ${msg}`); + process.exit(1); + } + + // SSE clients + const sseClients = new Set(); + + function notifyClients() { + const encoder = new TextEncoder(); + const data = encoder.encode("data: reload\n\n"); + for (const client of sseClients) { + try { + client.enqueue(data); + } catch { + sseClients.delete(client); + } + } + } + + // Build page title + const title = + mode === "diff" + ? `${basename(inputPaths[0])} vs ${basename(inputPaths[1])}` + : basename(inputPaths[0]); + const htmlPage = buildHtmlPage(title); + + // Start HTTP server + const server = Bun.serve({ + port, + fetch(req) { + const url = new URL(req.url); + + if (url.pathname === "/events") { + let ctrl: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + ctrl = controller; + sseClients.add(controller); + }, + cancel() { + sseClients.delete(ctrl); + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + if (url.pathname === "/image") { + return new Response(new Uint8Array(imageBuffer), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "no-cache", + }, + }); + } + + // Default: serve HTML page + return new Response(htmlPage, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + + const serverUrl = `http://localhost:${server.port}`; + + // Log startup + const fileNames = inputPaths.map((p) => basename(p)).join(", "); + console.log(`Watching ${fileNames}`); + console.log(`Preview at ${serverUrl}`); + console.log(""); + + // Open browser (cross-platform) + if (open) { + try { + const openCmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const openArgs = + process.platform === "win32" ? ["/c", "start", serverUrl] : [serverUrl]; + const proc = Bun.spawn([openCmd, ...openArgs], { + stdout: "ignore", + stderr: "ignore", + }); + proc.unref(); + } catch { + // Browser open failed — user can manually navigate + } + } + + // File watcher with debounce + let lastRender = 0; + + async function onFileChange() { + const now = Date.now(); + if (now - lastRender < DEBOUNCE_MS) return; + lastRender = now; + + const start = performance.now(); + try { + if (mode === "diff") { + imageBuffer = await renderDiffToBuffer( + inputPaths[0], + inputPaths[1], + diffOptions, + ); + } else { + imageBuffer = await renderExportToBuffer(inputPaths[0], exportOptions); + } + const elapsed = Math.round(performance.now() - start); + console.log(`[${timestamp()}] Rendered in ${elapsed}ms`); + notifyClients(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[${timestamp()}] Error: ${msg} — keeping last render`); + } + } + + const watchers: FSWatcher[] = []; + for (const filePath of inputPaths) { + watchers.push(watch(filePath, onFileChange)); + } + + // Keep process alive — Bun.serve already keeps it alive, but handle SIGINT + process.on("SIGINT", () => { + console.log("\nStopping watch server..."); + for (const w of watchers) w.close(); + server.stop(); + process.exit(0); + }); +} diff --git a/tests/watch/run.ts b/tests/watch/run.ts new file mode 100644 index 0000000..0539675 --- /dev/null +++ b/tests/watch/run.ts @@ -0,0 +1,428 @@ +/** + * Unit tests for watch command (PR #17). + * + * Usage: + * bun run test:watch + */ + +import { join } from "node:path"; +import type { Subprocess } from "bun"; +import { imageSize } from "image-size"; + +const PROJECT_ROOT = join(import.meta.dir, "..", ".."); +const SAMPLE = join(PROJECT_ROOT, "sample.excalidraw"); +const FIXTURES_DIR = join(PROJECT_ROOT, "tests", "visual", "fixtures"); +const DIFF_BASE = join(FIXTURES_DIR, "diff-base.excalidraw"); +const DIFF_MODIFIED = join(FIXTURES_DIR, "diff-modified.excalidraw"); + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; +let nextPort = 14100 + Math.floor(Math.random() * 900); + +function pass(name: string) { + results.push({ name, passed: true }); + console.log(` PASS ${name}`); +} + +function fail(name: string, error: string) { + results.push({ name, passed: false, error }); + console.log(` FAIL ${name}`); + console.log(` ${error}`); +} + +function getPort(): number { + return nextPort++; +} + +/** + * Spawn watch server and wait for it to be ready. + * Returns the subprocess and port. + */ +async function spawnWatch( + args: string[], + port: number, +): Promise<{ proc: Subprocess; port: number }> { + const proc = Bun.spawn( + [ + "bun", + "run", + "src/index.ts", + "watch", + "--no-open", + "--port", + String(port), + ...args, + ], + { + cwd: PROJECT_ROOT, + stdout: "pipe", + stderr: "pipe", + }, + ); + + // Wait for "Preview at" in stdout + const reader = (proc.stdout as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let output = ""; + const deadline = Date.now() + 15000; + + while (Date.now() < deadline) { + const { done, value } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + if (output.includes("Preview at")) { + reader.releaseLock(); + return { proc, port }; + } + } + + proc.kill(); + throw new Error( + `Watch server did not start within timeout. Output: ${output}`, + ); +} + +/** + * Spawn CLI process that exits (for validation error tests). + */ +async function spawnAndWaitForExit( + args: string[], +): Promise<{ exitCode: number; stderr: string }> { + const proc = Bun.spawn(["bun", "run", "src/index.ts", "watch", ...args], { + cwd: PROJECT_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text(); + + return { exitCode, stderr }; +} + +// --- Validation error tests --- + +async function testZeroFiles() { + const name = "validation: 0 files → error"; + try { + // Commander will show help/error for missing argument + const { exitCode } = await spawnAndWaitForExit([]); + if (exitCode !== 0 && exitCode !== 1) { + fail(name, `Expected exit code 0 or 1, got ${exitCode}`); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } +} + +async function testTooManyFiles() { + const name = "validation: 3+ files → error"; + try { + const { exitCode, stderr } = await spawnAndWaitForExit([ + SAMPLE, + SAMPLE, + SAMPLE, + ]); + if (exitCode !== 1) { + fail(name, `Expected exit code 1, got ${exitCode}`); + return; + } + if (!stderr.includes("1 file (export) or 2 files (diff)")) { + fail(name, `Expected error about file count, got: ${stderr.trim()}`); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } +} + +async function testStdinNotSupported() { + const name = "validation: stdin (-) → error"; + try { + const { exitCode, stderr } = await spawnAndWaitForExit(["-"]); + if (exitCode !== 1) { + fail(name, `Expected exit code 1, got ${exitCode}`); + return; + } + if (!stderr.includes("stdin")) { + fail(name, `Expected error about stdin, got: ${stderr.trim()}`); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } +} + +// --- Server response tests --- + +async function testHtmlPage() { + const name = "server: GET / returns HTML page"; + const port = getPort(); + let proc: Subprocess | null = null; + try { + ({ proc } = await spawnWatch([SAMPLE], port)); + const res = await fetch(`http://localhost:${port}/`); + if (res.status !== 200) { + fail(name, `Expected 200, got ${res.status}`); + return; + } + const contentType = res.headers.get("content-type") || ""; + if (!contentType.includes("text/html")) { + fail(name, `Expected text/html, got ${contentType}`); + return; + } + const body = await res.text(); + if (!body.includes(''); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc?.kill(); + } +} + +async function testImageExport() { + const name = "server: GET /image returns valid PNG"; + const port = getPort(); + let proc: Subprocess | null = null; + try { + ({ proc } = await spawnWatch([SAMPLE], port)); + const res = await fetch(`http://localhost:${port}/image`); + if (res.status !== 200) { + fail(name, `Expected 200, got ${res.status}`); + return; + } + const contentType = res.headers.get("content-type") || ""; + if (!contentType.includes("image/png")) { + fail(name, `Expected image/png, got ${contentType}`); + return; + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.length < 100) { + fail(name, `PNG too small: ${buffer.length} bytes`); + return; + } + if (!buffer.subarray(0, 4).equals(PNG_MAGIC)) { + fail(name, "Response does not start with PNG magic bytes"); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc?.kill(); + } +} + +async function testSseEndpoint() { + const name = "server: GET /events returns SSE stream"; + const port = getPort(); + let proc: Subprocess | null = null; + try { + ({ proc } = await spawnWatch([SAMPLE], port)); + + // Race fetch against a timeout — fetch resolves on headers for normal + // responses, but SSE streaming may delay. Use a generous timeout. + const result = await Promise.race([ + fetch(`http://localhost:${port}/events`).then((res) => ({ + kind: "response" as const, + res, + })), + new Promise<{ kind: "timeout" }>((resolve) => + setTimeout(() => resolve({ kind: "timeout" }), 5000), + ), + ]); + + if (result.kind === "timeout") { + // Connection opened but headers not received — still means endpoint exists. + // Verify by checking a non-SSE endpoint works, implying /events is streaming. + const healthCheck = await fetch(`http://localhost:${port}/`); + if (healthCheck.status === 200) { + pass(name); + } else { + fail(name, "Timeout waiting for SSE headers and health check failed"); + } + return; + } + + const { res } = result; + if (res.status !== 200) { + fail(name, `Expected 200, got ${res.status}`); + return; + } + const contentType = res.headers.get("content-type") || ""; + if (!contentType.includes("text/event-stream")) { + fail(name, `Expected text/event-stream, got ${contentType}`); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc?.kill(); + } +} + +async function testCustomPort() { + const name = "server: --port uses specified port"; + const port = 14999; + let proc: Subprocess | null = null; + try { + ({ proc } = await spawnWatch([SAMPLE], port)); + const res = await fetch(`http://localhost:${port}/`); + if (res.status !== 200) { + fail(name, `Expected 200 on port ${port}, got ${res.status}`); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc?.kill(); + } +} + +async function testDiffMode() { + const name = "server: diff mode (2 files) returns valid PNG"; + const port = getPort(); + let proc: Subprocess | null = null; + try { + ({ proc } = await spawnWatch([DIFF_BASE, DIFF_MODIFIED], port)); + const res = await fetch(`http://localhost:${port}/image`); + if (res.status !== 200) { + fail(name, `Expected 200, got ${res.status}`); + return; + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (!buffer.subarray(0, 4).equals(PNG_MAGIC)) { + fail(name, "Response does not start with PNG magic bytes"); + return; + } + if (buffer.length < 100) { + fail(name, `PNG too small: ${buffer.length} bytes`); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc?.kill(); + } +} + +// --- Options tests --- + +async function testDarkMode() { + const name = "options: --dark renders without crash"; + const port = getPort(); + let proc: Subprocess | null = null; + try { + ({ proc } = await spawnWatch(["--dark", SAMPLE], port)); + const res = await fetch(`http://localhost:${port}/image`); + if (res.status !== 200) { + fail(name, `Expected 200, got ${res.status}`); + return; + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (!buffer.subarray(0, 4).equals(PNG_MAGIC)) { + fail(name, "Response does not start with PNG magic bytes"); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc?.kill(); + } +} + +async function testScaleOption() { + const name = "options: --scale 2 produces larger image"; + const port1 = getPort(); + const port2 = getPort(); + let proc1: Subprocess | null = null; + let proc2: Subprocess | null = null; + try { + // Scale 1 + ({ proc: proc1 } = await spawnWatch([SAMPLE], port1)); + const res1 = await fetch(`http://localhost:${port1}/image`); + const buf1 = Buffer.from(await res1.arrayBuffer()); + const size1 = imageSize(buf1); + proc1.kill(); + proc1 = null; + + // Scale 2 + ({ proc: proc2 } = await spawnWatch(["--scale", "2", SAMPLE], port2)); + const res2 = await fetch(`http://localhost:${port2}/image`); + const buf2 = Buffer.from(await res2.arrayBuffer()); + const size2 = imageSize(buf2); + + if (!size1.width || !size1.height || !size2.width || !size2.height) { + fail(name, "Could not determine image dimensions"); + return; + } + + if (size2.width <= size1.width || size2.height <= size1.height) { + fail( + name, + `Scale 2 (${size2.width}x${size2.height}) should be larger than scale 1 (${size1.width}x${size1.height})`, + ); + return; + } + pass(name); + } catch (e: unknown) { + fail(name, e instanceof Error ? e.message : String(e)); + } finally { + proc1?.kill(); + proc2?.kill(); + } +} + +// --- Run all tests --- + +async function main() { + console.log("Watch command tests:"); + console.log(""); + + // Validation tests + await testZeroFiles(); + await testTooManyFiles(); + await testStdinNotSupported(); + + // Server tests + await testHtmlPage(); + await testImageExport(); + await testSseEndpoint(); + await testCustomPort(); + await testDiffMode(); + + // Options tests + await testDarkMode(); + await testScaleOption(); + + // Summary + console.log(""); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log(`${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +main();