feat(plan-canvas): download artifacts as PDF

This commit is contained in:
haelyra
2026-08-28 16:37:13 -04:00
parent 61652b74ff
commit 93cec5aa6c
11 changed files with 608 additions and 8 deletions
+3
View File
@@ -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`).
+15 -3
View File
@@ -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/<key>` — editor chrome; `GET /artifact/<key>/` — rendered artifact
(markdown → ECC plan template, HTML passthrough) with the annotation SDK injected;
sibling assets confined to the artifact directory
- `GET /api/session/<key>/state` — finite browser poll for chat, presence, end state,
and artifact revision; replaces one permanent browser connection per Canvas
- `GET /api/session/<key>/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/<key>/feedback` `{items[], endSession?}` — browser queues
chat / annotation / verdict items
- `GET /api/await?file=<path>[&timeoutMs=n]` — agent long-poll (whitespace heartbeat);
@@ -94,8 +99,8 @@ the poll is interrupted.
- `POST /api/session/<key>/reply` `{text}` — agent message → canvas chat
- `POST /api/session/<key>/end` (user) / `POST /api/end` `{file}` (agent) — ender recorded;
user ends are sticky: plain `open` refuses to reopen without `--reopen`
- `GET /events/<key>`SSE to the browser: `chat-sync`, `presence`
(waiting/listening/working), `reload` (artifact file changed), `ended`
- `GET /events/<key>`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.
@@ -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.
+261
View File
@@ -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
};
+36 -1
View File
@@ -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;
+67 -1
View File
@@ -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
<span>Annotate</span><span class="track"><span class="knob"></span></span>
</div>
<button id="themeBtn" class="icon-btn" type="button">light</button>
<span id="exportStatus" class="export-status" role="status" aria-live="polite"></span>
<button id="downloadPdfBtn" class="icon-btn" type="button" aria-label="Download PDF" title="Download the current artifact as a PDF">Download PDF</button>
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
</header>
@@ -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}
}
</style>
</head>
<body>
+2 -1
View File
@@ -71,7 +71,8 @@ function usage() {
' typing: --state <thinking|typing|idle> Defaults to typing',
' server: --port <n> --host <h>',
'',
'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');
}
+3
View File
@@ -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`).
+1 -1
View File
@@ -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));
+147
View File
@@ -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();
+23 -1
View File
@@ -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'"));