feat: Plan Canvas, a browser review canvas for plans (#2467)

* feat: add Plan Canvas - browser annotate-and-approve review for plan artifacts

- scripts/plan-canvas.js CLI (open/await/end/stop/server; bin ecc-plan-canvas)
- loopback server + ECC-styled chrome + annotation SDK + zero-dep markdown renderer
- Approve/Request-changes verdicts wired to the /plan confirmation gate
- plan-canvas skill, /plan-canvas command, SessionStart hook surfacing open reviews
- shared scripts/lib/loopback-guard.js extracted from control-pane (API re-exported)
- 121 new tests incl. full-workflow E2E; registered in manifests, catalog, registry

Inspired by lavish-axi (https://github.com/kunchenguid/lavish-axi) by @kunchenguid;
original ECC-native implementation, not a port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(plan-canvas): invoke via ecc-plan-canvas bin so the skill works from any project

Skill/command referenced a cwd-relative `node scripts/plan-canvas.js`, unusable
outside the ECC root. Switch to the ecc-plan-canvas bin (and $CLAUDE_PLUGIN_ROOT
fallback) and align CLI next_step hints so an agent can run it as a skill in any repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plan-canvas): render Mermaid diagrams + ship Codex cross-harness surface

- markdown renderer emits <pre class="mermaid"> for ```mermaid blocks (source
  entity-escaped so the browser decodes it for the renderer while blocking injection)
- artifact template loads a pinned Mermaid build only when a diagram is present,
  themed to ECC dark, securityLevel strict, graceful offline fallback to source
  (ECC_PLAN_CANVAS_MERMAID_URL overrides for a local mirror)
- skill teaches Mermaid-for-diagrams and states the CLI+JSON loop is harness-agnostic
- add .agents/skills/plan-canvas (Codex) with agents/openai.yaml interface manifest
- register in install-modules workflow-quality paths; docs updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plan-canvas): add demo screenshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): sync yarn.lock with new bin; add contributor checklist

- yarn.lock records the ecc-plan-canvas bin so Yarn hardened-mode install no
  longer wants to modify the lockfile on public PRs
- PR template + CONTRIBUTING gain a pre-push checklist covering the lockfile
  trap and the full skill/command/CLI registration surfaces

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Haley Chen <2022hachen@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
haelyra 2026-07-08 17:12:48 -04:00 committed by GitHub
parent 4130457d67
commit a511395613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 4398 additions and 60 deletions

View file

@ -0,0 +1,100 @@
/**
* Integration tests for scripts/hooks/plan-canvas-sessions.js (SessionStart)
*
* Run with: node tests/hooks/plan-canvas-sessions-hook.test.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runHook(stateDir) {
return spawnSync('node', [HOOK], {
encoding: 'utf8',
input: '{}',
env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir }
});
}
function writeState(stateDir, sessions) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify({ sessions }));
}
function runTests() {
console.log('\n=== Testing plan-canvas-sessions hook ===\n');
let passed = 0;
let failed = 0;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-hook-'));
if (test('exits 0 and prints nothing when no state exists', () => {
const result = runHook(path.join(tmp, 'missing'));
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
})) passed++; else failed++;
if (test('exits 0 and prints nothing when all sessions are ended', () => {
const dir = path.join(tmp, 'ended');
writeState(dir, {
abc123abc123: { key: 'abc123abc123', file: '/x/plan.md', status: 'ended', endedBy: 'user', pendingFeedback: [] }
});
const result = runHook(dir);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
})) passed++; else failed++;
if (test('surfaces open sessions with resume guidance', () => {
const dir = path.join(tmp, 'open');
writeState(dir, {
abc123abc123: {
key: 'abc123abc123',
file: '/projects/x/.claude/plans/feature.plan.md',
status: 'feedback',
pendingFeedback: [{ id: 'fb-1' }, { id: 'fb-2' }]
}
});
const result = runHook(dir);
assert.strictEqual(result.status, 0);
assert.ok(result.stdout.includes('[PlanCanvas]'));
assert.ok(result.stdout.includes('/projects/x/.claude/plans/feature.plan.md'));
assert.ok(result.stdout.includes('2 undelivered feedback items'));
assert.ok(result.stdout.includes('plan-canvas.js await'));
})) passed++; else failed++;
if (test('exits 0 on corrupt state (never blocks session start)', () => {
const dir = path.join(tmp, 'corrupt');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'sessions.json'), '{nope');
const result = runHook(dir);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
})) passed++; else failed++;
fs.rmSync(tmp, { recursive: true, force: true });
console.log('\n' + '='.repeat(40));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log('='.repeat(40));
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View file

@ -0,0 +1,265 @@
/**
* End-to-end test for Plan Canvas: the complete review workflow through the
* real CLI (scripts/plan-canvas.js) and a real detached server process, with
* the browser side simulated over the same HTTP surface the chrome uses.
*
* Flow under test:
* agent: open --no-open detached server starts, session opens
* browser: loads canvas + artifact
* agent: await (blocking child) long poll
* browser: POST annotation + request-changes verdict
* agent: await resolves with feedback JSON
* agent: edits plan, await --reply reply lands in canvas chat
* browser: POST end user end is sticky
* agent: open refused / --reopen works / end / stop
*
* Run with: node tests/integration/plan-canvas-e2e.test.js
*/
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const CLI = path.join(__dirname, '..', '..', 'scripts', 'plan-canvas.js');
const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js');
const results = [];
async function test(name, fn) {
try {
await fn();
console.log(`${name}`);
results.push(true);
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.stack || err.message}`);
results.push(false);
}
}
function cli(env, args, { timeoutMs = 15000 } = {}) {
const result = spawnSync('node', [CLI, ...args], {
encoding: 'utf8',
timeout: timeoutMs,
env: { ...process.env, ...env }
});
let parsed = null;
try {
parsed = JSON.parse(result.stdout.trim());
} catch {
// leave null; callers assert
}
return { ...result, parsed };
}
function request(port, method, requestPath, body = null) {
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) } : {}
},
res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
}
);
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function main() {
console.log('\n=== Plan Canvas end-to-end workflow ===\n');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-e2e-'));
const stateDir = path.join(tmp, 'state');
const plansDir = path.join(tmp, '.claude', 'plans');
fs.mkdirSync(plansDir, { recursive: true });
const plan = path.join(plansDir, 'notifications.plan.md');
fs.writeFileSync(
plan,
[
'# Plan: Real-Time Notifications',
'',
'**Complexity**: Medium',
'',
'## Summary',
'Notify users when watched markets resolve.',
'',
'## Files to Change',
'| File | Action | Why |',
'|---|---|---|',
'| `lib/notify.ts` | CREATE | delivery service |',
'',
'## Tasks',
'### Task 1: Schema',
'- **Action**: add notifications table',
'- **Validate**: `npm test`',
''
].join('\n')
);
// Unique port so the test never collides with a user's real canvas server.
const port = 20000 + Math.floor(Math.random() * 20000);
const env = { ECC_PLAN_CANVAS_STATE_DIR: stateDir, ECC_PLAN_CANVAS_PORT: String(port) };
let key = null;
try {
await test('agent opens the plan: detached server starts, session created', async () => {
const result = cli(env, ['open', plan, '--no-open']);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.parsed.status, 'open');
assert.ok(result.parsed.url.includes(`127.0.0.1:${port}/canvas/`));
key = result.parsed.url.split('/canvas/')[1];
const info = JSON.parse(fs.readFileSync(path.join(stateDir, 'server.json'), 'utf8'));
assert.strictEqual(info.port, port);
});
await test('browser loads the canvas chrome and the rendered plan', async () => {
const chrome = await request(port, 'GET', `/canvas/${key}`);
assert.strictEqual(chrome.statusCode, 200);
assert.ok(chrome.body.includes('Plan Canvas'));
assert.ok(chrome.body.includes('notifications.plan.md'));
const doc = await request(port, 'GET', `/artifact/${key}/`);
assert.ok(doc.body.includes('<h1 id="plan-real-time-notifications">'));
assert.ok(doc.body.includes('lib/notify.ts'));
assert.ok(doc.body.includes('/sdk.js'));
});
await test('SessionStart hook surfaces the open review', async () => {
const hook = spawnSync('node', [HOOK], { encoding: 'utf8', input: '{}', env: { ...process.env, ...env } });
assert.strictEqual(hook.status, 0);
assert.ok(hook.stdout.includes('notifications.plan.md'));
});
let awaitChild = null;
let awaitStdout = '';
const awaitExit = () =>
new Promise(resolve => {
awaitChild.on('close', resolve);
});
await test('agent blocks on await; user annotation + verdict resolve it', async () => {
awaitChild = spawn('node', [CLI, 'await', plan], { env: { ...process.env, ...env } });
awaitChild.stdout.on('data', chunk => {
awaitStdout += chunk;
});
const exited = awaitExit();
// Queued-then-drained semantics make this race-free: feedback posted
// before the poll attaches is delivered the moment it does.
const post = await request(port, 'POST', `/api/session/${key}/feedback`, {
items: [
{
kind: 'annotation',
text: 'Also notify via webhook, not just email',
anchor: { selector: 'h3:nth-of-type(1)', tag: 'h3', snippet: 'Task 1: Schema' }
},
{ kind: 'verdict', verdict: 'request-changes' }
]
});
assert.strictEqual(post.statusCode, 200);
await exited;
const feedback = JSON.parse(awaitStdout.trim());
assert.strictEqual(feedback.status, 'feedback');
assert.strictEqual(feedback.items.length, 2);
assert.strictEqual(feedback.items[0].kind, 'annotation');
assert.ok(feedback.items[0].anchor.snippet.includes('Task 1'));
assert.strictEqual(feedback.items[1].verdict, 'request-changes');
assert.ok(feedback.next_step.includes('--reply'));
});
await test('agent edits the plan and replies; reply reaches the canvas chat', async () => {
fs.appendFileSync(plan, '\n### Task 2: Webhook channel\n- **Action**: add webhook delivery\n');
const result = cli(env, ['await', plan, '--reply', 'Added webhook delivery as Task 2.', '--timeout-ms', '400']);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.parsed.status, 'waiting');
// The chrome bootstraps its chat from the canvas page.
const chrome = await request(port, 'GET', `/canvas/${key}`);
assert.ok(chrome.body.includes('Added webhook delivery as Task 2.'));
const doc = await request(port, 'GET', `/artifact/${key}/`);
assert.ok(doc.body.includes('Webhook channel'));
});
await test('user approves; the verdict arrives as plan confirmation', async () => {
awaitChild = spawn('node', [CLI, 'await', plan], { env: { ...process.env, ...env } });
awaitStdout = '';
awaitChild.stdout.on('data', chunk => {
awaitStdout += chunk;
});
const exited = awaitExit();
await request(port, 'POST', `/api/session/${key}/feedback`, {
items: [{ kind: 'verdict', verdict: 'approve' }]
});
await exited;
const feedback = JSON.parse(awaitStdout.trim());
assert.strictEqual(feedback.items[0].verdict, 'approve');
});
await test('user ends the session; plain reopen is refused, --reopen works', async () => {
await request(port, 'POST', `/api/session/${key}/end`);
const refused = cli(env, ['open', plan, '--no-open']);
assert.strictEqual(refused.parsed.status, 'user-ended');
assert.ok(refused.parsed.next_step.includes('Do not reopen'));
const forced = cli(env, ['open', plan, '--no-open', '--reopen']);
assert.strictEqual(forced.parsed.status, 'open');
});
await test('await on a user-ended session reports ended with guidance', async () => {
await request(port, 'POST', `/api/session/${key}/end`);
const result = cli(env, ['await', plan, '--timeout-ms', '400']);
assert.strictEqual(result.parsed.status, 'ended');
assert.strictEqual(result.parsed.endedBy, 'user');
assert.ok(result.parsed.next_step.includes('Stop polling'));
});
await test('agent end + status + stop shut everything down', async () => {
cli(env, ['open', plan, '--no-open', '--reopen']);
const ended = cli(env, ['end', plan]);
assert.strictEqual(ended.parsed.endedBy, 'agent');
const status = cli(env, []);
assert.ok(String(status.parsed.server).includes(`127.0.0.1:${port}`));
const stop = cli(env, ['stop']);
assert.strictEqual(stop.parsed.status, 'stopping');
// Server actually exits: health checks fail shortly after.
let gone = false;
for (let i = 0; i < 30 && !gone; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
gone = await request(port, 'GET', '/health').then(() => false).catch(() => true);
}
assert.ok(gone, 'server should stop listening after stop');
const after = cli(env, []);
assert.strictEqual(after.parsed.server, 'not running');
});
} finally {
// Belt and braces: never leave a server running even if a test failed.
cli(env, ['stop']);
fs.rmSync(tmp, { recursive: true, force: true });
}
const passed = results.filter(Boolean).length;
const failed = results.length - passed;
console.log('\n' + '='.repeat(40));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log('='.repeat(40));
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error(err);
console.log('Passed: 0');
console.log('Failed: 1');
process.exit(1);
});

