d48cda4081
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|