antcolony
All repositories: gitoria
12.0 KB
// tests/e2e-states.mjs — ticket antcolony#27: the scheduler and librarian on tickets WITH projects, roles and the NEW states// (tickets#20: review / pending / done / reopened / progress), 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-states');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: 'Nobody', 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 (states 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 XT = await login(creatorAcct, app, 'Creator', 'creator');const CT = await login(colonyAcct, app, 'Colony', 'colony');writeFileSync(TOKFILE, CT + '\n', { mode: 0o600 }); writeFileSync(CTOKFILE, XT + '\n', { mode: 0o600 });// the creator opens the project (= its admin); the colony is a member with the role editconst pr = await call(XT, 'POST', '/api/projects', { title: 'st', slug: 'st' });const mr = await call(XT, 'POST', '/api/projects/st/members', { user: 'Colony', role: 'edit' });check('setup: the creator opened project st, Colony is a member with the role edit', pr.status < 300 && mr.status < 300, pr.status + ' ' + pr.text + ' | ' + mr.status + ' ' + mr.text);const proj = await (await fetch(BASE + '/api/projects', { headers: { 'user-agent': 'st-e2e' } })).json();check('the server speaks the new states', proj.states.includes('review') && proj.states.includes('reopened'), J(proj.states));mkdirSync(REG, { recursive: true });writeFileSync(join(REG, 'st.json'), J({ name: 'st', dev: { host: 'loreana', folder: '/srv/st' }, concept: 'loreana:/srv/st/CONCEPT.md', dependsOn: [] }));const mk = async (subject, state, by = CT) => { const cr = await call(by, 'POST', '/api/projects/st/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) { const s = await call(by, 'POST', `/api/projects/st/tickets/${t.number}/state`, { state, text: 'seed' }); if (s.status != 201) throw new Error('state ' + state + ': ' + s.status + ' ' + s.text); } await sleep(30); return t.number; };const say = (who, n, text) => call(who, 'POST', `/api/projects/st/tickets/${n}/comments`, { text });const raw = async (n) => (await get('st', n)).ticket.state; // the SERVER's nameconst evs = async (n) => (await get('st', n)).events;// ---- reading: the new names come out as the old ones the logic knows ---------------------------------------------const a = await mk('open one'), b = await mk('under review', 'review'), c = await mk('pending one', 'pending'), d = await mk('done one', 'done'), e = await mk('rejected one', 'reopened', XT);let out = colony(['next']);check('next: the open ticket is the work; review / pending / done are not', new RegExp(`NEXT: st#${a} `).test(out) && !new RegExp(`NEXT: st#(${b}|${c}|${d}) `).test(out) && /1 awaiting creator/.test(out), out);check('next: 1 done ticket is counted as confirmed', /1 confirmed/.test(out), out);await say(XT, e, 'too small, do more');out = colony(['next']);check('next: a reopened ticket is picked up like a rejected one', new RegExp(`st#${e} .*(REJECTED by the creator|rejected by the creator)`).test(out) || /rejected by the creator/i.test(out), out);// ---- writing: the scheduler moves tickets in the new names ---------------------------------------------------------const f = await mk('to hold'), g = await mk('to confirm', 'review'), h = await mk('to reject', 'review');await say(XT, f, '/hold'); await say(XT, g, '/confirm'); await say(XT, h, '/reject not good');out = colony(['keywords', '--post'], { COLONY_CREATOR_TOKEN_FILE: CTOKFILE });check('/hold → pending on the server', await raw(f) == 'pending', out);check('/confirm → done on the server, by the creator', await raw(g) == 'done' && (await evs(g)).some(x => x.kind == 'state' && x.to == 'done' && x.author == 'Creator'), out);check('/reject → reopened on the server, the reason is the state text', await raw(h) == 'reopened' && (await evs(h)).filter(x => x.kind == 'state').pop().text == 'not good', out);// /reject was read as `rejected` → 'reopened' shows the reject flow: the creator's keyword is not acted on twiceconst before = J(await Promise.all([f, g, h].map(evs)));out = colony(['keywords', '--post'], { COLONY_CREATOR_TOKEN_FILE: CTOKFILE });check('a second keyword pass changes nothing', J(await Promise.all([f, g, h].map(evs))) == before, out);// ---- a report: built → stays "progress" until the deploy gate says; without a live site → review; a question → pending ----const w = await mk('build me');const lease = await call(CT, 'POST', `/api/projects/st/tickets/${w}/state`, { state: 'in progress', text: 'A worker started on this ticket.\n\ncolony-lease: s-x · loreana · started · 2999-01-01T00:00:00.000Z' });check('the server takes the old name too (in progress → progress)', lease.status == 201 && await raw(w) == 'progress', lease.text);const rf = join(WORK, 'r.json');writeFileSync(rf, J({ ticket: `st#${w}`, session: 's-x', result: 'The thing is built and answers.', complete: true, test: ['open the site — it says ok', 'reload — it still says ok'], done: ['Built it'], verified: [{ claim: 'it answers', command: 'curl x', output: 'ok' }], open: [], issues: [], questions: ['Is small enough?'], decided: [], running: [], refused: [] }));out = colony(['report', rf, '--post']);check('report --post: the built ticket goes to review (not "awaiting creator")', await raw(w) == 'review', out);const q = (await (await fetch(BASE + '/api/projects/st/tickets', { headers: { 'user-agent': 'st-e2e' } })).json()).tickets.find(t => t.source && t.source.includes('questions:0'));check('report --post: the report\'s question is a ticket in state pending', q && q.state == 'pending', J(q));// ---- the creator is the project admin, found by role, not by name -------------------------------------------------const cc = await mk('creator words');await say(XT, cc, 'We decide: every list is sorted by date.');await say(CT, cc, 'colony chatter that is not a decision');out = colony(['librarian']);check('librarian: the admin\'s text is a creator text although COLONY_CREATOR_NAMES names somebody else', new RegExp(`LIBRARIAN TEXT st#${cc} .*We decide`).test(out) && !/colony chatter/.test(out), out);const kw = await mk('keyword by the admin'); await say(XT, kw, '/prio high');const st2 = await mk('keyword by a member'); await say(CT, st2, '/prio high');out = colony(['keywords']);check('keywords: only the admin (by role) counts, the edit member\'s /prio is ignored', new RegExp(`KEYWORD st#${kw} /prio`).test(out) && !new RegExp(`KEYWORD st#${st2} /prio`).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
Latest commits
- 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
- a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
- c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
- 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
- 7f9660eeState of 2026-09-27, before the move to gitoriamre