View file

@ -0,0 +1,122 @@
/**
* Tests for scripts/lib/loopback-guard.js
*
* Run with: node tests/lib/loopback-guard.test.js
*/
const assert = require('assert');
const {
LOOPBACK_HOSTNAMES,
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin,
parseHostHeader
} = require('../../scripts/lib/loopback-guard');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing loopback-guard.js ===\n');
let passed = 0;
let failed = 0;
console.log('parseHostHeader:');
if (test('strips port from hostname', () => {
assert.strictEqual(parseHostHeader('127.0.0.1:4517'), '127.0.0.1');
assert.strictEqual(parseHostHeader('localhost:80'), 'localhost');
})) passed++; else failed++;
if (test('handles bare hostnames', () => {
assert.strictEqual(parseHostHeader('localhost'), 'localhost');
})) passed++; else failed++;
if (test('lowercases hostnames', () => {
assert.strictEqual(parseHostHeader('LocalHost:3000'), 'localhost');
})) passed++; else failed++;
if (test('keeps bracketed IPv6 hosts intact', () => {
assert.strictEqual(parseHostHeader('[::1]:4517'), '[::1]');
})) passed++; else failed++;
if (test('returns null for missing or malformed values', () => {
assert.strictEqual(parseHostHeader(null), null);
assert.strictEqual(parseHostHeader(undefined), null);
assert.strictEqual(parseHostHeader(''), null);
assert.strictEqual(parseHostHeader(' '), null);
assert.strictEqual(parseHostHeader(42), null);
assert.strictEqual(parseHostHeader('bad:host:extra'), null);
})) passed++; else failed++;
console.log('\nbuildAllowedHostnames:');
if (test('always includes loopback names', () => {
const set = buildAllowedHostnames(null);
for (const name of LOOPBACK_HOSTNAMES) assert.ok(set.has(name));
})) passed++; else failed++;
if (test('adds the configured host lowercased', () => {
const set = buildAllowedHostnames('MyBox.Local');
assert.ok(set.has('mybox.local'));
})) passed++; else failed++;
console.log('\nisAllowedHostHeader:');
const allowed = buildAllowedHostnames('127.0.0.1');
if (test('accepts loopback host headers', () => {
assert.strictEqual(isAllowedHostHeader('127.0.0.1:4517', allowed), true);
assert.strictEqual(isAllowedHostHeader('localhost:4517', allowed), true);
assert.strictEqual(isAllowedHostHeader('[::1]:4517', allowed), true);
})) passed++; else failed++;
if (test('rejects DNS-rebinding style hostnames', () => {
assert.strictEqual(isAllowedHostHeader('evil.example.com', allowed), false);
assert.strictEqual(isAllowedHostHeader('127.0.0.1.evil.example.com', allowed), false);
})) passed++; else failed++;
if (test('rejects missing host header', () => {
assert.strictEqual(isAllowedHostHeader(undefined, allowed), false);
})) passed++; else failed++;
console.log('\nisAllowedOrigin:');
if (test('absent origin is allowed (same-origin nav, CLI)', () => {
assert.strictEqual(isAllowedOrigin(undefined, allowed), true);
assert.strictEqual(isAllowedOrigin(null, allowed), true);
})) passed++; else failed++;
if (test('loopback origins are allowed', () => {
assert.strictEqual(isAllowedOrigin('http://127.0.0.1:4517', allowed), true);
assert.strictEqual(isAllowedOrigin('http://localhost:4517', allowed), true);
})) passed++; else failed++;
if (test('cross-site origins are rejected', () => {
assert.strictEqual(isAllowedOrigin('https://evil.example.com', allowed), false);
})) passed++; else failed++;
if (test('malformed origins are rejected', () => {
assert.strictEqual(isAllowedOrigin('not a url', allowed), false);
})) passed++; else failed++;
console.log('\n' + '='.repeat(40));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log('='.repeat(40));
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View file

