mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-19 11:34:09 +02:00
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:
parent
4130457d67
commit
a511395613
42 changed files with 4398 additions and 60 deletions
100
tests/hooks/plan-canvas-sessions-hook.test.js
Normal file
100
tests/hooks/plan-canvas-sessions-hook.test.js
Normal 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();
|
||||
265
tests/integration/plan-canvas-e2e.test.js
Normal file
265
tests/integration/plan-canvas-e2e.test.js
Normal 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);
|
||||
});
|
||||
122
tests/lib/loopback-guard.test.js
Normal file
122
tests/lib/loopback-guard.test.js
Normal 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();
|
||||
409
tests/lib/plan-canvas-markdown.test.js
Normal file
409
tests/lib/plan-canvas-markdown.test.js
Normal 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\'>'),
|
||||
'<a href="x" & 'y'>'
|
||||
);
|
||||
})) 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 < 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 < 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('<script>alert(1)</script>'), '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><script>alert(1)</script></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(')');
|
||||
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><img src=x onerror=alert(1)></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('"><img src=x onerror=alert(1)>'), `Escaped label expected, got ${out}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('image alt attribute value is escaped', () => {
|
||||
assert.strictEqual(
|
||||
renderMarkdown(''),
|
||||
'<p><img src="x.png" alt="a"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(''),
|
||||
'<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 > 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 --> 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('<script>'));
|
||||
})) 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('</pre><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();
|
||||
227
tests/lib/plan-canvas-sessions.test.js
Normal file
227
tests/lib/plan-canvas-sessions.test.js
Normal 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();
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
377
tests/scripts/plan-canvas.test.js
Normal file
377
tests/scripts/plan-canvas.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue