gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita6af7883a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mrea6af7883/tests/e2e-states.mjs

12.0 KB

  1. // tests/e2e-states.mjs — ticket antcolony#27: the scheduler and librarian on tickets WITH projects, roles and the NEW states
  2. // (tickets#20: review / pending / done / reopened / progress), against OWN
  3. // ident + tickets (copies, never the live ones). COLONY_E2E_PORT_BASE (default 8700; ident = base+1, tickets = base+2)
  4. import { spawn, spawnSync } from 'node:child_process';
  5. import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
  6. import { dirname, join, resolve } from 'node:path';
  7. import { fileURLToPath } from 'node:url';
  8. 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)
  9. const HERE = dirname(fileURLToPath(import.meta.url));
  10. const APP = resolve(HERE, '..');
  11. const WORK = join(APP, '.scratch', 'e2e-states');
  12. const PB = Number(process.env.COLONY_E2E_PORT_BASE || 8700);
  13. const IDENT_PORT = PB + 1, TICKETS_PORT = PB + 2;
  14. if (PB < 8700 || TICKETS_PORT > 8799) throw new Error('ports must stay in 8700-8799');
  15. 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';
  16. const BASE = `http://127.0.0.1:${TICKETS_PORT}`;
  17. const J = JSON.stringify, sleep = (ms) => new Promise(r => setTimeout(r, ms));
  18. let passes = 0, failures = 0;
  19. const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : '\n ' + String(detail).split('\n').join('\n ')}`); if (ok) passes++; else failures++; };
  20. rmSync(WORK, { recursive: true, force: true }); mkdirSync(WORK, { recursive: true });
  21. const CODE = join(WORK, 'app'); mkdirSync(CODE, { recursive: true });
  22. const CODE_SKIP = new Set(['.env', '.scratch', 'runs', 'sessions', 'briefs', 'logs', 'librarian', 'projects', 'mcp-needs-auth-cache.json', '.git']);
  23. for (const e of readdirSync(APP)) if (!CODE_SKIP.has(e)) cpSync(join(APP, e), join(CODE, e), { recursive: true });
  24. const SKIP = new Set(['.env', 'storage', '.sessions', '.scratch', 'server.log', 'server.pid', 'testapp', '.git']);
  25. const TCODE = join(WORK, 'tickets-code');
  26. cpSync(TICKETS_DIR, TCODE, { recursive: true, filter: (src) => { const rel = src.slice(TICKETS_DIR.length).replace(/^\/+/, ''); return rel === '' || !SKIP.has(rel.split('/')[0]); } });
  27. const { startIdent } = await import(join(TCODE, 'tests', 'identkit.mjs'));
  28. let ident = null, tickets = null, tlog = '';
  29. let emitI = 0;
  30. 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 }; };
  31. const login = async (acct, app, name, label) => {
  32. const first = await fetch(BASE + '/'); const cookie = (first.headers.get('set-cookie') || '').split(';')[0];
  33. const cb = await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(acct, app, BASE), { headers: { cookie }, redirect: 'manual' });
  34. await temit('saveDisplayName', [name], cookie);
  35. const tk = await temit('tokenCreate', [label], cookie); return tk.value && tk.value.token;
  36. };
  37. 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 }; };
  38. const get = async (p, n) => (await (await fetch(`${BASE}/api/projects/${p}/tickets/${n}`, { headers: { 'user-agent': 'st-e2e' } })).json());
  39. const CLEAN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(COLONY_|FAKE_)/.test(k)));
  40. const REG = join(WORK, 'projects'), TOKFILE = join(WORK, 'colony-token'), CTOKFILE = join(WORK, 'creator-token');
  41. 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 || ''); };
  42. try {
  43. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(WORK, 'ident'), port: IDENT_PORT });
  44. const colonyAcct = await ident.signIn('[email protected]');
  45. const app = await ident.registerApp(colonyAcct, 'tickets (states e2e)', [BASE]);
  46. const creatorAcct = await ident.signIn('[email protected]');
  47. const CREATOR_ID = await ident.exchange(app, await ident.selectorCode(creatorAcct, app, BASE));
  48. tickets = spawn(join(TCODE, 'bin/hybriel'), ['project.hl'], { cwd: TCODE, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env,
  49. 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',
  50. 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 } });
  51. tickets.stdout.on('data', d => tlog += d); tickets.stderr.on('data', d => tlog += d);
  52. let up = false; for (let i = 0; i < 80 && !up; i++) { try { up = (await fetch(BASE + '/')).ok; } catch {} if (!up) await sleep(250); }
  53. if (!up) throw new Error('tickets did not come up\n' + tlog);
  54. const XT = await login(creatorAcct, app, 'Creator', 'creator');
  55. const CT = await login(colonyAcct, app, 'Colony', 'colony');
  56. writeFileSync(TOKFILE, CT + '\n', { mode: 0o600 }); writeFileSync(CTOKFILE, XT + '\n', { mode: 0o600 });
  57. // the creator opens the project (= its admin); the colony is a member with the role edit
  58. const pr = await call(XT, 'POST', '/api/projects', { title: 'st', slug: 'st' });
  59. const mr = await call(XT, 'POST', '/api/projects/st/members', { user: 'Colony', role: 'edit' });
  60. 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);
  61. const proj = await (await fetch(BASE + '/api/projects', { headers: { 'user-agent': 'st-e2e' } })).json();
  62. check('the server speaks the new states', proj.states.includes('review') && proj.states.includes('reopened'), J(proj.states));
  63. mkdirSync(REG, { recursive: true });
  64. writeFileSync(join(REG, 'st.json'), J({ name: 'st', dev: { host: 'loreana', folder: '/srv/st' }, concept: 'loreana:/srv/st/CONCEPT.md', dependsOn: [] }));
  65. 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; };
  66. const say = (who, n, text) => call(who, 'POST', `/api/projects/st/tickets/${n}/comments`, { text });
  67. const raw = async (n) => (await get('st', n)).ticket.state; // the SERVER's name
  68. const evs = async (n) => (await get('st', n)).events;
  69. // ---- reading: the new names come out as the old ones the logic knows ---------------------------------------------
  70. 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);
  71. let out = colony(['next']);
  72. 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);
  73. check('next: 1 done ticket is counted as confirmed', /1 confirmed/.test(out), out);
  74. await say(XT, e, 'too small, do more');
  75. out = colony(['next']);
  76. 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);
  77. // ---- writing: the scheduler moves tickets in the new names ---------------------------------------------------------
  78. const f = await mk('to hold'), g = await mk('to confirm', 'review'), h = await mk('to reject', 'review');
  79. await say(XT, f, '/hold'); await say(XT, g, '/confirm'); await say(XT, h, '/reject not good');
  80. out = colony(['keywords', '--post'], { COLONY_CREATOR_TOKEN_FILE: CTOKFILE });
  81. check('/hold → pending on the server', await raw(f) == 'pending', out);
  82. 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);
  83. 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);
  84. // /reject was read as `rejected` → 'reopened' shows the reject flow: the creator's keyword is not acted on twice
  85. const before = J(await Promise.all([f, g, h].map(evs)));
  86. out = colony(['keywords', '--post'], { COLONY_CREATOR_TOKEN_FILE: CTOKFILE });
  87. check('a second keyword pass changes nothing', J(await Promise.all([f, g, h].map(evs))) == before, out);
  88. // ---- a report: built → stays "progress" until the deploy gate says; without a live site → review; a question → pending ----
  89. const w = await mk('build me');
  90. 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' });
  91. check('the server takes the old name too (in progress → progress)', lease.status == 201 && await raw(w) == 'progress', lease.text);
  92. const rf = join(WORK, 'r.json');
  93. 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: [] }));
  94. out = colony(['report', rf, '--post']);
  95. check('report --post: the built ticket goes to review (not "awaiting creator")', await raw(w) == 'review', out);
  96. 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'));
  97. check('report --post: the report\'s question is a ticket in state pending', q && q.state == 'pending', J(q));
  98. // ---- the creator is the project admin, found by role, not by name -------------------------------------------------
  99. const cc = await mk('creator words');
  100. await say(XT, cc, 'We decide: every list is sorted by date.');
  101. await say(CT, cc, 'colony chatter that is not a decision');
  102. out = colony(['librarian']);
  103. 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);
  104. const kw = await mk('keyword by the admin'); await say(XT, kw, '/prio high');
  105. const st2 = await mk('keyword by a member'); await say(CT, st2, '/prio high');
  106. out = colony(['keywords']);
  107. 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);
  108. } catch (e) {
  109. check('e2e ran to the end', false, e.stack || String(e));
  110. } finally {
  111. if (tickets) { try { tickets.kill('SIGTERM'); } catch {} await sleep(500); }
  112. if (ident) await ident.stop();
  113. writeFileSync(join(WORK, 'tickets.log'), tlog);
  114. }
  115. console.log(`\n${passes} passed, ${failures} failed (ports ${IDENT_PORT}/${TICKETS_PORT})`);
  116. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • 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