#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); const DEFAULT_OUTPUT_DIR = join(REPO_ROOT, "docs", "assets"); const SESSION_ID = "019ef4c0-0000-7000-8000-000000000001"; const DEMO_FILE = "docs/assets/pi-web-dev-screenshot.png"; const DEFAULT_SITE_URL = "https://pi-web.dev/"; const VIEWPORTS = { desktop: { width: 1440, height: 900, mobile: false }, tablet: { width: 1024, height: 768, mobile: false }, mobile: { width: 390, height: 844, mobile: true }, }; const args = parseArgs(process.argv.slice(2)); const outputDir = resolve(args.outputDir ?? DEFAULT_OUTPUT_DIR); const keepTemp = args.keepTemp === true; const siteUrl = args.siteUrl ?? DEFAULT_SITE_URL; const chromeBin = args.chromeBin ?? process.env["CHROME_BIN"] ?? findChrome(); if (chromeBin === undefined) { fail("Chromium was not found. Install chromium-browser/chromium or set CHROME_BIN=/path/to/chrome."); } const tempRoot = await mkdtemp(join(tmpdir(), "pi-web-screenshots-")); const children = new Set(); let cleanedUp = false; process.once("SIGINT", () => { void cleanup().finally(() => process.exit(130)); }); process.once("SIGTERM", () => { void cleanup().finally(() => process.exit(143)); }); async function main() { const logsDir = join(tempRoot, "logs"); const dataDir = join(tempRoot, "pi-web-data"); const configPath = join(tempRoot, "config.json"); const sessionDir = join(tempRoot, "sessions"); const agentDir = join(tempRoot, "pi-agent"); const demoProject = join(tempRoot, "pi-web"); const projectsFile = join(dataDir, "projects.json"); const socketPath = join(dataDir, "sessiond.sock"); await Promise.all([ mkdir(logsDir, { recursive: true }), mkdir(sessionDir, { recursive: true }), mkdir(agentDir, { recursive: true }), mkdir(dataDir, { recursive: true }), mkdir(outputDir, { recursive: true }), ]); console.log(`Temporary workspace: ${tempRoot}`); await cloneDemoProject(demoProject); await removeLegacyDemoMedia(demoProject); const projectId = "pi-web-demo"; const workspaceId = createWorkspaceId(projectId, demoProject); await writeJson(projectsFile, { projects: [{ id: projectId, name: "pi-web", path: demoProject, createdAt: new Date().toISOString() }], }); await writeJson(configPath, { host: "127.0.0.1", allowedHosts: true }); await writeDemoSession(sessionDir, demoProject); const apiPort = await getFreePort(); const clientPort = await getFreePort(); const debugPort = await getFreePort(); const env = { ...process.env, PI_WEB_DATA_DIR: dataDir, PI_WEB_CONFIG: configPath, PI_WEB_PROJECTS_FILE: projectsFile, PI_WEB_SESSIOND_SOCKET: socketPath, PI_WEB_HOST: "127.0.0.1", PI_WEB_PORT: String(apiPort), PI_WEB_ALLOWED_HOSTS: "true", PI_CODING_AGENT_DIR: agentDir, PI_CODING_AGENT_SESSION_DIR: sessionDir, PI_OFFLINE: "1", NO_COLOR: "1", }; const tsxBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx"); const viteBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite"); assertExecutable(tsxBin, "Run npm install before capturing screenshots."); assertExecutable(viteBin, "Run npm install before capturing screenshots."); console.log("Starting isolated PI WEB session daemon, API server, and Vite client…"); startChild("sessiond", tsxBin, ["src/server/sessiond.ts"], { env, cwd: REPO_ROOT, logsDir }); await waitForFile(socketPath, 10_000); startChild("api", tsxBin, ["src/server/index.ts"], { env, cwd: REPO_ROOT, logsDir }); await waitForHttp(`http://127.0.0.1:${apiPort}/api/projects`, 15_000); startChild("vite", viteBin, ["--host", "127.0.0.1", "--port", String(clientPort), "--strictPort", "true"], { env, cwd: REPO_ROOT, logsDir }); await waitForHttp(`http://127.0.0.1:${clientPort}/`, 30_000); console.log("Starting Chromium and capturing screenshots…"); const chrome = startChild("chromium", chromeBin, chromeArgs(debugPort, join(tempRoot, "chrome-profile")), { env, cwd: REPO_ROOT, logsDir }); await waitForHttp(`http://127.0.0.1:${debugPort}/json/version`, 15_000); const cdp = await openPage(debugPort); try { await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); await captureWebsiteScreenshot(cdp, join(demoProject, DEMO_FILE), siteUrl); const appUrl = new URL(`http://127.0.0.1:${clientPort}/`); appUrl.searchParams.set("project", projectId); appUrl.searchParams.set("workspace", workspaceId); appUrl.searchParams.set("session", SESSION_ID); appUrl.searchParams.set("view", "chat"); await captureDesktop(cdp, appUrl, join(outputDir, "pi-web-desktop.png")); await captureDefaultApp(cdp, appUrl, VIEWPORTS.tablet, join(outputDir, "pi-web-tablet.png")); await captureDefaultApp(cdp, appUrl, VIEWPORTS.mobile, join(outputDir, "pi-web-mobile.png")); } finally { cdp.close(); chrome.kill("SIGTERM"); } console.log(`Wrote ${join(outputDir, "pi-web-desktop.png")}`); console.log(`Wrote ${join(outputDir, "pi-web-tablet.png")}`); console.log(`Wrote ${join(outputDir, "pi-web-mobile.png")}`); if (keepTemp) console.log(`Kept temporary workspace: ${tempRoot}`); } async function captureWebsiteScreenshot(cdp, outputPath, url) { await mkdir(dirname(outputPath), { recursive: true }); await setViewport(cdp, { width: 1280, height: 720, mobile: false }); try { await navigate(cdp, url, 20_000); await waitForDocumentFonts(cdp); await sleep(3500); await capturePng(cdp, outputPath); } catch (error) { console.warn(`Unable to capture ${url}; using a local fallback image. ${error instanceof Error ? error.message : String(error)}`); const fallback = `data:text/html,${encodeURIComponent(fallbackWebsiteHtml(url))}`; await navigate(cdp, fallback, 10_000); await sleep(300); await capturePng(cdp, outputPath); } } async function captureDesktop(cdp, appUrl, outputPath) { await setViewport(cdp, VIEWPORTS.desktop); await navigate(cdp, appUrl.href, 15_000); await waitForApp(cdp); await selectPreviewImage(cdp); await sleep(500); await capturePng(cdp, outputPath); } async function captureDefaultApp(cdp, appUrl, viewport, outputPath) { await setViewport(cdp, viewport); await navigate(cdp, appUrl.href, 15_000); await waitForApp(cdp); await sleep(700); await capturePng(cdp, outputPath); } async function selectPreviewImage(cdp) { await evaluate(cdp, `(async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const app = document.querySelector("pi-web-app"); if (!app) throw new Error("pi-web-app not found"); if (typeof app.openWorkspaceTool !== "function" || app.files === undefined) { throw new Error("PI WEB app internals needed for deterministic screenshot setup were not available"); } app.openWorkspaceTool("core:workspace.files"); await app.updateComplete; await sleep(900); await app.files.refreshFiles(); await app.updateComplete; if (app.state?.expandedDirs?.["docs"] === undefined) await app.files.expandDir("docs"); await app.updateComplete; if (app.state?.expandedDirs?.["docs/assets"] === undefined) await app.files.expandDir("docs/assets"); await app.updateComplete; await app.files.selectFile(${JSON.stringify(DEMO_FILE)}); await app.updateComplete; const panel = app.shadowRoot?.querySelector("workspace-panel"); const root = panel?.shadowRoot; await panel?.updateComplete; const start = Date.now(); while (Date.now() - start < 8000) { const image = root?.querySelector(".image-preview img"); if (root?.textContent.includes(${JSON.stringify(DEMO_FILE)}) && image instanceof HTMLImageElement && image.complete) return true; await sleep(100); } throw new Error("Timed out waiting for image preview"); })()`); } async function waitForApp(cdp) { await evaluate(cdp, `new Promise((resolve, reject) => { const start = Date.now(); const visibleText = () => { const app = document.querySelector("pi-web-app"); const appRoot = app?.shadowRoot; const chatRoot = appRoot?.querySelector("chat-view")?.shadowRoot; return [appRoot?.textContent ?? "", chatRoot?.textContent ?? ""].join("\\n"); }; const check = () => { const text = visibleText(); if (text.includes("Showing messages") && text.includes("assistant")) { resolve(true); return; } if (Date.now() - start > 15000) { reject(new Error("PI WEB app did not restore the seeded session in time. Visible text: " + visibleText())); return; } setTimeout(check, 100); }; check(); })`); } async function waitForDocumentFonts(cdp) { try { await evaluate(cdp, `document.fonts?.ready?.then(() => true) ?? true`); } catch { // Font loading is best-effort; screenshots still work with fallback fonts. } } async function navigate(cdp, url, timeoutMs) { const loaded = cdp.waitForEvent("Page.loadEventFired", timeoutMs).catch(() => undefined); await cdp.send("Page.navigate", { url }); await loaded; } async function setViewport(cdp, viewport) { await cdp.send("Emulation.setDeviceMetricsOverride", { width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: viewport.mobile, }); } async function capturePng(cdp, outputPath) { await mkdir(dirname(outputPath), { recursive: true }); const { data } = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false }); await writeFile(outputPath, Buffer.from(data, "base64")); } async function cloneDemoProject(target) { const result = spawnSync("git", ["clone", "--quiet", "--local", "--no-hardlinks", REPO_ROOT, target], { cwd: REPO_ROOT, encoding: "utf8", }); if (result.status !== 0) throw new Error(`git clone failed:\n${result.stderr || result.stdout}`); } async function removeLegacyDemoMedia(projectRoot) { await Promise.all([ rm(join(projectRoot, "docs", "assets", "pi-web-demo.gif"), { force: true }), rm(join(projectRoot, "docs", "assets", "pi-web-demo.webm"), { force: true }), rm(join(projectRoot, "docs", "assets", "pi-web-demo-flow.gif"), { force: true }), ]); } async function writeDemoSession(sessionDir, cwd) { const now = new Date(); const timestamp = now.toISOString(); const file = join(sessionDir, `${timestamp.replaceAll(":", "-")}_${SESSION_ID}.jsonl`); const ms = now.getTime(); const entries = [ { type: "session", version: 3, id: SESSION_ID, timestamp, cwd }, { type: "model_change", id: "10000001", parentId: null, timestamp: iso(ms + 100), provider: "openai-codex", modelId: "gpt-5.5" }, { type: "thinking_level_change", id: "10000002", parentId: "10000001", timestamp: iso(ms + 200), thinkingLevel: "off" }, { type: "message", id: "10000003", parentId: "10000002", timestamp: iso(ms + 1000), message: { role: "user", content: [{ type: "text", text: "Take a screenshot of https://pi-web.dev, save it under docs/assets, and tell me where I can preview it." }], timestamp: ms + 1000, }, }, { type: "session_info", id: "10000004", parentId: "10000003", timestamp: iso(ms + 1100), name: "Screenshot pi-web.dev" }, { type: "message", id: "10000005", parentId: "10000004", timestamp: iso(ms + 2000), message: { role: "assistant", content: [{ type: "toolCall", id: "call_demo_screenshot", name: "bash", arguments: { command: `capture-browser-screenshot https://pi-web.dev ${DEMO_FILE}` }, }], api: "openai-codex-responses", provider: "openai-codex", model: "gpt-5.5", usage: zeroUsage(), stopReason: "toolUse", timestamp: ms + 2000, }, }, { type: "message", id: "10000006", parentId: "10000005", timestamp: iso(ms + 3000), message: { role: "toolResult", toolCallId: "call_demo_screenshot", toolName: "bash", content: [{ type: "text", text: `Saved screenshot to ${DEMO_FILE}` }], isError: false, timestamp: ms + 3000, }, }, { type: "message", id: "10000007", parentId: "10000006", timestamp: iso(ms + 4000), message: { role: "assistant", content: [{ type: "text", text: `Done — I saved the screenshot at \`${DEMO_FILE}\`. Open the Files panel to preview it.` }], api: "openai-codex-responses", provider: "openai-codex", model: "gpt-5.5", usage: zeroUsage(), stopReason: "stop", timestamp: ms + 4000, }, }, ]; await writeFile(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); } function fallbackWebsiteHtml(url) { return `
PI WEB

