gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitc613d26bc613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mrec613d26b/tests/e2e-keywords.mjs

10.5 KB

  1. // tests/e2e-keywords.mjs — ticket antcolony#17: the creator's /confirm /reject /prio /hold in comments, against OWN
  2. // ident + tickets (copies, never the live ones). COLONY_E2E_PORT_BASE (default 8700; ident = base+1, tickets = base+2)
  3. import { spawn, spawnSync } from 'node:child_process';
  4. import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
  5. import { dirname, join, resolve } from 'node:path';
  6. import { fileURLToPath } from 'node:url';
  7. 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)
  8. const HERE = dirname(fileURLToPath(import.meta.url));
  9. const APP = resolve(HERE, '..');
  10. const WORK = join(APP, '.scratch', 'e2e-keywords');
  11. const PB = Number(process.env.COLONY_E2E_PORT_BASE || 8700);
  12. const IDENT_PORT = PB + 1, TICKETS_PORT = PB + 2;
  13. if (PB < 8700 || TICKETS_PORT > 8799) throw new Error('ports must stay in 8700-8799');
  14. 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';
  15. const BASE = `http://127.0.0.1:${TICKETS_PORT}`;
  16. const J = JSON.stringify, sleep = (ms) => new Promise(r => setTimeout(r, ms));
  17. let passes = 0, failures = 0;
  18. const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : '\n ' + String(detail).split('\n').join('\n ')}`); if (ok) passes++; else failures++; };
  19. rmSync(WORK, { recursive: true, force: true }); mkdirSync(WORK, { recursive: true });
  20. const CODE = join(WORK, 'app'); mkdirSync(CODE, { recursive: true });
  21. const CODE_SKIP = new Set(['.env', '.scratch', 'runs', 'sessions', 'briefs', 'logs', 'librarian', 'projects', 'mcp-needs-auth-cache.json', '.git']);
  22. for (const e of readdirSync(APP)) if (!CODE_SKIP.has(e)) cpSync(join(APP, e), join(CODE, e), { recursive: true });
  23. const SKIP = new Set(['.env', 'storage', '.sessions', '.scratch', 'server.log', 'server.pid', 'testapp', '.git']);
  24. const TCODE = join(WORK, 'tickets-code');
  25. cpSync(TICKETS_DIR, TCODE, { recursive: true, filter: (src) => { const rel = src.slice(TICKETS_DIR.length).replace(/^\/+/, ''); return rel === '' || !SKIP.has(rel.split('/')[0]); } });
  26. const { startIdent } = await import(join(TCODE, 'tests', 'identkit.mjs'));
  27. let ident = null, tickets = null, tlog = '';
  28. let emitI = 0;
  29. 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 }; };
  30. const login = async (acct, app, name, label) => {
  31. const first = await fetch(BASE + '/'); const cookie = (first.headers.get('set-cookie') || '').split(';')[0];
  32. const cb = await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(acct, app, BASE), { headers: { cookie }, redirect: 'manual' });
  33. await temit('saveDisplayName', [name], cookie);
  34. const tk = await temit('tokenCreate', [label], cookie); return tk.value && tk.value.token;
  35. };
  36. 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 }; };
  37. const get = async (p, n) => (await (await fetch(`${BASE}/api/projects/${p}/tickets/${n}`, { headers: { 'user-agent': 'kw-e2e' } })).json());
  38. const CLEAN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(COLONY_|FAKE_)/.test(k)));
  39. const REG = join(WORK, 'projects'), TOKFILE = join(WORK, 'colony-token'), CTOKFILE = join(WORK, 'creator-token');
  40. 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 || ''); };
  41. try {
  42. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(WORK, 'ident'), port: IDENT_PORT });
  43. const colonyAcct = await ident.signIn('[email protected]');
  44. const app = await ident.registerApp(colonyAcct, 'tickets (keywords e2e)', [BASE]);
  45. const creatorAcct = await ident.signIn('[email protected]');
  46. const CREATOR_ID = await ident.exchange(app, await ident.selectorCode(creatorAcct, app, BASE));
  47. tickets = spawn(join(TCODE, 'bin/hybriel'), ['project.hl'], { cwd: TCODE, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env,
  48. 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',
  49. 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 } });
  50. tickets.stdout.on('data', d => tlog += d); tickets.stderr.on('data', d => tlog += d);
  51. let up = false; for (let i = 0; i < 80 && !up; i++) { try { up = (await fetch(BASE + '/')).ok; } catch {} if (!up) await sleep(250); }
  52. if (!up) throw new Error('tickets did not come up\n' + tlog);
  53. const CT = await login(colonyAcct, app, 'Colony', 'colony');
  54. const XT = await login(creatorAcct, app, 'Creator', 'creator');
  55. writeFileSync(TOKFILE, CT + '\n', { mode: 0o600 }); writeFileSync(CTOKFILE, XT + '\n', { mode: 0o600 });
  56. mkdirSync(REG, { recursive: true });
  57. writeFileSync(join(REG, 'kw.json'), J({ name: 'kw', dev: { host: 'loreana', folder: '/srv/kw' }, concept: 'loreana:/srv/kw/CONCEPT.md', dependsOn: [] }));
  58. 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; };
  59. const say = (who, n, text) => call(who, 'POST', `/api/projects/kw/tickets/${n}/comments`, { text });
  60. const st = async (n) => (await get('kw', n)).ticket.state;
  61. const evs = async (n) => (await get('kw', n)).events;
  62. 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');
  63. const n9 = await mk('stale hold'), n10 = await mk('by a stranger'), n11 = await mk('not first word');
  64. await say(XT, n1, '/hold');
  65. await say(XT, n3, '/prio high');
  66. await say(XT, n4, '/prio banana');
  67. await say(XT, n5, '/confirm');
  68. await say(XT, n9, '/hold'); await call(CT, 'POST', `/api/projects/kw/tickets/${n9}/state`, { state: 'awaiting creator', text: 'moved' });
  69. await say(CT, n10, '/hold');
  70. await say(XT, n11, 'please do not /hold this');
  71. let out = colony(['keywords']);
  72. 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);
  73. out = colony(['keywords', '--post']);
  74. 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);
  75. const e3 = await evs(n3);
  76. 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)));
  77. check('/prio banana → refused in a comment, no priority', (await evs(n4)).some(e => /I did not understand the priority "banana"/.test(e.text)));
  78. 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);
  79. check('a stale /hold (state changed after it) is ignored', await st(n9) == 'awaiting creator' && (await evs(n9)).length == 3, out);
  80. check('a keyword from a non-creator is ignored', await st(n10) == 'open' && (await evs(n10)).length == 2);
  81. check('a keyword that is not the first word is ignored', await st(n11) == 'open' && (await evs(n11)).length == 2);
  82. const before = J(await Promise.all([n1, n3, n4, n5].map(evs)));
  83. out = colony(['keywords', '--post']);
  84. 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);
  85. out = colony(['next']);
  86. 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);
  87. await say(XT, n3, '/prio low');
  88. out = colony(['next']);
  89. check('the last /prio wins: low goes after the older ticket', new RegExp(`NEXT: kw#${n2} `).test(out), out);
  90. const n6 = await mk('confirm', 'awaiting creator'), n7 = await mk('reject', 'awaiting creator'), n8 = await mk('reject bare', 'awaiting creator');
  91. await say(XT, n6, '/confirm'); await say(XT, n7, '/reject too small\nand ugly'); await say(XT, n8, '/reject');
  92. out = colony(['keywords', '--post'], { COLONY_CREATOR_TOKEN_FILE: CTOKFILE });
  93. 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);
  94. const r7 = (await evs(n7)).filter(e => e.kind == 'state').pop();
  95. check('/reject <why> → rejected, the reason is the state text', await st(n7) == 'rejected' && r7.text == 'too small\nand ugly', J(r7));
  96. 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);
  97. // n5 was refused before (marker) — a later token does not act on it behind the creator's back
  98. check('an already-answered /confirm is not redone once the token exists', await st(n5) != 'confirmed', out);
  99. } catch (e) {
  100. check('e2e ran to the end', false, e.stack || String(e));
  101. } finally {
  102. if (tickets) { try { tickets.kill('SIGTERM'); } catch {} await sleep(500); }
  103. if (ident) await ident.stop();
  104. writeFileSync(join(WORK, 'tickets.log'), tlog);
  105. }
  106. console.log(`\n${passes} passed, ${failures} failed (ports ${IDENT_PORT}/${TICKETS_PORT})`);
  107. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • 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