antcolony
All repositories: gitoria
10.2 KB
// tests/e2e-status.mjs — ticket antcolony#19: the status and the daily summary from tickets, against OWN// ident + tickets (copies, never the live ones). COLONY_E2E_PORT_BASE (default 8700; ident = base+1, tickets = base+2)import { spawn, spawnSync } from 'node:child_process';import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, readFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';process.env.COLONY_SANDBOX = process.env.COLONY_SANDBOX || 'off'; // antcolony#18: the fake claude lives outside any box (tests/e2e-sandbox.mjs tests the box)const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const WORK = join(APP, '.scratch', 'e2e-status');const PB = Number(process.env.COLONY_E2E_PORT_BASE || 8700);const IDENT_PORT = PB + 1, TICKETS_PORT = PB + 2;if (PB < 8700 || TICKETS_PORT > 8799) throw new Error('ports must stay in 8700-8799');const TICKETS_DIR = process.env.COLONY_E2E_TICKETS_DIR || '/media/STORAGE/projects/tickets.worldapi.org', IDENT_DIR = process.env.COLONY_E2E_IDENT_DIR || '/media/STORAGE/projects/ident.worldapi.org';const BASE = `http://127.0.0.1:${TICKETS_PORT}`;const J = JSON.stringify, sleep = (ms) => new Promise(r => setTimeout(r, ms));let passes = 0, failures = 0;const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : '\n ' + String(detail).split('\n').join('\n ')}`); if (ok) passes++; else failures++; };rmSync(WORK, { recursive: true, force: true }); mkdirSync(WORK, { recursive: true });const CODE = join(WORK, 'app'); mkdirSync(CODE, { recursive: true });const CODE_SKIP = new Set(['.env', '.scratch', 'runs', 'sessions', 'briefs', 'logs', 'librarian', 'projects', 'mcp-needs-auth-cache.json', '.git']);for (const e of readdirSync(APP)) if (!CODE_SKIP.has(e)) cpSync(join(APP, e), join(CODE, e), { recursive: true });const SKIP = new Set(['.env', 'storage', '.sessions', '.scratch', 'server.log', 'server.pid', 'testapp', '.git']);const TCODE = join(WORK, 'tickets-code');cpSync(TICKETS_DIR, TCODE, { recursive: true, filter: (src) => { const rel = src.slice(TICKETS_DIR.length).replace(/^\/+/, ''); return rel === '' || !SKIP.has(rel.split('/')[0]); } });const { startIdent } = await import(join(TCODE, 'tests', 'identkit.mjs'));let ident = null, tickets = null, tlog = '';let emitI = 0;const temit = async (event, payload, cookie) => { const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: ++emitI, event, payload }) }); const raw = await r.text(); let j = null; try { j = JSON.parse(raw); } catch {} return { value: j && j.value, raw }; };const login = async (acct, app, name, label) => {const first = await fetch(BASE + '/'); const cookie = (first.headers.get('set-cookie') || '').split(';')[0];const cb = await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(acct, app, BASE), { headers: { cookie }, redirect: 'manual' });await temit('saveDisplayName', [name], cookie);const tk = await temit('tokenCreate', [label], cookie); return tk.value && tk.value.token;};const call = async (token, method, path, body) => { const r = await fetch(BASE + path, { method, headers: { 'user-agent': 'st-e2e', 'content-type': 'application/json', authorization: 'Bearer ' + token }, body: body ? J(body) : undefined }); const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {} return { status: r.status, json: j, text: t }; };const get = async (p, n) => (await (await fetch(`${BASE}/api/projects/${p}/tickets/${n}`, { headers: { 'user-agent': 'st-e2e' } })).json());const CLEAN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(COLONY_|FAKE_)/.test(k)));const REG = join(WORK, 'projects'), TOKFILE = join(WORK, 'colony-token'), CTOKFILE = join(WORK, 'creator-token');const colony = (args, extra = {}) => { const r = spawnSync(join(CODE, 'colony'), args, { cwd: WORK, env: { ...CLEAN, COLONY_TICKETS_URL: BASE, COLONY_PROJECTS_DIR: REG, COLONY_TOKEN_FILE: TOKFILE, COLONY_USER_AGENT: 'st-e2e', COLONY_CREATOR_NAMES: 'Creator', COLONY_LIBRARIAN_DIR: join(WORK, 'librarian'), ...extra }, encoding: 'utf8', timeout: 90000 }); return (r.stdout || '') + (r.stderr || ''); };try {ident = await startIdent({ identDir: IDENT_DIR, workDir: join(WORK, 'ident'), port: IDENT_PORT });const colonyAcct = await ident.signIn('[email protected]');const app = await ident.registerApp(colonyAcct, 'tickets (status e2e)', [BASE]);const creatorAcct = await ident.signIn('[email protected]');const CREATOR_ID = await ident.exchange(app, await ident.selectorCode(creatorAcct, app, BASE));tickets = spawn(join(TCODE, 'bin/hybriel'), ['project.hl'], { cwd: TCODE, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env,TICKETS_PORT: String(TICKETS_PORT), TICKETS_STORAGE: join(WORK, 'data', 'mpackdb'), TICKETS_SESSIONS: join(WORK, 'data', 'sessions'), HL_HOST: '127.0.0.1', TICKETS_WATCH: '0',IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TICKETS_PUBLIC_URL: BASE, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret, TICKETS_CREATOR_IDENTITY: CREATOR_ID } });tickets.stdout.on('data', d => tlog += d); tickets.stderr.on('data', d => tlog += d);let up = false; for (let i = 0; i < 80 && !up; i++) { try { up = (await fetch(BASE + '/')).ok; } catch {} if (!up) await sleep(250); }if (!up) throw new Error('tickets did not come up\n' + tlog);const CT = await login(colonyAcct, app, 'Colony', 'colony');const XT = await login(creatorAcct, app, 'Creator', 'creator');writeFileSync(TOKFILE, CT + '\n', { mode: 0o600 }); writeFileSync(CTOKFILE, XT + '\n', { mode: 0o600 });mkdirSync(REG, { recursive: true });writeFileSync(join(REG, 'kw.json'), J({ name: 'kw', dev: { host: 'loreana', folder: '/srv/kw' }, concept: 'loreana:/srv/kw/CONCEPT.md', dependsOn: [] }));const mk = async (subject, state) => { const cr = await call(CT, 'POST', '/api/projects/kw/tickets', { subject, summary: 's' }); if (!cr.json || !cr.json.ticket) throw new Error('create: ' + cr.status + ' ' + cr.text); const t = cr.json.ticket; if (state) await call(CT, 'POST', `/api/projects/kw/tickets/${t.number}/state`, { state, text: 'seed' }); await sleep(30); return t.number; };const say = (who, n, text) => call(who, 'POST', `/api/projects/kw/tickets/${n}/comments`, { text });const st = async (n) => (await get('kw', n)).ticket.state;const evs = async (n) => (await get('kw', n)).events;const inbox = async () => (await (await fetch(`${BASE}/api/tickets`, { headers: { 'user-agent': 'st-e2e' } })).json()).tickets.filter(t => t.project === 'kw' && (t.source || '').startsWith('colony:digest:'));const SD = join(WORK, 'status');const env = { COLONY_INBOX_PROJECT: 'kw', COLONY_STATUS_DIR: SD };const a = await mk('built one', 'awaiting creator'), b = await mk('built two', 'awaiting creator');const q = await mk('Question: which colour?', 'awaiting creator');await mk('queued', null); const w = await mk('working', 'in progress'); await mk('parked', 'on hold');let out = colony(['digest'], env);check('dry run: 3 tickets wait, question and built listed apart, nothing filed', /3 tickets wait for you/.test(out) && /Questions to answer \(1\)/.test(out) && /Built, to check \(2\)/.test(out) && /kw#\d+ — Question: which colour\?/.test(out) && (await inbox()).length == 0, out);check('the summary counts what the colony works on and queues', /working on 1 and has 1 more in the queue/.test(out), out);out = colony(['digest', '--post'], env);const d1 = await inbox();check('--post files ONE summary ticket in the inbox project, with the day in its source', d1.length == 1 && /^Daily summary \d{4}-\d\d-\d\d — 3 tickets wait for you$/.test(d1[0].subject) && /^colony:digest:\d{4}-\d\d-\d\d$/.test(d1[0].source), out + J(d1));check('the summary ticket is in the creator inbox (awaiting creator)', d1.length == 1 && d1[0].state == 'awaiting creator', J(d1));const body = (await get('kw', d1[0].number)).ticket.summary || '';check('the summary text is the ticket text', /3 tickets wait for you\./.test(body) && /built one/.test(body), body);out = colony(['digest', '--post'], env);check('a second --post the same day files nothing new', (await inbox()).length == 1 && /already filed today/.test(out), out);out = colony(['digest'], env);check('the summary ticket does not count as waiting for the creator', /3 tickets wait for you/.test(out), out);const md = existsSync(join(SD, 'STATUS.md')) ? readFileSync(join(SD, 'STATUS.md'), 'utf8') : '';check('status/STATUS.md is written: table row with the state counts', /\| kw \| 1 \| 1 \| 3 \| 1 \| 0 \| 0 \|/.test(md) && /do not edit by hand/.test(md), md);const before = existsSync(join(SD, 'STATUS.md'));rmSync(SD, { recursive: true, force: true });out = colony(['status', '--write'], env);check('status prints the table and --write writes the file', /\| project \| open \|/.test(out) && existsSync(join(SD, 'STATUS.md')) && /Being worked on in kw/.test(out), out);// the daemon: `run --once` files the summary once (source of today exists → no second ticket)out = colony(['digest', '--post'], { ...env, COLONY_TOKEN_FILE: join(WORK, 'missing') });check('--post without a token says so and files nothing', /COLONY_TOKEN_FILE: no such file/.test(out) && (await inbox()).length == 1, out);out = colony(['run', '--once', '--live'], { ...env, COLONY_DIGEST_HOUR: '0', COLONY_LIBRARIAN: 'off', COLONY_QUOTA_RESERVE: '100', COLONY_WEEK_LIMIT: '100', COLONY_SANDBOX: 'off' });check('run --once: digest tick finds today filed, no duplicate', /DIGEST \d{4}-\d\d-\d\d — 3 waiting · already filed kw#\d+/.test(out) && (await inbox()).length == 1, out);out = colony(['run', '--once', '--live'], { ...env, COLONY_DIGEST_HOUR: 'off', COLONY_LIBRARIAN: 'off', COLONY_QUOTA_RESERVE: '100', COLONY_WEEK_LIMIT: '100' });check('COLONY_DIGEST_HOUR=off: no digest line', !/DIGEST /.test(out), out);} catch (e) {check('e2e ran to the end', false, e.stack || String(e));} finally {if (tickets) { try { tickets.kill('SIGTERM'); } catch {} await sleep(500); }if (ident) await ident.stop();writeFileSync(join(WORK, 'tickets.log'), tlog);}console.log(`\n${passes} passed, ${failures} failed (ports ${IDENT_PORT}/${TICKETS_PORT})`);process.exit(failures ? 1 : 0);
Branches
- mainmain branch