@ -0,0 +1,409 @@
/**
* Tests for scripts/lib/plan-canvas/markdown.js
*
* Run with: node tests/lib/plan-canvas-markdown.test.js
*/
const assert = require('assert');
// Import the module
const { renderMarkdown, escapeHtml, slugify } = require('../../scripts/lib/plan-canvas/markdown');
// Test helper
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
// Test suite
function runTests() {
console.log('\n=== Testing plan-canvas/markdown.js ===\n');
let passed = 0;
let failed = 0;
// escapeHtml tests
console.log('escapeHtml:');
if (test('escapes & < > " \'', () => {
assert.strictEqual(
escapeHtml('<a href="x" & \'y\'>'),
'&lt;a href=&quot;x&quot; &amp; &#39;y&#39;&gt;'
);
})) passed++; else failed++;
if (test('leaves safe text unchanged', () => {
assert.strictEqual(escapeHtml('plain text 123'), 'plain text 123');
})) passed++; else failed++;
if (test('handles null/undefined as empty string', () => {
assert.strictEqual(escapeHtml(null), '');
assert.strictEqual(escapeHtml(undefined), '');
})) passed++; else failed++;
// slugify tests
console.log('\nslugify:');
if (test('lowercases and hyphenates spaces', () => {
assert.strictEqual(slugify('Plan Overview'), 'plan-overview');
})) passed++; else failed++;
if (test('strips punctuation', () => {
assert.strictEqual(slugify('Files to Change: Phase 1!'), 'files-to-change-phase-1');
})) passed++; else failed++;
if (test('collapses repeated separators and trims', () => {
assert.strictEqual(slugify(' A B--C '), 'a-b-c');
})) passed++; else failed++;
if (test('returns empty string for symbol-only input', () => {
assert.strictEqual(slugify('***'), '');
})) passed++; else failed++;
// Heading tests
console.log('\nHeadings:');
for (let level = 1; level <= 6; level++) {
if (test(`renders h${level} with slug id`, () => {
const md = `${'#'.repeat(level)} Title ${level}`;
assert.strictEqual(
renderMarkdown(md),
`<h${level} id="title-${level}">Title ${level}</h${level}>`
);
})) passed++; else failed++;
}
if (test('heading supports inline formatting, slug ignores markers', () => {
assert.strictEqual(
renderMarkdown('## Rollout **Plan**'),
'<h2 id="rollout-plan">Rollout <strong>Plan</strong></h2>'
);
})) passed++; else failed++;
// Paragraph and inline tests
console.log('\nParagraphs and Inline:');
if (test('splits paragraphs on blank lines', () => {
assert.strictEqual(
renderMarkdown('first para\n\nsecond para'),
'<p>first para</p>\n<p>second para</p>'
);
})) passed++; else failed++;
if (test('joins consecutive lines into one paragraph', () => {
assert.strictEqual(renderMarkdown('line a\nline b'), '<p>line a\nline b</p>');
})) passed++; else failed++;
if (test('renders bold, italic, strikethrough, inline code', () => {
const out = renderMarkdown('has **bold**, *ital*, _emph_, ~~gone~~, and `a < b`.');
assert.strictEqual(
out,
'<p>has <strong>bold</strong>, <em>ital</em>, <em>emph</em>, <del>gone</del>, and <code>a &lt; b</code>.</p>'
);
})) passed++; else failed++;
if (test('does not italicize snake_case identifiers', () => {
const out = renderMarkdown('use snake_case_name here');
assert.ok(!out.includes('<em>'), `No <em> expected, got ${out}`);
})) passed++; else failed++;
if (test('inline code contents are not parsed further', () => {
assert.strictEqual(renderMarkdown('`**x**`'), '<p><code>**x**</code></p>');
})) passed++; else failed++;
// List tests
console.log('\nLists:');
if (test('renders nested unordered list (2 levels)', () => {
const out = renderMarkdown('- top one\n - child one\n - child two\n- top two');
assert.ok(out.startsWith('<ul>'), 'Should start with <ul>');
assert.ok(out.includes('<li>top one\n<ul>'), 'Nested list should sit inside first <li>');
assert.ok(out.includes('<li>child one</li>'), 'Should contain first child');
assert.ok(out.includes('</ul>\n</li>\n<li>top two</li>'), 'Second top item follows nested list');
})) passed++; else failed++;
if (test('renders ordered list', () => {
assert.strictEqual(
renderMarkdown('1. first\n2. second'),
'<ol>\n<li>first</li>\n<li>second</li>\n</ol>'
);
})) passed++; else failed++;
if (test('renders unordered list nested inside ordered list', () => {
const out = renderMarkdown('1. step one\n - detail\n2. step two');
assert.ok(out.startsWith('<ol>'), 'Outer list should be <ol>');
assert.ok(out.includes('<li>step one\n<ul>\n<li>detail</li>\n</ul>\n</li>'), `Nested <ul> expected, got ${out}`);
})) passed++; else failed++;
if (test('renders task list items (checked and unchecked)', () => {
const out = renderMarkdown('- [ ] draft plan\n- [x] review plan');
assert.ok(out.includes('<li class="task"><input type="checkbox" disabled> draft plan</li>'), `Unchecked task expected, got ${out}`);
assert.ok(out.includes('<li class="task"><input type="checkbox" disabled checked> review plan</li>'), `Checked task expected, got ${out}`);
})) passed++; else failed++;
if (test('asterisk bullets work like hyphen bullets', () => {
assert.strictEqual(renderMarkdown('* a\n* b'), '<ul>\n<li>a</li>\n<li>b</li>\n</ul>');
})) passed++; else failed++;
// Table tests
console.log('\nTables:');
const planTable = [
'| File | Action | Why |',
'|:-----|:------:|----:|',
'| `scripts/lib/plan-canvas/markdown.js` | Create | GFM renderer |',
'| `tests/lib/plan-canvas-markdown.test.js` | Create | **Required** coverage |'
].join('\n');
if (test('renders plan-artifact table with thead/tbody', () => {
const out = renderMarkdown(planTable);
assert.ok(out.startsWith('<table>'), 'Should start with <table>');
assert.ok(out.includes('<thead>'), 'Should contain <thead>');
assert.ok(out.includes('<tbody>'), 'Should contain <tbody>');
})) passed++; else failed++;
if (test('applies alignment styles to header and body cells', () => {
const out = renderMarkdown(planTable);
assert.ok(out.includes('<th style="text-align:left">File</th>'), 'Left-aligned header');
assert.ok(out.includes('<th style="text-align:center">Action</th>'), 'Center-aligned header');
assert.ok(out.includes('<th style="text-align:right">Why</th>'), 'Right-aligned header');
assert.ok(out.includes('<td style="text-align:center">Create</td>'), 'Center-aligned cell');
})) passed++; else failed++;
if (test('renders inline code and bold inside table cells', () => {
const out = renderMarkdown(planTable);
assert.ok(out.includes('<code>scripts/lib/plan-canvas/markdown.js</code>'), 'Code span in cell');
assert.ok(out.includes('<strong>Required</strong> coverage'), 'Bold in cell');
})) passed++; else failed++;
if (test('omits style attribute when column has no alignment', () => {
const out = renderMarkdown('| A | B |\n|---|---|\n| 1 | 2 |');
assert.ok(out.includes('<th>A</th>'), 'Header without style');
assert.ok(out.includes('<td>1</td>'), 'Cell without style');
assert.ok(!out.includes('style='), 'No style attributes at all');
})) passed++; else failed++;
// Code fence tests
console.log('\nFenced Code Blocks:');
if (test('renders fence with language class and escaped content', () => {
assert.strictEqual(
renderMarkdown('```js\nconst x = 1 < 2;\n```'),
'<pre><code class="language-js">const x = 1 &lt; 2;</code></pre>'
);
})) passed++; else failed++;
if (test('escapes <script> inside code blocks', () => {
const out = renderMarkdown('```html\n<script>alert(1)</script>\n```');
assert.ok(!out.includes('<script>'), 'Raw script tag must not survive');
assert.ok(out.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'Escaped script expected');
})) passed++; else failed++;
if (test('does not parse inline markdown inside code blocks', () => {
const out = renderMarkdown('```\n**not bold**\n```');
assert.ok(out.includes('**not bold**'), 'Literal asterisks expected');
assert.ok(!out.includes('<strong>'), 'No strong tag expected');
})) passed++; else failed++;
if (test('sanitizes language attribute to [a-z0-9-]', () => {
const out = renderMarkdown('```C++ extra info\ncode\n```');
assert.ok(out.includes('class="language-c"'), `Sanitized lang expected, got ${out}`);
})) passed++; else failed++;
if (test('unclosed fence consumes to end of input', () => {
const out = renderMarkdown('```\nno closing fence');
assert.strictEqual(out, '<pre><code>no closing fence</code></pre>');
})) passed++; else failed++;
// Blockquote and horizontal rule tests
console.log('\nBlockquotes and Rules:');
if (test('renders blockquote with inline formatting', () => {
assert.strictEqual(
renderMarkdown('> planning note with **bold**'),
'<blockquote>\n<p>planning note with <strong>bold</strong></p>\n</blockquote>'
);
})) passed++; else failed++;
if (test('renders nested blockquotes', () => {
const out = renderMarkdown('> outer\n> > inner');
const opens = out.split('<blockquote>').length - 1;
assert.strictEqual(opens, 2, `Expected 2 blockquotes, got ${opens}`);
assert.ok(out.includes('<p>outer</p>'), 'Outer text expected');
assert.ok(out.includes('<p>inner</p>'), 'Inner text expected');
})) passed++; else failed++;
if (test('renders --- and *** as horizontal rules', () => {
assert.strictEqual(
renderMarkdown('above\n\n---\n\nbelow'),
'<p>above</p>\n<hr>\n<p>below</p>'
);
assert.strictEqual(renderMarkdown('***'), '<hr>');
})) passed++; else failed++;
// XSS tests
console.log('\nXSS Hardening:');
if (test('escapes raw <script> in a paragraph', () => {
const out = renderMarkdown('<script>alert(1)</script>');
assert.strictEqual(out, '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
})) passed++; else failed++;
if (test('javascript: link renders as plain label text', () => {
const out = renderMarkdown('[x](javascript:alert(1))');
assert.ok(!out.includes('<a'), 'No anchor expected');
assert.ok(!out.includes('javascript'), 'Payload URL must be dropped');
assert.ok(out.includes('x'), 'Label text should remain');
})) passed++; else failed++;
if (test('mixed-case JaVaScRiPt: link is blocked', () => {
const out = renderMarkdown('[x](JaVaScRiPt:alert(1))');
assert.ok(!out.includes('<a'), 'No anchor expected');
assert.ok(!/javascript/i.test(out), 'Payload URL must be dropped');
})) passed++; else failed++;
if (test('whitespace-obfuscated scheme is blocked', () => {
const out = renderMarkdown('[x](java\tscript:alert(1))');
assert.ok(!out.includes('<a'), 'No anchor expected');
assert.ok(!out.includes('script:'), 'Payload URL must be dropped');
})) passed++; else failed++;
if (test('data: and vbscript: links are blocked', () => {
assert.strictEqual(renderMarkdown('[x](data:text/html;base64,AAAA)'), '<p>x</p>');
const vb = renderMarkdown('[x](vbscript:msgbox(1))');
assert.ok(!vb.includes('<a'), 'No anchor expected');
assert.ok(!vb.includes('vbscript'), 'Payload URL must be dropped');
})) passed++; else failed++;
if (test('javascript: image renders as plain alt text', () => {
const out = renderMarkdown('![x](javascript:alert(1))');
assert.ok(!out.includes('<img'), 'No img expected');
assert.ok(!out.includes('javascript'), 'Payload URL must be dropped');
})) passed++; else failed++;
if (test('raw <img onerror> HTML is escaped', () => {
const out = renderMarkdown('<img src=x onerror=alert(1)>');
assert.strictEqual(out, '<p>&lt;img src=x onerror=alert(1)&gt;</p>');
})) passed++; else failed++;
if (test('event-handler injection via link text is escaped', () => {
const out = renderMarkdown('["><img src=x onerror=alert(1)>](https://evil.example)');
assert.ok(!out.includes('<img'), 'No raw img expected');
assert.ok(out.includes('&quot;&gt;&lt;img src=x onerror=alert(1)&gt;'), `Escaped label expected, got ${out}`);
})) passed++; else failed++;
if (test('image alt attribute value is escaped', () => {
assert.strictEqual(
renderMarkdown('![a"b](x.png)'),
'<p><img src="x.png" alt="a&quot;b"></p>'
);
})) passed++; else failed++;
// Link protocol tests
console.log('\nLink Protocols:');
if (test('https link gets target=_blank and rel=noopener', () => {
assert.strictEqual(
renderMarkdown('[docs](https://example.com)'),
'<p><a href="https://example.com" target="_blank" rel="noopener">docs</a></p>'
);
})) passed++; else failed++;
if (test('#anchor link has no target/rel', () => {
assert.strictEqual(
renderMarkdown('[phase](#phase-1)'),
'<p><a href="#phase-1">phase</a></p>'
);
})) passed++; else failed++;
if (test('relative link has no target/rel', () => {
assert.strictEqual(
renderMarkdown('[utils](./scripts/lib/utils.js)'),
'<p><a href="./scripts/lib/utils.js">utils</a></p>'
);
})) passed++; else failed++;
if (test('mailto link allowed without target/rel', () => {
assert.strictEqual(
renderMarkdown('[mail](mailto:team@example.com)'),
'<p><a href="mailto:team@example.com">mail</a></p>'
);
})) passed++; else failed++;
if (test('relative image src allowed', () => {
assert.strictEqual(
renderMarkdown('![diagram](assets/plan.png)'),
'<p><img src="assets/plan.png" alt="diagram"></p>'
);
})) passed++; else failed++;
if (test('inline formatting works inside link labels', () => {
const out = renderMarkdown('[`code` and **bold** docs](https://example.com)');
assert.ok(out.includes('<code>code</code> and <strong>bold</strong> docs</a>'), `Formatted label expected, got ${out}`);
})) passed++; else failed++;
// Edge case tests
console.log('\nEdge Cases:');
if (test('empty input returns empty string', () => {
assert.strictEqual(renderMarkdown(''), '');
assert.strictEqual(renderMarkdown(null), '');
assert.strictEqual(renderMarkdown(undefined), '');
})) passed++; else failed++;
if (test('input without trailing newline works', () => {
assert.strictEqual(renderMarkdown('final line'), '<p>final line</p>');
})) passed++; else failed++;
if (test('CRLF line endings are normalized', () => {
assert.strictEqual(renderMarkdown('one\r\n\r\ntwo'), '<p>one</p>\n<p>two</p>');
})) passed++; else failed++;
if (test('whitespace-only input returns empty string', () => {
assert.strictEqual(renderMarkdown(' \n\n '), '');
})) passed++; else failed++;
console.log('\nMermaid diagrams:');
if (test('```mermaid becomes <pre class="mermaid">, not a code block', () => {
const html = renderMarkdown('```mermaid\nflowchart LR\n A --> B\n```');
assert.ok(html.includes('<pre class="mermaid">'), 'expected mermaid container');
assert.ok(!html.includes('language-mermaid'), 'should not render as a code block');
})) passed++; else failed++;
if (test('mermaid arrows are entity-escaped so textContent decodes them', () => {
// The browser decodes &gt; back to > in textContent, so the renderer
// still receives valid `-->` while HTML injection is prevented.
const html = renderMarkdown('```mermaid\nA --> B\n```');
assert.ok(html.includes('A --&gt; B'));
})) passed++; else failed++;
if (test('script tags inside a mermaid block are inert', () => {
const html = renderMarkdown('```mermaid\n<script>alert(1)</script>\n```');
assert.ok(!html.includes('<script>alert(1)</script>'));
assert.ok(html.includes('&lt;script&gt;'));
})) passed++; else failed++;
if (test('a </pre> in the source cannot break out of the container', () => {
const html = renderMarkdown('```mermaid\nA</pre><img src=x onerror=1>\n```');
assert.ok(!html.includes('</pre><img'));
assert.ok(html.includes('&lt;/pre&gt;&lt;img'));
})) passed++; else failed++;
// Summary
console.log('\n=== Test Results ===');
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(`Total: ${passed + failed}\n`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View file

@ -0,0 +1,227 @@
/**
* Tests for scripts/lib/plan-canvas/sessions.js
*
* Run with: node tests/lib/plan-canvas-sessions.test.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
canonicalizeArtifactPath,
createSessionStore,
normalizeFeedbackItem,
sessionKeyFor
} = require('../../scripts/lib/plan-canvas/sessions');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-test-'));
const artifact = path.join(dir, 'demo.plan.md');
fs.writeFileSync(artifact, '# Plan\n');
const store = createSessionStore({ stateDir: path.join(dir, 'state') });
return { dir, artifact, store };
}
function runTests() {
console.log('\n=== Testing plan-canvas sessions.js ===\n');
let passed = 0;
let failed = 0;
const fixtures = [];
console.log('Keys and normalization:');
if (test('sessionKeyFor is a stable 12-char hex key', () => {
const key = sessionKeyFor('/tmp/x.md');
assert.match(key, /^[a-f0-9]{12}$/);
assert.strictEqual(key, sessionKeyFor('/tmp/x.md'));
assert.notStrictEqual(key, sessionKeyFor('/tmp/y.md'));
})) passed++; else failed++;
if (test('canonicalizeArtifactPath resolves relative paths', () => {
const abs = canonicalizeArtifactPath('some-file.md');
assert.ok(path.isAbsolute(abs));
})) passed++; else failed++;
if (test('normalizeFeedbackItem accepts chat, annotation, verdict', () => {
assert.strictEqual(normalizeFeedbackItem({ kind: 'chat', text: 'hi' }, 1).kind, 'chat');
const ann = normalizeFeedbackItem(
{ kind: 'annotation', text: 'fix', anchor: { selector: 'h2', tag: 'h2', snippet: 'Phase 2' } },
2
);
assert.strictEqual(ann.anchor.selector, 'h2');
const verdict = normalizeFeedbackItem({ kind: 'verdict', verdict: 'approve' }, 3);
assert.strictEqual(verdict.verdict, 'approve');
})) passed++; else failed++;
if (test('normalizeFeedbackItem rejects malformed input', () => {
assert.strictEqual(normalizeFeedbackItem(null, 1), null);
assert.strictEqual(normalizeFeedbackItem({ kind: 'nope', text: 'x' }, 1), null);
assert.strictEqual(normalizeFeedbackItem({ kind: 'chat', text: '' }, 1), null);
assert.strictEqual(normalizeFeedbackItem({ kind: 'verdict', verdict: 'maybe' }, 1), null);
assert.strictEqual(normalizeFeedbackItem({ kind: 'annotation', text: 'x' }, 1), null);
assert.strictEqual(normalizeFeedbackItem({ kind: 'annotation', text: '', anchor: { selector: 'p' } }, 1), null);
})) passed++; else failed++;
console.log('\nOpen / reopen semantics:');
if (test('open creates a session keyed by canonical path', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session, refused } = fx.store.open(fx.artifact);
assert.strictEqual(refused, false);
assert.strictEqual(session.status, 'open');
assert.strictEqual(session.file, canonicalizeArtifactPath(fx.artifact));
assert.strictEqual(fx.store.findByFile(fx.artifact).key, session.key);
})) passed++; else failed++;
if (test('user-ended sessions refuse a plain reopen but allow --reopen', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.end(session.key, 'user');
assert.strictEqual(fx.store.open(fx.artifact).refused, true);
const forced = fx.store.open(fx.artifact, { reopen: true });
assert.strictEqual(forced.refused, false);
assert.strictEqual(forced.session.status, 'open');
})) passed++; else failed++;
if (test('agent-ended sessions reopen without a flag', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.end(session.key, 'agent');
assert.strictEqual(fx.store.open(fx.artifact).refused, false);
})) passed++; else failed++;
console.log('\nFeedback queue / deliver-and-drain:');
if (test('queueFeedback filters bad items and mirrors chat', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
const result = fx.store.queueFeedback(session.key, [
{ kind: 'chat', text: 'hello agent' },
{ kind: 'bogus' },
{ kind: 'verdict', verdict: 'approve' }
]);
assert.strictEqual(result.accepted.length, 2);
assert.strictEqual(result.pending, 2);
const chat = fx.store.get(session.key).chat;
assert.strictEqual(chat.length, 2);
assert.strictEqual(chat[0].role, 'user');
assert.ok(chat[1].text.includes('Approved the plan'));
})) passed++; else failed++;
if (test('takeFeedback drains once, then returns waiting', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'one' }]);
const first = fx.store.takeFeedback(session.key);
assert.strictEqual(first.status, 'feedback');
assert.strictEqual(first.items.length, 1);
assert.strictEqual(fx.store.takeFeedback(session.key).status, 'waiting');
})) passed++; else failed++;
if (test('takeFeedback reports missing for unknown sessions', () => {
const fx = makeFixture();
fixtures.push(fx);
assert.strictEqual(fx.store.takeFeedback('deadbeef0000').status, 'missing');
})) passed++; else failed++;
if (test('send-and-end delivers final batch with attribution', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'last words' }], { endSession: true });
const result = fx.store.takeFeedback(session.key);
assert.strictEqual(result.status, 'feedback');
assert.strictEqual(result.sessionEnded, true);
assert.strictEqual(result.endedBy, 'user');
const after = fx.store.takeFeedback(session.key);
assert.strictEqual(after.status, 'ended');
assert.strictEqual(after.endedBy, 'user');
})) passed++; else failed++;
if (test('queueFeedback on an ended session is refused', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.end(session.key, 'agent');
assert.strictEqual(fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'late' }]), null);
})) passed++; else failed++;
console.log('\nPersistence:');
if (test('queued feedback survives a store reload (server restart)', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.queueFeedback(session.key, [{ kind: 'chat', text: 'persist me' }]);
const reloaded = createSessionStore({ stateDir: fx.store.stateDir });
const result = reloaded.takeFeedback(session.key);
assert.strictEqual(result.status, 'feedback');
assert.strictEqual(result.items[0].text, 'persist me');
})) passed++; else failed++;
if (test('corrupt state file starts fresh instead of crashing', () => {
const fx = makeFixture();
fixtures.push(fx);
fs.mkdirSync(fx.store.stateDir, { recursive: true });
fs.writeFileSync(fx.store.stateFile, '{not json');
const reloaded = createSessionStore({ stateDir: fx.store.stateDir });
assert.deepStrictEqual(reloaded.list(), []);
})) passed++; else failed++;
if (test('addAgentReply appends to the transcript', () => {
const fx = makeFixture();
fixtures.push(fx);
const { session } = fx.store.open(fx.artifact);
fx.store.addAgentReply(session.key, 'done, take a look');
const chat = fx.store.get(session.key).chat;
assert.strictEqual(chat[chat.length - 1].role, 'agent');
})) passed++; else failed++;
if (test('list and hasOpenSessions reflect state', () => {
const fx = makeFixture();
fixtures.push(fx);
assert.strictEqual(fx.store.hasOpenSessions(), false);
const { session } = fx.store.open(fx.artifact);
assert.strictEqual(fx.store.hasOpenSessions(), true);
assert.strictEqual(fx.store.list().length, 1);
fx.store.end(session.key, 'user');
assert.strictEqual(fx.store.hasOpenSessions(), false);
})) passed++; else failed++;
for (const fx of fixtures) {
try {
fs.rmSync(fx.dir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
console.log('\n' + '='.repeat(40));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log('='.repeat(40));
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View file

@ -58,6 +58,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/list-installed.js",
"scripts/loop-status.js",
"scripts/observability-readiness.js",
"scripts/plan-canvas.js",
"scripts/operator-readiness-dashboard.js",
"scripts/platform-audit.js",
"scripts/preview-pack-smoke.js",

View file

@ -0,0 +1,377 @@
/**
* 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
*/
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 { 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());
}
// Open an SSE stream and collect parsed events into `received`.
function openSse(port, key) {
const received = [];
let close = () => {};
const ready = new Promise((resolve, reject) => {
const req = http.get(
{ host: '127.0.0.1', port, path: `/events/${key}`, agent: false },
res => {
let buffer = '';
res.on('data', chunk => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const eventMatch = frame.match(/^event: (.+)$/m);
const dataMatch = frame.match(/^data: (.+)$/m);
if (eventMatch && dataMatch) {
received.push({ event: eventMatch[1], data: JSON.parse(dataMatch[1]) });
}
}
});
resolve();
}
);
req.on('error', reject);
close = () => req.destroy();
});
return { received, ready, close: () => close() };
}
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, '<!DOCTYPE html><html><body><h1>Report</h1></body></html>');
fs.writeFileSync(path.join(tmp, 'style.css'), 'body { color: red }');
fs.writeFileSync(path.join(os.tmpdir(), 'plan-canvas-outside.txt'), 'secret');
const store = createSessionStore({ stateDir: path.join(tmp, 'state') });
let idleFired = false;
const canvas = createPlanCanvasServer({
store,
version: '9.9.9-test',
heartbeatMs: 25,
idleTimeoutMs: 0,
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' });
})) 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('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('<h1 id="plan-demo">'));
assert.ok(res.body.includes('<table>'));
assert.ok(res.body.includes('<script src="/sdk.js">'));
assert.strictEqual(res.headers['content-security-policy'], undefined);
// No diagram in this plan → no Mermaid loader shipped.
assert.ok(!res.body.includes('mermaid.run'));
})) passed++; else failed++;
if (await test('a plan containing ```mermaid serves the themed Mermaid loader', async () => {
const diagram = path.join(tmp, 'flow.plan.md');
fs.writeFileSync(diagram, '# Flow\n\n```mermaid\nflowchart LR\n A --> B\n```\n');
const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: diagram } }));
const res = await request(port, 'GET', `/artifact/${opened.key}/`);
assert.ok(res.body.includes('<pre class="mermaid">'), 'diagram container present');
assert.ok(res.body.includes('mermaid.run'), 'loader injected');
assert.ok(res.body.includes("securityLevel: 'strict'"), 'sanitizing config present');
await request(port, 'POST', '/api/end', { body: { file: diagram } });
})) passed++; else failed++;
if (await test('HTML artifacts pass through with the SDK injected before </body>', async () => {
const open = await request(port, 'POST', '/api/sessions', { body: { file: htmlArtifact } });
htmlKey = jsonBody(open).key;
const res = await request(port, 'GET', `/artifact/${htmlKey}/`);
assert.ok(res.body.includes('<h1>Report</h1>'));
assert.ok(res.body.includes('<script src="/sdk.js"></script>\n</body>'));
})) passed++; else failed++;
if (await test('sibling assets are served, traversal is blocked', async () => {
const ok = await request(port, 'GET', `/artifact/${key}/style.css`);
assert.strictEqual(ok.statusCode, 200);
assert.ok(ok.body.includes('color: red'));
const escape = await request(port, 'GET', `/artifact/${key}/..%2Fplan-canvas-outside.txt`);
assert.strictEqual(escape.statusCode, 403);
})) passed++; else failed++;
if (await test('static chrome assets are served', async () => {
for (const asset of ['/canvas.css', '/client.js', '/sdk.js']) {
const res = await request(port, 'GET', asset);
assert.strictEqual(res.statusCode, 200, `${asset} should be 200`);
}
})) passed++; else failed++;
if (await test('await with timeoutMs returns waiting when idle', async () => {
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`);
assert.strictEqual(jsonBody(res).status, 'waiting');
})) passed++; else failed++;
if (await test('await returns missing for files without a session', async () => {
const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(path.join(tmp, 'other.md'))}`);
assert.strictEqual(jsonBody(res).status, 'missing');
})) passed++; else failed++;
if (await test('browser feedback wakes a blocking await; presence transitions', async () => {
const sse = openSse(port, key);
await sse.ready;
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'listening'));
const post = await request(port, 'POST', `/api/session/${key}/feedback`, {
body: {
items: [
{ kind: 'annotation', text: 'tighten this', anchor: { selector: 'h2:nth-of-type(1)', tag: 'h2', snippet: 'Files to Change' } },
{ kind: 'verdict', verdict: 'request-changes' }
]
}
});
assert.strictEqual(jsonBody(post).accepted, 2);
const result = jsonBody(await awaitPromise);
assert.strictEqual(result.status, 'feedback');
assert.strictEqual(result.items.length, 2);
assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)');
assert.strictEqual(result.items[1].verdict, 'request-changes');
await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'working'));
await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2));
sse.close();
})) passed++; else failed++;
if (await test('long-poll heartbeat whitespace arrives before the payload', async () => {
const chunks = [];
const done = new Promise((resolve, reject) => {
const req = http.get(
{ host: '127.0.0.1', port, path: `/api/await?file=${encodeURIComponent(artifact)}`, agent: false },
res => {
res.on('data', chunk => chunks.push(chunk.toString()));
res.on('end', resolve);
}
);
req.on('error', reject);
});
// Heartbeats tick every 25ms in this test server; wait for a few first.
await waitFor(() => chunks.join('').length >= 3);
assert.ok(/^\s+$/.test(chunks.join('')), 'expected only whitespace before payload');
await request(port, 'POST', `/api/session/${key}/feedback`, { body: { items: [{ kind: 'chat', text: 'wake up' }] } });
await done;
const full = chunks.join('');
assert.strictEqual(JSON.parse(full.trim()).status, 'feedback');
})) passed++; else failed++;
if (await test('agent reply lands in the chat via SSE chat-sync', async () => {
const sse = openSse(port, key);
await sse.ready;
const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } });
assert.strictEqual(jsonBody(res).status, 'sent');
await waitFor(() =>
sse.received.some(
e => e.event === 'chat-sync' && e.data.chat.some(m => m.role === 'agent' && m.text.includes('reworked'))
)
);
sse.close();
})) passed++; else failed++;
if (await test('live reload: editing the artifact emits an SSE reload event', async () => {
const sse = openSse(port, key);
await sse.ready;
fs.appendFileSync(artifact, '\n## Addendum\n');
await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 });
sse.close();
})) passed++; else failed++;
if (await test('send-and-end delivers the final batch and ends the session', async () => {
const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => canvas.presenceFor(key) === 'listening');
await request(port, 'POST', `/api/session/${key}/feedback`, {
body: { items: [{ kind: 'chat', text: 'looks good, wrapping up' }], endSession: true }
});
const result = jsonBody(await awaitPromise);
assert.strictEqual(result.status, 'feedback');
assert.strictEqual(result.sessionEnded, true);
assert.strictEqual(result.endedBy, 'user');
const after = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=0`);
assert.strictEqual(jsonBody(after).status, 'ended');
})) passed++; else failed++;
if (await test('user-ended sessions return 409 on plain reopen, open with reopen:true', async () => {
const refused = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
assert.strictEqual(refused.statusCode, 409);
assert.strictEqual(jsonBody(refused).status, 'user-ended');
const forced = await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } });
assert.strictEqual(forced.statusCode, 200);
})) passed++; else failed++;
if (await test('agent end via POST /api/end allows plain reopen', async () => {
const res = await request(port, 'POST', '/api/end', { body: { file: artifact } });
assert.strictEqual(jsonBody(res).endedBy, 'agent');
const reopened = await request(port, 'POST', '/api/sessions', { body: { file: artifact } });
assert.strictEqual(reopened.statusCode, 200);
})) passed++; else failed++;
if (await test('feedback on an ended session is refused with 409', async () => {
await request(port, 'POST', `/api/end`, { body: { file: htmlArtifact } });
const res = await request(port, 'POST', `/api/session/${htmlKey}/feedback`, {
body: { items: [{ kind: 'chat', text: 'too late' }] }
});
assert.strictEqual(res.statusCode, 409);
})) passed++; else failed++;
if (await test('GET / lists sessions in the ECC shell', async () => {
const res = await request(port, 'GET', '/');
assert.ok(res.body.includes('Plan Canvas sessions'));
assert.ok(res.body.includes('demo.plan.md'));
})) passed++; else failed++;
if (await test('POST /shutdown triggers the shutdown callback', async () => {
const res = await request(port, 'POST', '/shutdown');
assert.strictEqual(jsonBody(res).status, 'stopping');
await waitFor(() => idleFired);
})) passed++; else failed++;
if (await test('close() settles a held long-poll instead of hanging', async () => {
await request(port, 'POST', '/api/sessions', { body: { file: artifact, reopen: true } });
const held = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`);
await waitFor(() => canvas.presenceFor(store.findByFile(artifact).key) === 'listening');
await canvas.close();
const result = jsonBody(await held);
assert.strictEqual(result.status, 'waiting');
assert.ok(result.note.includes('shutting down'));
})) passed++; else failed++;
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(path.join(os.tmpdir(), 'plan-canvas-outside.txt'), { force: true });
console.log('\n' + '='.repeat(40));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log('='.repeat(40));
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error(err);
console.log('Passed: 0');
console.log('Failed: 1');
process.exit(1);
});