/**
* 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 (fetch + SSE) and the agent CLI (long-poll) do.
*
* Run with: node tests/scripts/plan-canvas.test.js
* Offline artifact checks: add --artifact-security-only (no listener).
*/
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { compileFunction } = require('node:vm');
const { createRequire } = require('node:module');
const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions');
const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server');
class SkippedTest extends Error {}
function failureText(error) {
try { return error?.stack || error?.message || String(error); }
catch { return 'Unprintable thrown value'; }
}
function createTestRunner(log = console.log) {
let results = { passed: 0, failed: 0, skipped: 0 };
return {
get results() { return results; },
async test(name, fn) {
try {
await fn();
results = { ...results, passed: results.passed + 1 };
log(` PASS ${name}`);
} catch (error) {
if (error instanceof SkippedTest) {
results = { ...results, skipped: results.skipped + 1 };
log(` SKIP ${name}: ${error.message}`);
} else {
results = { ...results, failed: results.failed + 1 };
log(` FAIL ${name}\n Error: ${failureText(error)}`);
}
}
}
};
}
function createTestSymlink(target, link, type = 'file', { symlink = fs.symlinkSync, platform = process.platform } = {}) {
try {
symlink(target, link, type);
} catch (error) {
if (error.code === 'ENOSYS' || error.code === 'ENOTSUP'
|| (platform === 'win32' && error.code === 'EPERM')) {
throw new SkippedTest(`symlink creation unavailable (${platform}, ${error.code})`);
}
throw error;
}
}
function printResults(results) {
console.log(`Passed: ${results.passed}`);
console.log(`Failed: ${results.failed}`);
console.log(`Skipped: ${results.skipped}`);
process.exitCode = results.failed > 0 ? 1 : 0;
}
// A failed or stalled close may leave handles alive. Bound the whole async
// run, then drain both output queues before exiting so the summary survives.
// A blocked event loop or broken output sink still needs an outer watchdog.
async function runTestProcess(run, suite, { timeoutMs = 60_000, flushTimeoutMs = 2_000 } = {}) {
process.exitCode = 1;
let deadline;
const timeout = new Promise(resolve => {
deadline = setTimeout(() => resolve({
failed: true,
error: new Error(`Plan Canvas test deadline exceeded (${timeoutMs}ms); setup, test, or cleanup did not settle`),
}), timeoutMs);
});
const execution = Promise.resolve().then(run).then(
() => ({ failed: false }),
error => ({ failed: true, error })
);
const outcome = await Promise.race([execution, timeout]);
clearTimeout(deadline);
const results = {
...suite.results,
failed: suite.results.failed + (outcome.failed ? 1 : 0),
};
let status = results.failed > 0 ? 1 : 0;
try {
if (outcome.failed) console.error(failureText(outcome.error));
console.log('\n' + '='.repeat(40));
printResults(results);
console.log('='.repeat(40));
} catch {
status = 1;
}
// Capture the result before a late task can affect process.exitCode. The
// deadline loser is observed by execution's rejection handler above.
process.exitCode = status;
let flushDeadline;
const flushed = await Promise.race([
Promise.all([process.stdout, process.stderr].map(stream => new Promise((resolve, reject) => {
let settled = false;
const finish = error => {
if (settled) return;
settled = true;
stream.removeListener('error', onError);
stream.removeListener('close', onClose);
if (error) reject(error);
else resolve();
};
const onError = error => finish(error || new Error('Test output stream failed'));
const onClose = () => finish(new Error('Test output stream closed before flush'));
if (stream.destroyed || stream.writableEnded) {
onClose();
return;
}
stream.once('error', onError);
stream.once('close', onClose);
try { stream.write('', error => error ? onError(error) : finish()); }
catch (error) { onError(error); }
}))).then(() => true, () => false),
new Promise(resolve => { flushDeadline = setTimeout(() => resolve(false), flushTimeoutMs); }),
]);
clearTimeout(flushDeadline);
process.exit(flushed ? status : 1);
}
// Compile the exact trusted module privately; this is not a security sandbox.
// Relative dependencies retain normal resolution, without rewriting source or
// changing module loaders, shared exports, or require.cache.
const artifactServerFilename = require.resolve('../../scripts/lib/plan-canvas/server');
const artifactServerSource = fs.readFileSync(artifactServerFilename, 'utf8');
const artifactRequire = createRequire(artifactServerFilename);
function loadArtifactServer(filesystem, localHttp) {
const localRequire = name => {
if (name === 'fs' || name === 'node:fs') return filesystem;
if (name === 'http' || name === 'node:http') return localHttp;
return artifactRequire(name);
};
const localModule = { exports: {} };
const evaluate = compileFunction(artifactServerSource,
['require', 'module', 'exports', '__filename', '__dirname'], { filename: artifactServerFilename });
evaluate.call(localModule.exports, localRequire, localModule, localModule.exports,
artifactServerFilename, path.dirname(artifactServerFilename));
return localModule.exports;
}
// Preserve arbitrary primary thrown values, including falsy values. Secondary
// cleanup diagnostics are intentionally discarded when a primary exists.
async function withFixtureCleanup(callback, cleanups) {
let didThrow = false;
let primary;
let result;
try { result = await callback(); } catch (error) { didThrow = true; primary = error; }
let cleanupThrew = false;
let firstCleanup;
for (const cleanup of cleanups()) {
try { await cleanup(); } catch (error) {
if (!cleanupThrew) { cleanupThrew = true; firstCleanup = error; }
}
}
if (didThrow) throw primary;
if (cleanupThrew) throw firstCleanup;
return result;
}
// Explicit close and finally cleanup await the same attempt, including failure.
function onceCleanup(cleanup) {
let pending;
return () => {
if (!pending) pending = Promise.resolve().then(cleanup);
return pending;
};
}
async function withResourceScope(callback) {
const clients = [];
const servers = [];
const roots = [];
const own = (group, cleanup) => {
const close = onceCleanup(cleanup);
group.push(close);
return close;
};
const resources = {
client: cleanup => own(clients, cleanup),
server: cleanup => own(servers, cleanup),
root: cleanup => own(roots, cleanup),
};
return withFixtureCleanup(() => callback(resources), () => [...clients, ...servers, ...roots]);
}
// Requests are owned before setup writes or awaits. Destroy the response and
// request independently so one cleanup failure cannot leave the other open.
function ownHttpClient(req, getResponse, resources) {
const cleanup = () => withFixtureCleanup(() => {}, () => {
const response = getResponse();
return [...(response ? [() => response.destroy()] : []), () => req.destroy()];
});
return resources ? resources.client(cleanup) : onceCleanup(cleanup);
}
// Capture fresh real dispatchers without binding sockets. Each request captures
// its own filesystem facade; the fixture owns those canvases until teardown.
// Artifact bodies are inert response bytes, never executed in a browser.
async function withArtifactHandler(callback, {
closeCanvas = canvas => canvas.close(),
removeRoot = root => fs.rmSync(root, { recursive: true, force: true })
} = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-artifact-'));
const canvases = [];
return withFixtureCleanup(async () => {
const base = path.join(root, 'artifacts');
const outside = path.join(root, 'outside');
fs.mkdirSync(base);
fs.mkdirSync(outside);
const session = { key: '0123456789ab', file: path.join(base, 'main.md') };
fs.writeFileSync(session.file, '# Inert fixture\n');
fs.writeFileSync(path.join(outside, 'secret.txt'), 'private-fixture-secret');
const get = (asset, filesystem = fs) => new Promise(resolve => {
let handler;
const localHttp = Object.freeze({ ...http, createServer(requestHandler) {
handler = requestHandler;
return {
listen() { throw new Error('Artifact tests must not open a listener'); },
close(done) { done(); }
};
} });
const localServer = loadArtifactServer(filesystem, localHttp);
const canvas = localServer.createPlanCanvasServer({
store: { get: key => key === session.key ? session : null }, idleTimeoutMs: 0
});
canvases.push(canvas);
const response = { headersSent: false };
response.writeHead = (statusCode, headers) => {
response.statusCode = statusCode;
response.headers = headers;
response.headersSent = true;
};
response.end = data => resolve({ statusCode: response.statusCode, headers: response.headers, body: String(data || '') });
handler({ method: 'GET', headers: { host: '127.0.0.1' }, url: `/artifact/${session.key}/${asset}` }, response);
});
return callback({ base, outside, session, get });
}, () => [...canvases.map(canvas => () => closeCanvas(canvas)), () => removeRoot(root)]);
}
async function artifactSecurityTests(test) {
const sandbox = 'sandbox allow-scripts allow-forms allow-popups';
const svg = '';
await test('SVG sibling documents keep their MIME and use the artifact sandbox', () => withArtifactHandler(async ({ base, get }) => {
fs.writeFileSync(path.join(base, 'shape.svg'), svg);
const response = await get('shape.svg');
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'image/svg+xml');
assert.strictEqual(response.headers['content-security-policy'], sandbox);
assert.strictEqual(response.body, svg);
}));
await test('SVG symlink request and target MIME fallback both receive sandbox CSP', () => withArtifactHandler(async ({ base, get }) => {
fs.writeFileSync(path.join(base, 'extensionless'), svg);
fs.writeFileSync(path.join(base, 'shape.svg'), svg);
createTestSymlink(path.join(base, 'extensionless'), path.join(base, 'by-name.svg'));
createTestSymlink(path.join(base, 'shape.svg'), path.join(base, 'by-target'));
for (const alias of ['by-name.svg', 'by-target']) {
const response = await get(alias);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'image/svg+xml');
assert.strictEqual(response.headers['content-security-policy'], sandbox);
assert.strictEqual(response.body, svg);
}
}));
await test('HTML and Markdown artifact policies and ordinary CSS MIME are preserved', () => withArtifactHandler(async ({ base, session, get }) => {
fs.writeFileSync(path.join(base, 'note.html'), '
inert HTML');
fs.writeFileSync(path.join(base, 'style.css'), 'body { color: red }');
for (const asset of ['', 'note.html']) {
const response = await get(asset);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8');
assert.strictEqual(response.headers['content-security-policy'], sandbox);
}
session.file = path.join(base, 'note.html');
const html = await get('');
assert.strictEqual(html.headers['content-security-policy'], sandbox);
assert.ok(html.body.includes(''));
const css = await get('style.css');
assert.strictEqual(css.statusCode, 200);
assert.strictEqual(css.headers['content-type'], 'text/css; charset=utf-8');
assert.strictEqual(css.headers['content-security-policy'], undefined);
assert.strictEqual(css.body, 'body { color: red }');
}));
await test('outside file and directory symlinks are refused without exposing their bytes', () => withArtifactHandler(async ({ base, outside, get }) => {
createTestSymlink(path.join(outside, 'secret.txt'), path.join(base, 'outside.txt'));
createTestSymlink(outside, path.join(base, 'outside-dir'), 'dir');
for (const asset of ['outside.txt', 'outside-dir/secret.txt']) {
const response = await get(asset);
assert.strictEqual(response.statusCode, 403);
assert.ok(!response.body.includes('private-fixture-secret'));
}
}));
await test('internal CSS symlink MIME uses the requested extension', () => withArtifactHandler(async ({ base, get }) => {
fs.writeFileSync(path.join(base, 'raw-style'), 'body { color: blue }');
createTestSymlink(path.join(base, 'raw-style'), path.join(base, 'theme.css'));
const response = await get('theme.css');
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'text/css; charset=utf-8');
assert.strictEqual(response.body, 'body { color: blue }');
}));
await test('broken sibling links return 404', () => withArtifactHandler(async ({ base, get }) => {
createTestSymlink(path.join(base, 'missing.txt'), path.join(base, 'broken.txt'));
assert.strictEqual((await get('broken.txt')).statusCode, 404);
}));
await test('encoded lexical traversal stays forbidden', () => withArtifactHandler(async ({ get }) => {
const response = await get('..%2Foutside%2Fsecret.txt');
assert.strictEqual(response.statusCode, 403);
assert.ok(!response.body.includes('private-fixture-secret'));
}));
await test('missing paths escape angle brackets and quotes under restrictive CSP', () => withArtifactHandler(async ({ base, session, get }) => {
// This is a missing-path string, not a platform-dependent filename.
session.file = path.join(base, 'hi