/**
* Integration tests for the Plan Canvas server (scripts/lib/plan-canvas/).
*
* Spins up the real HTTP server in-process and drives it exactly like the
* browser chrome (finite fetch polling) and the agent CLI (long-poll) do.
*
* Run with: node tests/scripts/plan-canvas.test.js
*/
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions');
const {
PLAN_CANVAS_RUNTIME_ID,
createPlanCanvasServer
} = require('../../scripts/lib/plan-canvas/server');
async function test(name, fn) {
try {
await fn();
console.log(` ✓ ${name}`);
return true;
} catch (err) {
console.log(` ✗ ${name}`);
console.log(` Error: ${err.stack || err.message}`);
return false;
}
}
function request(port, method, requestPath, { body = null, headers = {} } = {}) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const req = http.request(
{
host: '127.0.0.1',
port,
method,
path: requestPath,
agent: false,
headers: payload
? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), ...headers }
: headers
},
res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: data }));
}
);
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
function jsonBody(res) {
return JSON.parse(res.body.trim());
}
async function browserState(port, key) {
return jsonBody(await request(port, 'GET', `/api/session/${key}/state`));
}
function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const timer = setInterval(() => {
if (predicate()) {
clearInterval(timer);
resolve();
} else if (Date.now() - startedAt > timeoutMs) {
clearInterval(timer);
reject(new Error('waitFor timed out'));
}
}, intervalMs);
});
}
async function main() {
console.log('\n=== Testing plan-canvas server ===\n');
let passed = 0;
let failed = 0;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-server-'));
const artifact = path.join(tmp, 'demo.plan.md');
fs.writeFileSync(artifact, '# Plan: Demo\n\n## Files to Change\n\n| File | Action |\n|---|---|\n| `a.js` | UPDATE |\n');
const htmlArtifact = path.join(tmp, 'report.html');
fs.writeFileSync(
htmlArtifact,
'
Report
'
);
fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }');
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-outside-'));
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'secret');
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
const pdfRequests = [];
let holdPdfExport = false;
let releasePdfExport = null;
let pdfFailure = null;
const serverLogs = [];
let idleFired = false;
const canvas = createPlanCanvasServer({
store,
version: '9.9.9-test',
heartbeatMs: 25,
idleTimeoutMs: 0,
pdfExporter: async options => {
pdfRequests.push(options);
if (pdfFailure) throw pdfFailure;
if (holdPdfExport) await new Promise(resolve => { releasePdfExport = resolve; });
return { buffer: Buffer.from('%PDF-1.4\n%%EOF\n'), filename: 'demo.pdf' };
},
log: line => serverLogs.push(line),
onIdleShutdown: () => {
idleFired = true;
}
});
const { port } = await canvas.listen(0);
let key = null;
let htmlKey = null;
if (await test('GET /health identifies the app and version', async () => {
const res = await request(port, 'GET', '/health');
assert.deepStrictEqual(jsonBody(res), {
ok: true,
app: 'ecc-plan-canvas',
version: '9.9.9-test',
protocolVersion: 4,
runtimeId: PLAN_CANVAS_RUNTIME_ID
});
})) passed++; else failed++;
if (await test('requests with a non-loopback Host header are rejected', async () => {
const res = await request(port, 'GET', '/health', { headers: { host: 'evil.example.com' } });
assert.strictEqual(res.statusCode, 403);
})) passed++; else failed++;
if (await test('requests with a cross-site Origin are rejected', async () => {
const res = await request(port, 'POST', '/shutdown', { headers: { origin: 'https://evil.example.com' } });
assert.strictEqual(res.statusCode, 403);
})) passed++; else failed++;
if (await test('POST /api/sessions opens a session for an existing artifact', async () => {
const res = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
assert.strictEqual(res.statusCode, 200);
const body = jsonBody(res);
assert.strictEqual(body.status, 'open');
assert.match(body.key, /^[a-f0-9]{12}$/);
key = body.key;
})) passed++; else failed++;
if (await test('POST /api/sessions 404s for a missing artifact', async () => {
const res = await request(port, 'POST', '/api/sessions', { body: { file: path.join(tmp, 'nope.md') } });
assert.strictEqual(res.statusCode, 404);
})) passed++; else failed++;
if (await test('GET /canvas/:key serves the ECC chrome with CSP', async () => {
const res = await request(port, 'GET', `/canvas/${key}`);
assert.strictEqual(res.statusCode, 200);
assert.ok(res.headers['content-security-policy'].includes("default-src 'self'"));
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++;
if (await test('markdown artifacts render in the ECC plan template with the SDK', async () => {
const res = await request(port, 'GET', `/artifact/${key}/`);
assert.strictEqual(res.statusCode, 200);
assert.ok(res.body.includes(''));
assert.ok(res.body.includes('
'));
assert.ok(res.body.includes('\n
Already rendered diagram