From 93cec5aa6c5539830ca316669e090042e011182c Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:37:13 -0400 Subject: [PATCH] feat(plan-canvas): download artifacts as PDF --- .agents/skills/plan-canvas/SKILL.md | 3 + docs/design/plan-canvas.md | 18 +- docs/testing/plan-canvas-pdf-export.tdd.md | 50 ++++ scripts/lib/plan-canvas/pdf.js | 261 +++++++++++++++++++++ scripts/lib/plan-canvas/server.js | 37 ++- scripts/lib/plan-canvas/ui.js | 68 +++++- scripts/plan-canvas.js | 3 +- skills/plan-canvas/SKILL.md | 3 + tests/integration/plan-canvas-e2e.test.js | 2 +- tests/scripts/plan-canvas-pdf.test.js | 147 ++++++++++++ tests/scripts/plan-canvas.test.js | 24 +- 11 files changed, 608 insertions(+), 8 deletions(-) create mode 100644 docs/testing/plan-canvas-pdf-export.tdd.md create mode 100644 scripts/lib/plan-canvas/pdf.js create mode 100644 tests/scripts/plan-canvas-pdf.test.js diff --git a/.agents/skills/plan-canvas/SKILL.md b/.agents/skills/plan-canvas/SKILL.md index 8b77e1e26..a385a0a06 100644 --- a/.agents/skills/plan-canvas/SKILL.md +++ b/.agents/skills/plan-canvas/SKILL.md @@ -154,6 +154,9 @@ mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use. session is refused; pass `--reopen` only when the user asks to resume. - Sibling assets (images, CSS) must sit next to the artifact and be referenced by relative path. +- The reviewer can use **Download PDF** in the Canvas header to save the current + artifact directly. Export stays local and uses an installed Chrome, Chromium, + or Edge renderer; `ECC_PLAN_CANVAS_CHROME_PATH` selects a nonstandard install. - The server is loopback-only and exits after 30 idle minutes (`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`). diff --git a/docs/design/plan-canvas.md b/docs/design/plan-canvas.md index 4989b46fa..e3a3d0f0e 100644 --- a/docs/design/plan-canvas.md +++ b/docs/design/plan-canvas.md @@ -42,6 +42,7 @@ A loopback-only web editor for plan artifacts (and any local HTML artifact): | Editor chrome | `scripts/lib/plan-canvas/ui.js` | `scripts/lib/control-pane/ui.js`, tokens from `scripts/dashboard-web.js` | | Markdown plan renderer | `scripts/lib/plan-canvas/markdown.js` | zero new deps; renders the `commands/plan.md` artifact schema (tables, tasks, code fences, Mermaid blocks) | | Mermaid diagrams | `scripts/lib/plan-canvas/ui.js` | ` ```mermaid ` blocks render in the browser after page load, themed to ECC; pinned CDN with non-blocking fallback (`ECC_PLAN_CANVAS_MERMAID_URL` for a local mirror) | +| PDF export | `scripts/lib/plan-canvas/pdf.js` | local Chrome, Chromium, or Edge print renderer; returns a PDF download without a cloud converter or new npm runtime dependency (`ECC_PLAN_CANVAS_CHROME_PATH` override) | | Session state | `scripts/lib/plan-canvas/sessions.js` | file-path-keyed sessions, state under `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR` override) | | Skill | `skills/plan-canvas/SKILL.md` | skills-first surface; teaches the open → await → reply loop; defers visual guidance to `frontend-design-direction`, `artifact-design`, `dataviz` | | Command shim | `commands/plan-canvas.md` | legacy parity surface, points at the skill | @@ -87,6 +88,10 @@ the poll is interrupted. - `GET /canvas/` — editor chrome; `GET /artifact//` — rendered artifact (markdown → ECC plan template, HTML passthrough) with the annotation SDK injected; sibling assets confined to the artifact directory +- `GET /api/session//state` — finite browser poll for chat, presence, end state, + and artifact revision; replaces one permanent browser connection per Canvas +- `GET /api/session//pdf` — render the current loopback artifact with a private + temporary Chromium profile and return it as an attachment with a safe filename - `POST /api/session//feedback` `{items[], endSession?}` — browser queues chat / annotation / verdict items - `GET /api/await?file=[&timeoutMs=n]` — agent long-poll (whitespace heartbeat); @@ -94,8 +99,8 @@ the poll is interrupted. - `POST /api/session//reply` `{text}` — agent message → canvas chat - `POST /api/session//end` (user) / `POST /api/end` `{file}` (agent) — ender recorded; user ends are sticky: plain `open` refuses to reopen without `--reopen` -- `GET /events/` — SSE to the browser: `chat-sync`, `presence` - (waiting/listening/working), `reload` (artifact file changed), `ended` +- `GET /events/` — returns HTTP 204 so pre-protocol-3 EventSource clients stop + reconnecting and release their browser connection slots ## Deliberate differences from lavish-axi @@ -104,7 +109,7 @@ the poll is interrupted. - ECC design tokens and chrome; JSON (not TOON) agent output. - Mermaid renders themed to ECC, but without lavish's pan/zoom or node-id capture — whole-element annotation covers pointing at a diagram or node. -- No export/share hosting, no layout-audit gate, no bundled playbooks — ECC's existing +- Local PDF export without share hosting, no layout-audit gate, no bundled playbooks — ECC's existing design skills (`frontend-design-direction`, `artifact-design`, `dataviz`) cover authoring. ## Security posture @@ -119,3 +124,10 @@ after an artifact containing a diagram has loaded; it renders with `securityLeve cannot hold the Canvas page in a loading state, degrades to showing diagram source if unavailable, and can be repointed at a local mirror via `ECC_PLAN_CANVAS_MERMAID_URL`. The server itself still makes no network calls. + +PDF export also stays local. The server launches an installed Chrome, Chromium, or Edge +executable with a new temporary profile, restricts the print target to the loopback Canvas +origin, waits for a complete `%PDF` document, terminates that private renderer, and removes +its temporary profile. Set `ECC_PLAN_CANVAS_CHROME_PATH` when auto-discovery cannot find the +browser. The export endpoint returns an actionable error instead of uploading the plan or +silently falling back to a remote service. diff --git a/docs/testing/plan-canvas-pdf-export.tdd.md b/docs/testing/plan-canvas-pdf-export.tdd.md new file mode 100644 index 000000000..0da4bd212 --- /dev/null +++ b/docs/testing/plan-canvas-pdf-export.tdd.md @@ -0,0 +1,50 @@ +# Plan Canvas PDF export TDD evidence + +## User journey + +As a Plan Canvas reviewer, I can select **Download PDF** and receive the current +artifact as a real PDF file without sending the plan to an external converter. + +## Guarantees + +| Guarantee | Evidence | Result | +| --- | --- | --- | +| The Canvas exposes a labeled PDF download action | Server integration test and browser accessibility tree | PASS | +| The browser fetches a PDF, creates a Blob URL, and starts a named download | Generated-client integration test and real Chrome interaction | PASS | +| Only a loopback artifact URL can reach the renderer | `assertLoopbackUrl` tests | PASS | +| Filenames are useful and safe across platforms | `pdfFileName` tests | PASS | +| Incomplete output is never served as a PDF | `%PDF` header and `%%EOF` completion tests | PASS | +| Renderer state is private and temporary | Fake-process lifecycle test and live process/temp-state inspection | PASS | +| Export adds no cloud converter or npm runtime dependency | Implementation and package diff inspection | PASS | + +## Red and green + +- RED: the focused server suite produced 30 passes and 2 failures because the + Canvas had no Download PDF control or PDF endpoint. +- GREEN: renderer unit tests pass 6/6, Plan Canvas server tests pass 32/32, + and the end-to-end review workflow passes 10/10. +- FULL SUITE: `npm test` passes all 4,003 discovered tests; full ESLint, + Markdown lint, package dry-run, and `git diff --check` also pass. +- COVERAGE: `npm run coverage` passes 4,003/4,003 with 88.97% statements, + 80.58% branches, 94.22% functions, and 88.97% lines. The Plan Canvas + module group reaches 96.72% statements and lines, 84.47% branches, and + 95.34% functions. +- BROWSER: Chrome selected Download PDF on the real Sandbox Execution Fabric. + The request returned HTTP 200, `application/pdf`, and downloaded + `EXECUTION-FABRIC.pdf` to the browser's download directory. +- DOCUMENT: the downloaded Sandbox PDF is a valid PDF 1.4 document with five + pages. The ECC 2 to ECC 3 master plan exports as a valid eight-page PDF. + The HTML release-preview artifact exports as a valid four-page PDF. + Rendered first-page previews retain headings, body text, tables/code styling, + and print-safe light colors. + +## Runtime contract + +The loopback server discovers Google Chrome, Chromium, or Microsoft Edge, or +uses `ECC_PLAN_CANVAS_CHROME_PATH`. It launches the executable without a shell, +with a private temporary profile and a loopback-only artifact URL. Completion +requires both a `%PDF-` header and `%%EOF` marker. The renderer is terminated and +temporary state is removed before the response is handed to the browser. + +If no renderer exists, the browser receives an actionable local error. Plan +Canvas does not upload the artifact or add a hosted conversion dependency. diff --git a/scripts/lib/plan-canvas/pdf.js b/scripts/lib/plan-canvas/pdf.js new file mode 100644 index 000000000..51e484262 --- /dev/null +++ b/scripts/lib/plan-canvas/pdf.js @@ -0,0 +1,261 @@ +'use strict'; + +/** + * Local PDF export for Plan Canvas. + * + * Chromium's print-to-PDF implementation renders the same loopback artifact + * the reviewer sees, so Markdown, HTML, images, tables, and Mermaid diagrams + * keep their browser layout. No artifact content is sent to a converter or + * added as an npm runtime dependency. + */ + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const DEFAULT_EXPORT_TIMEOUT_MS = 45 * 1000; +const PDF_POLL_MS = 100; + +function errorWithCode(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +function isExecutable(file, { platform = process.platform, fsImpl = fs } = {}) { + if (!file) return false; + try { + fsImpl.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); + return fsImpl.statSync(file).isFile(); + } catch { + return false; + } +} + +function pathExecutableNames(platform) { + if (platform === 'win32') { + return ['chrome.exe', 'msedge.exe', 'chromium.exe']; + } + return ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge-stable', 'microsoft-edge']; +} + +function executableCandidates({ env, platform }) { + const candidates = []; + if (platform === 'darwin') { + candidates.push( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' + ); + if (env.HOME) { + candidates.push( + path.join(env.HOME, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'), + path.join(env.HOME, 'Applications/Chromium.app/Contents/MacOS/Chromium'), + path.join(env.HOME, 'Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge') + ); + } + } + if (platform === 'win32') { + for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) { + candidates.push( + path.join(root, 'Google/Chrome/Application/chrome.exe'), + path.join(root, 'Microsoft/Edge/Application/msedge.exe'), + path.join(root, 'Chromium/Application/chrome.exe') + ); + } + } + + const pathEntries = String(env.PATH || '') + .split(path.delimiter) + .map(entry => entry.replace(/^"|"$/g, '')) + .filter(Boolean); + for (const directory of pathEntries) { + for (const name of pathExecutableNames(platform)) candidates.push(path.join(directory, name)); + } + return candidates; +} + +function resolveChromiumExecutable({ + env = process.env, + platform = process.platform, + fsImpl = fs +} = {}) { + const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim(); + if (override) return isExecutable(override, { platform, fsImpl }) ? override : null; + return executableCandidates({ env, platform }).find(candidate => isExecutable(candidate, { platform, fsImpl })) || null; +} + +function pdfFileName(artifactFile) { + const original = path.basename(String(artifactFile || 'plan')); + const stem = original + .replace(/\.(?:plan\.)?(?:md|markdown|html?|htm)$/i, '') + .split('') + .map(character => character.charCodeAt(0) < 32 ? '-' : character) + .join('') + .replace(/[<>:"/\\|?*]/g, '-') + .replace(/[.\s]+$/g, '') + .trim() || 'plan'; + return `${stem}.pdf`; +} + +function assertLoopbackUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw errorWithCode('PDF export URL is invalid', 'PDF_EXPORT_INVALID_URL'); + } + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) { + throw errorWithCode('PDF export is restricted to the Plan Canvas loopback server', 'PDF_EXPORT_INVALID_URL'); + } + return url.toString(); +} + +function isCompletePdf(file, fsImpl = fs) { + let fd; + try { + const stat = fsImpl.statSync(file); + if (!stat.isFile() || stat.size < 12) return false; + fd = fsImpl.openSync(file, 'r'); + const head = Buffer.alloc(5); + fsImpl.readSync(fd, head, 0, head.length, 0); + const tailLength = Math.min(2048, stat.size); + const tail = Buffer.alloc(tailLength); + fsImpl.readSync(fd, tail, 0, tailLength, stat.size - tailLength); + return head.toString('ascii') === '%PDF-' && tail.toString('latin1').includes('%%EOF'); + } catch { + return false; + } finally { + if (fd !== undefined) { + try { fsImpl.closeSync(fd); } catch { /* best-effort probe */ } + } + } +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode) return; + const closed = new Promise(resolve => child.once('close', resolve)); + try { child.kill('SIGTERM'); } catch { return; } + await Promise.race([closed, delay(1000)]); + if (child.exitCode === null && !child.signalCode) { + try { child.kill('SIGKILL'); } catch { /* already gone */ } + await Promise.race([closed, delay(500)]); + } +} + +function waitForPdf(child, outputFile, { timeoutMs, fsImpl }) { + return new Promise((resolve, reject) => { + let stderr = ''; + let settled = false; + const finish = error => { + if (settled) return; + settled = true; + clearInterval(poll); + clearTimeout(timeout); + child.removeListener('error', onError); + child.removeListener('close', onClose); + if (error) reject(error); + else resolve(); + }; + const failure = message => { + const detail = stderr.trim().slice(-1200); + return errorWithCode(`${message}${detail ? `: ${detail}` : ''}`, 'PDF_EXPORT_FAILED'); + }; + const onError = error => finish(failure(`Could not start the PDF renderer (${error.message})`)); + const onClose = code => { + if (isCompletePdf(outputFile, fsImpl)) finish(); + else finish(failure(`PDF renderer exited with status ${code}`)); + }; + if (child.stderr) { + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + if (stderr.length > 8000) stderr = stderr.slice(-4000); + }); + } + child.once('error', onError); + child.once('close', onClose); + const poll = setInterval(() => { + if (isCompletePdf(outputFile, fsImpl)) finish(); + }, PDF_POLL_MS); + const timeout = setTimeout(() => finish(failure(`PDF export timed out after ${timeoutMs}ms`)), timeoutMs); + }); +} + +async function exportPdf({ + url, + artifactFile, + env = process.env, + platform = process.platform, + executable = null, + timeoutMs = DEFAULT_EXPORT_TIMEOUT_MS, + spawnImpl = spawn, + fsImpl = fs, + osImpl = os +} = {}) { + const safeUrl = assertLoopbackUrl(url); + const browser = executable || resolveChromiumExecutable({ env, platform, fsImpl }); + if (!browser) { + const override = String(env.ECC_PLAN_CANVAS_CHROME_PATH || '').trim(); + throw errorWithCode( + override + ? `PDF renderer not found at ECC_PLAN_CANVAS_CHROME_PATH=${override}` + : 'PDF export requires Google Chrome, Chromium, or Microsoft Edge; set ECC_PLAN_CANVAS_CHROME_PATH to its executable', + 'PDF_BROWSER_NOT_FOUND' + ); + } + + const tempDir = fsImpl.mkdtempSync(path.join(osImpl.tmpdir(), 'ecc-plan-canvas-pdf-')); + const outputFile = path.join(tempDir, 'artifact.pdf'); + const profileDir = path.join(tempDir, 'profile'); + fsImpl.mkdirSync(profileDir, { recursive: true }); + let child = null; + try { + const args = [ + '--headless=new', + '--disable-gpu', + '--disable-component-update', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--metrics-recording-only', + '--mute-audio', + '--no-first-run', + '--no-default-browser-check', + '--no-pdf-header-footer', + '--print-to-pdf-no-header', + '--hide-scrollbars', + `--user-data-dir=${profileDir}`, + `--print-to-pdf=${outputFile}`, + '--virtual-time-budget=5000', + safeUrl + ]; + child = spawnImpl(browser, args, { + stdio: ['ignore', 'ignore', 'pipe'], + shell: false, + env: { ...env, LANG: 'en_US.UTF-8' } + }); + await waitForPdf(child, outputFile, { timeoutMs, fsImpl }); + const buffer = fsImpl.readFileSync(outputFile); + if (!isCompletePdf(outputFile, fsImpl)) { + throw errorWithCode('PDF renderer produced an incomplete document', 'PDF_EXPORT_FAILED'); + } + return { buffer, filename: pdfFileName(artifactFile) }; + } finally { + await stopChild(child); + fsImpl.rmSync(tempDir, { recursive: true, force: true }); + } +} + +module.exports = { + DEFAULT_EXPORT_TIMEOUT_MS, + assertLoopbackUrl, + exportPdf, + isCompletePdf, + pdfFileName, + resolveChromiumExecutable +}; diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js index c8fa091e4..7237a4da0 100644 --- a/scripts/lib/plan-canvas/server.js +++ b/scripts/lib/plan-canvas/server.js @@ -17,6 +17,7 @@ const path = require('path'); const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard'); const { renderMarkdown } = require('./markdown'); +const { exportPdf } = require('./pdf'); const { artifactSdkJs } = require('./sdk'); const { canvasCss, @@ -35,7 +36,7 @@ const MAX_BODY_BYTES = 1024 * 1024; const DEFAULT_THINKING_STALE_MS = 90 * 1000; // An explicit typing signal expires faster: it means "a reply is seconds away". const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000; -const PLAN_CANVAS_PROTOCOL_VERSION = 3; +const PLAN_CANVAS_PROTOCOL_VERSION = 4; const TYPING_STATES = new Set(['thinking', 'typing', 'idle']); // Package versions do not distinguish two worktrees on the same release. @@ -45,6 +46,7 @@ function computeRuntimeId() { const sources = [ ['loopback-guard.js', path.join(__dirname, '..', 'loopback-guard.js')], ['markdown.js', path.join(__dirname, 'markdown.js')], + ['pdf.js', path.join(__dirname, 'pdf.js')], ['sdk.js', path.join(__dirname, 'sdk.js')], ['server.js', __filename], ['sessions.js', path.join(__dirname, 'sessions.js')], @@ -135,6 +137,22 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) { res.end(html); } +function sendPdf(res, { buffer, filename }) { + const asciiName = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '-'); + const encodedName = encodeURIComponent(filename).replace(/['()]/g, character => + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ); + res.writeHead(200, { + 'content-type': 'application/pdf', + 'content-length': buffer.length, + 'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`, + 'x-content-type-options': 'nosniff', + 'x-plan-canvas-filename': encodeURIComponent(filename), + 'cache-control': 'no-store' + }); + res.end(buffer); +} + function createPlanCanvasServer({ store, host = DEFAULT_HOST, @@ -143,6 +161,7 @@ function createPlanCanvasServer({ heartbeatMs = 15000, thinkingStaleMs = DEFAULT_THINKING_STALE_MS, typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS, + pdfExporter = exportPdf, onIdleShutdown = null, log = () => {} } = {}) { @@ -371,6 +390,22 @@ function createPlanCanvasServer({ }); } + const pdfMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/pdf$/); + if (pdfMatch && req.method === 'GET') { + const session = store.get(pdfMatch[1]); + if (!session) return sendJson(res, 404, { error: 'unknown session' }); + try { + const pdf = await pdfExporter({ + url: `http://${req.headers.host}/artifact/${session.key}/?pdf=1`, + artifactFile: session.file + }); + return sendPdf(res, pdf); + } catch (error) { + const statusCode = error.code === 'PDF_BROWSER_NOT_FOUND' ? 503 : 500; + return sendJson(res, statusCode, { error: error.message, code: error.code || 'PDF_EXPORT_FAILED' }); + } + } + const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/); if (sessionMatch && req.method === 'POST') { const [, key, action] = sessionMatch; diff --git a/scripts/lib/plan-canvas/ui.js b/scripts/lib/plan-canvas/ui.js index 68492e08f..dbf4ed5b8 100644 --- a/scripts/lib/plan-canvas/ui.js +++ b/scripts/lib/plan-canvas/ui.js @@ -117,9 +117,19 @@ function canvasCss() { .toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)} .toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff} - .icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s} + .icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;white-space:nowrap;transition:all .12s} .icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)} + .icon-btn:disabled{cursor:wait;opacity:.65} .icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)} + .export-status{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;color:var(--text2)} + .export-status.error{color:var(--red)} + @media(max-width:900px){ + .bar{gap:8px;padding:0 10px} + .brand .name,.presence,.toggle>span:first-child,.export-status{display:none} + .brand .file{max-width:90px} + #downloadPdfBtn{font-size:0} + #downloadPdfBtn:after{content:'PDF';font-size:11.5px} + } .layout{display:flex;height:calc(100% - 52px)} .frame{flex:1;min-width:0;position:relative;background:var(--bg2)} @@ -204,6 +214,8 @@ function canvasClientJs() { const sendBtn = $('send'); const statusEl = $('sendStatus'); const presence = $('presence'); + const downloadPdfBtn = $('downloadPdfBtn'); + const exportStatus = $('exportStatus'); const QKEY = 'ecc-plan-canvas:queue:' + key; let queue = []; let lastScroll = { x: 0, y: 0 }; @@ -415,6 +427,48 @@ function canvasClientJs() { $('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }])); // --- session controls ------------------------------------------------ + let exportStatusTimer = null; + function reportExportStatus(message, isError) { + clearTimeout(exportStatusTimer); + exportStatus.textContent = message; + exportStatus.classList.toggle('error', Boolean(isError)); + if (message && !isError) { + exportStatusTimer = setTimeout(() => { exportStatus.textContent = ''; }, 5000); + } + } + downloadPdfBtn.addEventListener('click', async () => { + if (downloadPdfBtn.disabled) return; + downloadPdfBtn.disabled = true; + downloadPdfBtn.textContent = 'Preparing PDF\u2026'; + reportExportStatus('Rendering locally\u2026'); + try { + const res = await fetch('/api/session/' + key + '/pdf', { cache: 'no-store' }); + if (!res.ok) { + const detail = await res.json().catch(() => ({})); + throw new Error(detail.error || ('HTTP ' + res.status)); + } + const blob = await res.blob(); + if (!blob.size || blob.type !== 'application/pdf') throw new Error('server returned an invalid PDF'); + const encodedName = res.headers.get('x-plan-canvas-filename') || 'plan.pdf'; + let filename = 'plan.pdf'; + try { filename = decodeURIComponent(encodedName); } catch { /* safe fallback */ } + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + link.hidden = true; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 30000); + reportExportStatus('Downloaded ' + filename); + } catch (error) { + reportExportStatus('PDF failed: ' + error.message, true); + } finally { + downloadPdfBtn.disabled = false; + downloadPdfBtn.textContent = 'Download PDF'; + } + }); $('reloadBtn').addEventListener('click', reloadArtifact); $('endBtn').addEventListener('click', async () => { if (!window.confirm('End this review session?')) return; @@ -527,6 +581,8 @@ function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canv Annotate + + @@ -600,6 +656,16 @@ ${TOKENS_CSS} pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto} pre.mermaid[data-processed] svg{max-width:100%;height:auto} pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px} + @page{margin:14mm} + @media print{ + :root{--bg:#fff;--bg2:#fff;--bg3:#f4f5f7;--bg4:#eaecef;--surface:#fff;--surface-hover:#fff;--border:#d8dce5;--border-light:#c6cbd6;--text:#161922;--text2:#505667;--text3:#73798a;--accent:#3d5ab8;--accent-glow:rgba(61,90,184,.1);--pink:#b94076} + *{-webkit-print-color-adjust:exact;print-color-adjust:exact} + body{background:#fff;color:var(--text);font-size:11pt} + .doc{max-width:none;padding:0} + h1,h2,h3,h4,h5,h6{break-after:avoid-page} + blockquote,pre,table,img,svg{break-inside:avoid-page} + [data-ecc-plan-canvas="ui"]{display:none!important} + } diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index 389099906..1b28b1883 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -71,7 +71,8 @@ function usage() { ' typing: --state Defaults to typing', ' server: --port --host ', '', - 'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS' + 'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS,', + ' ECC_PLAN_CANVAS_CHROME_PATH' ].join('\n'); } diff --git a/skills/plan-canvas/SKILL.md b/skills/plan-canvas/SKILL.md index 40a02581a..66e2dde3e 100644 --- a/skills/plan-canvas/SKILL.md +++ b/skills/plan-canvas/SKILL.md @@ -155,6 +155,9 @@ mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use. session is refused; pass `--reopen` only when the user asks to resume. - Sibling assets (images, CSS) must sit next to the artifact and be referenced by relative path. +- The reviewer can use **Download PDF** in the Canvas header to save the current + artifact directly. Export stays local and uses an installed Chrome, Chromium, + or Edge renderer; `ECC_PLAN_CANVAS_CHROME_PATH` selects a nonstandard install. - The server is loopback-only and exits after 30 idle minutes (`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`). diff --git a/tests/integration/plan-canvas-e2e.test.js b/tests/integration/plan-canvas-e2e.test.js index 8e7bb811b..c72dacc83 100644 --- a/tests/integration/plan-canvas-e2e.test.js +++ b/tests/integration/plan-canvas-e2e.test.js @@ -176,7 +176,7 @@ async function main() { assert.strictEqual(result.parsed.status, 'open'); assert.strictEqual(shutdownRequested, true, 'current CLI should retire the stale server'); const health = JSON.parse((await request(legacyPort, 'GET', '/health')).body); - assert.strictEqual(health.protocolVersion, 3); + assert.strictEqual(health.protocolVersion, 4); } finally { if (legacyServer.listening) { await new Promise(resolve => legacyServer.close(resolve)); diff --git a/tests/scripts/plan-canvas-pdf.test.js b/tests/scripts/plan-canvas-pdf.test.js new file mode 100644 index 000000000..e2e2b4153 --- /dev/null +++ b/tests/scripts/plan-canvas-pdf.test.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const { EventEmitter } = require('events'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + assertLoopbackUrl, + exportPdf, + isCompletePdf, + pdfFileName, + resolveChromiumExecutable +} = require('../../scripts/lib/plan-canvas/pdf'); + +const results = []; + +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + results.push(true); + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` ${error.stack || error.message}`); + results.push(false); + } +} + +function fakeChild(onSpawn) { + const child = new EventEmitter(); + child.stderr = new EventEmitter(); + child.exitCode = null; + child.signalCode = null; + child.kill = signal => { + child.signalCode = signal; + setImmediate(() => { + child.exitCode = 0; + child.emit('close', 0, signal); + }); + return true; + }; + onSpawn(child); + return child; +} + +async function main() { + console.log('\n=== Testing Plan Canvas PDF export ===\n'); + + await test('builds safe, useful PDF filenames', () => { + assert.strictEqual(pdfFileName('/tmp/feature-fleet-2.2.plan.md'), 'feature-fleet-2.2.pdf'); + assert.strictEqual(pdfFileName('/tmp/release-preview.html'), 'release-preview.pdf'); + assert.strictEqual(pdfFileName('/tmp/bad:name?.md'), 'bad-name-.pdf'); + assert.strictEqual(pdfFileName(''), 'plan.pdf'); + }); + + await test('restricts the renderer to loopback artifact URLs', () => { + assert.strictEqual( + assertLoopbackUrl('http://127.0.0.1:4518/artifact/abc/?pdf=1'), + 'http://127.0.0.1:4518/artifact/abc/?pdf=1' + ); + assert.throws(() => assertLoopbackUrl('https://127.0.0.1/artifact/abc'), { code: 'PDF_EXPORT_INVALID_URL' }); + assert.throws(() => assertLoopbackUrl('http://example.com/artifact/abc'), { code: 'PDF_EXPORT_INVALID_URL' }); + }); + + await test('honors an explicit Chromium executable override', () => { + const fsImpl = { + accessSync(file) { assert.strictEqual(file, '/opt/test/chrome'); }, + statSync() { return { isFile: () => true }; } + }; + assert.strictEqual( + resolveChromiumExecutable({ + env: { ECC_PLAN_CANVAS_CHROME_PATH: '/opt/test/chrome', PATH: '' }, + platform: 'linux', + fsImpl + }), + '/opt/test/chrome' + ); + }); + + await test('fails actionably when no local PDF renderer is installed', async () => { + await assert.rejects( + exportPdf({ + url: 'http://127.0.0.1:4517/artifact/abc123/?pdf=1', + artifactFile: '/workspace/launch.plan.md', + env: { PATH: '' }, + platform: 'linux' + }), + error => error.code === 'PDF_BROWSER_NOT_FOUND' && error.message.includes('ECC_PLAN_CANVAS_CHROME_PATH') + ); + }); + + await test('recognizes only complete PDF output', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-complete-')); + const file = path.join(tmp, 'artifact.pdf'); + fs.writeFileSync(file, '%PDF-1.4\npartial'); + assert.strictEqual(isCompletePdf(file), false); + fs.appendFileSync(file, '\n%%EOF\n'); + assert.strictEqual(isCompletePdf(file), true); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + await test('renders, terminates its private browser, and removes temporary state', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-pdf-test-')); + let spawned = null; + let child = null; + const spawnImpl = (command, args, options) => { + spawned = { command, args, options }; + const outputFile = args.find(arg => arg.startsWith('--print-to-pdf=')).slice('--print-to-pdf='.length); + child = fakeChild(() => { + setTimeout(() => fs.writeFileSync(outputFile, '%PDF-1.4\nlocal plan\n%%EOF\n'), 20); + }); + return child; + }; + + const result = await exportPdf({ + url: 'http://localhost:4517/artifact/abc123/?pdf=1', + artifactFile: '/workspace/launch.plan.md', + executable: '/opt/test/chrome', + timeoutMs: 1000, + spawnImpl, + osImpl: { tmpdir: () => tempRoot } + }); + + assert.strictEqual(result.filename, 'launch.pdf'); + assert.ok(result.buffer.subarray(0, 5).equals(Buffer.from('%PDF-'))); + assert.strictEqual(spawned.command, '/opt/test/chrome'); + assert.strictEqual(spawned.options.shell, false); + assert.ok(spawned.args.includes('--headless=new')); + assert.ok(spawned.args.includes('http://localhost:4517/artifact/abc123/?pdf=1')); + assert.strictEqual(child.signalCode, 'SIGTERM'); + assert.deepStrictEqual(fs.readdirSync(tempRoot), []); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const passed = results.filter(Boolean).length; + const failed = results.length - passed; + console.log('\n========================================'); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('========================================'); + process.exit(failed ? 1 : 0); +} + +main(); diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 0e0455055..7967497e6 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -98,12 +98,17 @@ async function main() { fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret'); const store = createSessionStore({ stateDir: path.join(tmp, 'state') }); + const pdfRequests = []; let idleFired = false; const canvas = createPlanCanvasServer({ store, version: '9.9.9-test', heartbeatMs: 25, idleTimeoutMs: 0, + pdfExporter: async options => { + pdfRequests.push(options); + return { buffer: Buffer.from('%PDF-1.4\n%%EOF\n'), filename: 'demo.pdf' }; + }, onIdleShutdown: () => { idleFired = true; } @@ -119,7 +124,7 @@ async function main() { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test', - protocolVersion: 3, + protocolVersion: 4, runtimeId: PLAN_CANVAS_RUNTIME_ID }); })) passed++; else failed++; @@ -155,6 +160,7 @@ async function main() { assert.ok(res.body.includes('Plan Canvas')); assert.ok(res.body.includes('pc-session')); assert.ok(res.body.includes('Approve plan')); + assert.ok(res.body.includes('Download PDF')); assert.ok(res.body.includes('sandbox="allow-scripts allow-forms allow-popups"')); })) passed++; else failed++; @@ -209,6 +215,22 @@ async function main() { } })) passed++; else failed++; + if (await test('Download PDF fetches a generated PDF and starts a browser download', async () => { + const client = await request(port, 'GET', '/client.js'); + assert.ok(client.body.includes("'/api/session/' + key + '/pdf'")); + assert.ok(client.body.includes('URL.createObjectURL')); + + const res = await request(port, 'GET', `/api/session/${key}/pdf`); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers['content-type'], 'application/pdf'); + assert.match(res.headers['content-disposition'], /^attachment;/); + assert.strictEqual(res.headers['x-plan-canvas-filename'], 'demo.pdf'); + assert.ok(res.body.startsWith('%PDF-1.4')); + assert.strictEqual(pdfRequests.length, 1); + assert.strictEqual(pdfRequests[0].artifactFile, fs.realpathSync(artifact)); + assert.strictEqual(pdfRequests[0].url, `http://127.0.0.1:${port}/artifact/${key}/?pdf=1`); + })) passed++; else failed++; + if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => { const res = await request(port, 'GET', '/client.js'); assert.ok(res.body.includes("'/api/session/' + key + '/state'"));