antcolony
All repositories: gitoria
10.5 KB
// tests/e2e-keywords.mjs — ticket antcolony#17: the creator's /confirm /reject /prio /hold in comments, 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 } 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-keywords');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': 'kw-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': 'kw-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: 'kw-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 (keywords 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 n1 = await mk('to hold'), n2 = await mk('older, normal'), n3 = await mk('newer, high'), n4 = await mk('bad prio'), n5 = await mk('confirm no token', 'awaiting creator');const n9 = await mk('stale hold'), n10 = await mk('by a stranger'), n11 = await mk('not first word');await say(XT, n1, '/hold');await say(XT, n3, '/prio high');await say(XT, n4, '/prio banana');await say(XT, n5, '/confirm');await say(XT, n9, '/hold'); await call(CT, 'POST', `/api/projects/kw/tickets/${n9}/state`, { state: 'awaiting creator', text: 'moved' });await say(CT, n10, '/hold');await say(XT, n11, 'please do not /hold this');let out = colony(['keywords']);check('dry run lists the keywords and writes nothing', /KEYWORD kw#\d+ \/hold — would put the ticket on hold/.test(out) && await st(n1) == 'open' && (await evs(n3)).length == 2, out);out = colony(['keywords', '--post']);check('/hold → on hold, with the reason text', await st(n1) == 'on hold' && (await evs(n1)).some(e => e.kind == 'state' && e.to == 'on hold' && e.text == 'On hold, as you asked.'), out);const e3 = await evs(n3);check('/prio high → answered once with the marker', e3.length == 3 && e3.some(e => /Priority is now high/.test(e.text) && /colony-keyword: \S+ · prio/.test(e.text)), J(e3.map(e => e.text)));check('/prio banana → refused in a comment, no priority', (await evs(n4)).some(e => /I did not understand the priority "banana"/.test(e.text)));check('/confirm without a creator token → answered honestly, state unchanged', await st(n5) != 'confirmed' && (await evs(n5)).some(e => /I cannot confirm for you/.test(e.text)), out);check('a stale /hold (state changed after it) is ignored', await st(n9) == 'awaiting creator' && (await evs(n9)).length == 3, out);check('a keyword from a non-creator is ignored', await st(n10) == 'open' && (await evs(n10)).length == 2);check('a keyword that is not the first word is ignored', await st(n11) == 'open' && (await evs(n11)).length == 2);const before = J(await Promise.all([n1, n3, n4, n5].map(evs)));out = colony(['keywords', '--post']);check('a second pass changes nothing (idempotent)', J(await Promise.all([n1, n3, n4, n5].map(evs))) == before && /0 keyword\(s\) handled/.test(out), out);out = colony(['next']);check('next picks the /prio high ticket before the older normal one', new RegExp(`NEXT: kw#${n3} .*highest priority \\(high\\)`).test(out) && out.indexOf(`kw#${n2} `) > out.indexOf(`kw#${n3} `), out);await say(XT, n3, '/prio low');out = colony(['next']);check('the last /prio wins: low goes after the older ticket', new RegExp(`NEXT: kw#${n2} `).test(out), out);const n6 = await mk('confirm', 'awaiting creator'), n7 = await mk('reject', 'awaiting creator'), n8 = await mk('reject bare', 'awaiting creator');await say(XT, n6, '/confirm'); await say(XT, n7, '/reject too small\nand ugly'); await say(XT, n8, '/reject');out = colony(['keywords', '--post'], { COLONY_CREATOR_TOKEN_FILE: CTOKFILE });check('/confirm with the creator token → confirmed, by the creator', await st(n6) == 'confirmed' && (await evs(n6)).some(e => e.kind == 'state' && e.to == 'confirmed' && e.author == 'Creator'), out);const r7 = (await evs(n7)).filter(e => e.kind == 'state').pop();check('/reject <why> → rejected, the reason is the state text', await st(n7) == 'rejected' && r7.text == 'too small\nand ugly', J(r7));check('/reject without a reason → refused in a comment, state unchanged', !['confirmed', 'rejected'].includes(await st(n8)) && (await evs(n8)).some(e => /needs a reason/.test(e.text)), out);// n5 was refused before (marker) — a later token does not act on it behind the creator's backcheck('an already-answered /confirm is not redone once the token exists', await st(n5) != 'confirmed', 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