pi-web.dev

Fallback screenshot for ${escapeHtml(url)}.

`; } function chromeArgs(debugPort, userDataDir) { return [ "--headless=new", `--remote-debugging-port=${debugPort}`, "--remote-debugging-address=127.0.0.1", "--remote-allow-origins=*", `--user-data-dir=${userDataDir}`, "--window-size=1440,900", "--force-device-scale-factor=1", "--hide-scrollbars", "--disable-background-networking", "--disable-dev-shm-usage", "--disable-gpu", "--disable-extensions", "--disable-features=Translate,MediaRouter,OptimizationHints", "--no-default-browser-check", "--no-first-run", "--no-sandbox", "about:blank", ]; } function startChild(name, command, childArgs, { env, cwd, logsDir }) { const logPath = join(logsDir, `${name}.log`); const child = spawn(command, childArgs, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); children.add(child); const chunks = []; const collect = (chunk) => { chunks.push(Buffer.from(chunk)); if (chunks.length > 120) chunks.shift(); }; child.stdout.on("data", collect); child.stderr.on("data", collect); child.stdout.on("data", (chunk) => appendLog(logPath, chunk)); child.stderr.on("data", (chunk) => appendLog(logPath, chunk)); child.once("exit", (code, signal) => { children.delete(child); if (!cleanedUp && code !== 0 && signal === null) { const recent = Buffer.concat(chunks).toString("utf8").trim(); console.error(`${name} exited with code ${code}. Recent log:\n${recent}`); } }); return child; } function appendLog(path, chunk) { void mkdir(dirname(path), { recursive: true }) .then(() => writeFile(path, chunk, { flag: "a" })) .catch(() => undefined); } async function cleanup() { if (cleanedUp) return; cleanedUp = true; await Promise.all([...children].map((child) => terminate(child))); if (!keepTemp) await rm(tempRoot, { recursive: true, force: true }); } async function terminate(child) { if (child.exitCode !== null || child.signalCode !== null) return; child.kill("SIGTERM"); await Promise.race([ new Promise((resolve) => child.once("exit", resolve)), sleep(2500).then(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }), ]); } async function waitForHttp(url, timeoutMs) { const start = Date.now(); let lastError; while (Date.now() - start < timeoutMs) { try { const response = await fetch(url); if (response.ok) return; lastError = new Error(`${response.status} ${response.statusText}`); } catch (error) { lastError = error; } await sleep(150); } throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); } async function waitForFile(path, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (existsSync(path)) return; await sleep(100); } throw new Error(`Timed out waiting for ${path}`); } async function getFreePort() { return new Promise((resolve, reject) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address !== null ? address.port : undefined; server.close(() => { if (port === undefined) reject(new Error("Unable to allocate a port")); else resolve(port); }); }); server.on("error", reject); }); } async function openPage(debugPort) { const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT" }); if (!response.ok) throw new Error(`Unable to create Chromium tab: ${response.status} ${response.statusText}`); const info = await response.json(); return CDP.connect(info.webSocketDebuggerUrl); } async function evaluate(cdp, expression) { const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (response.exceptionDetails !== undefined) throw new Error(`Browser evaluation failed: ${JSON.stringify(response.exceptionDetails)}`); return response.result?.value; } class CDP { static connect(url) { return new Promise((resolve, reject) => { const ws = new WebSocket(url); const cdp = new CDP(ws); ws.addEventListener("open", () => resolve(cdp), { once: true }); ws.addEventListener("error", (event) => reject(event.error ?? new Error("CDP websocket error")), { once: true }); }); } constructor(ws) { this.ws = ws; this.nextId = 1; this.pending = new Map(); this.listeners = new Map(); ws.addEventListener("message", (event) => this.onMessage(event)); ws.addEventListener("close", () => { for (const { reject } of this.pending.values()) reject(new Error("CDP websocket closed")); this.pending.clear(); }); } send(method, params = {}) { const id = this.nextId++; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); } waitForEvent(method, timeoutMs) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { cleanupListener(); reject(new Error(`Timed out waiting for ${method}`)); }, timeoutMs); const listener = (params) => { cleanupListener(); resolve(params); }; const cleanupListener = () => { clearTimeout(timer); const listeners = this.listeners.get(method) ?? []; this.listeners.set(method, listeners.filter((candidate) => candidate !== listener)); }; this.listeners.set(method, [...this.listeners.get(method) ?? [], listener]); }); } close() { this.ws.close(); } onMessage(event) { const message = JSON.parse(String(event.data)); if (message.id !== undefined) { const pending = this.pending.get(message.id); if (pending === undefined) return; this.pending.delete(message.id); if (message.error !== undefined) pending.reject(new Error(JSON.stringify(message.error))); else pending.resolve(message.result ?? {}); return; } if (message.method !== undefined) { for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {}); } } } function createWorkspaceId(projectId, path) { return createHash("sha1").update(`${projectId}:${path}`).digest("hex").slice(0, 12); } async function writeJson(path, value) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function iso(ms) { return new Date(ms).toISOString(); } function zeroUsage() { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } function parseArgs(argv) { const parsed = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--help" || arg === "-h") { console.log(`Usage: node scripts/capture-screenshots.mjs [--output-dir docs/assets] [--site-url https://pi-web.dev/] [--keep-temp] [--chrome-bin /path/to/chrome]\n\nCaptures desktop, tablet, and mobile PI WEB screenshots from an isolated temporary instance.`); process.exit(0); } if (arg === "--keep-temp") { parsed.keepTemp = true; continue; } if (arg === "--output-dir") { parsed.outputDir = requireValue(argv, ++i, arg); continue; } if (arg.startsWith("--output-dir=")) { parsed.outputDir = arg.slice("--output-dir=".length); continue; } if (arg === "--site-url") { parsed.siteUrl = requireValue(argv, ++i, arg); continue; } if (arg.startsWith("--site-url=")) { parsed.siteUrl = arg.slice("--site-url=".length); continue; } if (arg === "--chrome-bin") { parsed.chromeBin = requireValue(argv, ++i, arg); continue; } if (arg.startsWith("--chrome-bin=")) { parsed.chromeBin = arg.slice("--chrome-bin=".length); continue; } fail(`Unknown argument: ${arg}`); } return parsed; } function requireValue(argv, index, flag) { const value = argv[index]; if (value === undefined || value.startsWith("--")) fail(`${flag} requires a value`); return value; } function findChrome() { return findExecutable(["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]); } function findExecutable(candidates) { for (const candidate of candidates) { const result = spawnSync("sh", ["-lc", `command -v ${shellQuote(candidate)}`], { encoding: "utf8" }); if (result.status === 0) return result.stdout.trim().split("\n")[0]; } return undefined; } function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; } function assertExecutable(path, message) { if (!existsSync(path)) fail(`${path} was not found. ${message}`); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function escapeHtml(value) { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } function fail(message) { console.error(message); process.exit(1); } try { await main(); } finally { await cleanup(); }