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
+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'"));