gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7538b0347538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre7538b034/tests/browser.mjs

86.3 KB

  1. // tests/browser.mjs — THE GATE of tickets.worldapi.org: its own server on its own
  2. // storage, the import command, the JSON API, and TWO real headless Chromes (two
  3. // viewers) for the live push. Everything is checked in the browser's DOM, not in
  4. // curl output. Ticket #10 added: the strict API table (unknown / missing / empty /
  5. // wrong-type fields → 400 naming the field, nothing written), Vienna local time (checked
  6. // against node's Intl, also tests/localtime.hl's DST edges), and the paragraph import
  7. // (a CRLF fixture in .scratch/gate-import → #7, rendered with a blank line between).
  8. // Ticket #15 added: `author` required on every POST (missing/empty → 400 naming author),
  9. // invalid JSON → 400 { error } without source paths (jsoncheck.hl), the web forms have no
  10. // name field (author 'creator'), and list lines kept by the import (.scratch/gate-import-lists → hybriel #3).
  11. // Ticket #38 (mission 008) added: UUID keys + storage in <store>/mpackdb, per-project
  12. // numbers and URLs /<slug>/<number> (ticket #25; was /projects/<slug>/<number>). The gate FIRST writes an old-format store
  13. // (tests/oldstore.hl: 5 tickets, old #1–#5 in projects alpha / beta.example.org), migrates
  14. // it TWICE with tools/migrate-008.hl (the second run must write nothing), and runs the
  15. // server on the migrated tables: old numbers answer on /api/tickets/<n>, /tickets/<n>
  16. // redirects (301) to the new URL, the per-project API, live pushes carrying the new hrefs.
  17. // Ticket #7 (mission 011) added LOGIN VIA IDENT: the gate runs its OWN ident (a copy of
  18. // ident's code without .env / storage — codes go to a mail sink, never real mail; tests/identkit.mjs)
  19. // on :8353, registers tickets there (origin = this gate's server), and then: every API write
  20. // needs `Authorization: Bearer <token>` (401 otherwise, checked before the body; `author` in a
  21. // body → 400), a machine user "gate" logs in through the login button's server half
  22. // (/login/callback) and makes its token over the face; the FIRST server runs without
  23. // TICKETS_CREATOR_IDENTITY (nobody may confirm/reject → 403), the second with alice's per-app id.
  24. // In the browsers: signed out = reading only; A (alice, the creator) logs in with the identity
  25. // SELECTOR, B (bob) with the LOGIN BUTTON; both get the display-name prompt; writes show the
  26. // user as author; bob cannot confirm/reject, alice can; bob's token on /you: shown once → API
  27. // write as bob → revoke → 401; forged face sessions (#31) are refused; old events keep their
  28. // authors; logout resets the selector.
  29. // Ticket #12 (mission 024) added THE MARKDOWN EDITOR: comment, state note, edit summary and new-ticket
  30. // summary are <md-editor>s around their textareas (shared/md-editor.js). Real typing (formatting
  31. // while typing, "- " lists, Ctrl+Enter sends), the toolbar by click / tap at 390px, a hostile rich
  32. // paste reduced to the subset, the edit form opening the stored Markdown byte for byte, SSR keeps
  33. // the plain textareas (no JS = as before). The component's own test: worldapi-components/test/run.mjs.
  34. //
  35. // node tests/browser.mjs (TICKETS_GATE_PORT to move the server, default 8352;
  36. // TICKETS_GATE_IDENT_PORT the gate's ident, default 8353;
  37. // TICKETS_GATE_IDENT_DIR ident's code, default ../ident.worldapi.org)
  38. //
  39. // Debug ports: 8620-8629 (browser A) and 8630-8639 (browser B) — TICKETS_GATE_CHROME_A /
  40. // TICKETS_GATE_CHROME_B ("8810-8814") move them (mission 017: parallel workers get disjoint
  41. // port ranges); browser C (mission 046, the installable app / offline part at the end) 8640-8649,
  42. // TICKETS_GATE_CHROME_C. Screenshots at 390px
  43. // and 1280px land in .scratch/gate-*.png — LOOK at them. The whole server log is kept in
  44. // .scratch/gate-server.log. Every process started here (server, import runs, Chromes)
  45. // is stopped by PID in `finally`.
  46. import { spawn, spawnSync } from 'node:child_process';
  47. import http from 'node:http';
  48. import { rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, copyFileSync, readFileSync } from 'node:fs';
  49. import { dirname, join, resolve } from 'node:path';
  50. import { fileURLToPath } from 'node:url';
  51. import { launchBrowser } from './cdp.mjs';
  52. import { startIdent } from './identkit.mjs';
  53. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  54. const HERE = dirname(fileURLToPath(import.meta.url));
  55. const APP = resolve(HERE, '..');
  56. const BIN = join(APP, 'bin/hybriel');
  57. const PORT = Number(process.env.TICKETS_GATE_PORT || 8352);
  58. const BASE = `http://127.0.0.1:${PORT}`;
  59. const SCRATCH = join(APP, '.scratch');
  60. const STORE = join(SCRATCH, 'gate-store');
  61. const IDENT_PORT = Number(process.env.TICKETS_GATE_IDENT_PORT || 8353);
  62. // ident's CODE (read only — copied without .env/storage by identkit): the sibling folder,
  63. // or the Loreana path when the gate runs in a copy (.scratch/dev…)
  64. const IDENT_DIR = process.env.TICKETS_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));
  65. rmSync(STORE, { recursive: true, force: true });
  66. mkdirSync(join(STORE, 'mpackdb'), { recursive: true });
  67. let failures = 0, passes = 0;
  68. function check(label, ok, detail = '') {
  69. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  70. if (ok) passes++; else failures++;
  71. }
  72. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  73. const J = JSON.stringify;
  74. // ---- the legacy fixture (ticket #20): a store from before projects were own data; the server's migrate.hl
  75. // turns it into projects with ids, members and the new states at its first start ----------------------
  76. const hl = (script, env) => { const r = spawnSync(BIN, [script], { cwd: APP, env: { ...process.env, ...env }, encoding: 'utf8', timeout: 60000 }); return (r.stdout || '') + (r.stderr || ''); };
  77. const fixture = hl('tests/oldstore.hl', { TICKETS_STORAGE: join(STORE, 'mpackdb') });
  78. check('migrate: the pre-#20 fixture is written (2 projects, 5 tickets, 8 events)', /oldstore: 2 projects, 5 tickets, 8 events/.test(fixture), fixture);
  79. // ---- the servers: our own ident, then tickets (twice: without and with a creator) ----------
  80. let log = '';
  81. let server = null;
  82. let ident = null;
  83. // ticket #9 (mission 012): the exchanges ident RECEIVES from tickets, counted by a small proxy
  84. // between the two (tickets' IDENT_EXCHANGE_URL → here → our ident)
  85. const EXCH_PORT = Number(process.env.TICKETS_GATE_EXCHANGE_PORT || 8357);
  86. const exchanges = [];
  87. const exchProxy = http.createServer((req, res) => {
  88. let body = '';
  89. req.on('data', d => body += d);
  90. req.on('end', async () => {
  91. try {
  92. const r = await fetch(`http://127.0.0.1:${IDENT_PORT}${req.url}`, { method: req.method, headers: { 'content-type': req.headers['content-type'] || 'application/json' }, body: req.method === 'GET' ? undefined : body });
  93. const t = await r.text();
  94. let code = ''; try { code = JSON.parse(body).code || ''; } catch {}
  95. exchanges.push({ path: req.url, code, status: r.status });
  96. res.writeHead(r.status, { 'content-type': r.headers.get('content-type') || 'application/json' });
  97. res.end(t);
  98. } catch (e) { req.socket.destroy(); } // ident stopped: tickets must see NO answer, like a direct connection
  99. });
  100. });
  101. await new Promise((ok, bad) => { exchProxy.once('error', bad); exchProxy.listen(EXCH_PORT, '127.0.0.1', ok); });
  102. let APPKEY = null;
  103. function startTickets(extraEnv) {
  104. log += `\n==== tickets server start ${J(Object.keys(extraEnv))}\n`;
  105. server = spawn(BIN, ['project.hl'], {
  106. cwd: APP,
  107. env: { ...process.env, TICKETS_PORT: String(PORT), TICKETS_STORAGE: join(STORE, 'mpackdb'), TICKETS_SESSIONS: join(STORE, 'sessions'),
  108. IDENT_URL: `http://127.0.0.1:${IDENT_PORT}`, IDENT_EXCHANGE_URL: `http://127.0.0.1:${EXCH_PORT}`, TICKETS_PUBLIC_URL: BASE, IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret,
  109. TICKETS_CREATOR_IDENTITY: '', TICKETS_WATCH: '0', ...extraEnv },
  110. stdio: ['ignore', 'pipe', 'pipe'],
  111. });
  112. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  113. }
  114. async function stopTickets() {
  115. if (!server) return;
  116. const s = server;
  117. try { s.kill('SIGTERM'); } catch {}
  118. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  119. }
  120. async function ticketsUp() {
  121. for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/'); if (r.ok) return; } catch {} await sleep(250); }
  122. throw new Error('server did not come up\n' + log);
  123. }
  124. // THE API: reads never carry a token (reading is public); writes carry the gate user's
  125. // token unless another (or none: null) is given
  126. let TOKEN = null;
  127. const api = async (method, path, body, token = TOKEN) => {
  128. const headers = body ? { 'content-type': 'application/json' } : {};
  129. if (method !== 'GET' && token) headers.authorization = 'Bearer ' + token;
  130. const r = await fetch(BASE + path, { method, headers, body: body ? J(body) : undefined });
  131. let json = null; try { json = await r.json(); } catch {}
  132. return { status: r.status, json };
  133. };
  134. const runImport = (token = TOKEN) => {
  135. const r = spawnSync(BIN, ['tools/import.hl'], { cwd: APP, env: { ...process.env, TICKETS_URL: BASE, TICKETS_TOKEN: token || '' }, encoding: 'utf8', timeout: 60000 });
  136. return (r.stdout || '') + (r.stderr || '');
  137. };
  138. // a face over the REST carrier (POST /__hl/emit) with a cookie (or none)
  139. let emitI = 0;
  140. // hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is
  141. // ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.
  142. const framework_refused = (raw) => { try { const j = JSON.parse(raw); return j.ok === false && /the `session` parameter is filled by the server/.test(j.error || ''); } catch { return false; } };
  143. const temit = async (event, payload, cookie) => {
  144. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  145. const raw = await r.text();
  146. let j = null; try { j = JSON.parse(raw); } catch {}
  147. return { status: r.status, value: j ? j.value : undefined, raw };
  148. };
  149. const cookieOf = (r) => (r.headers.get('set-cookie') || '').split(';')[0];
  150. const texts = (page, sel) => page.evaluate(`Array.from(document.querySelectorAll(${J(sel)})).map(e => e.textContent.trim())`);
  151. const type = (page, sel, value) => page.evaluate(`(() => { const i = document.querySelector(${J(sel)}); i.value = ${J(value)}; i.dispatchEvent(new Event("input", { bubbles: true })); })()`);
  152. const choose = (page, sel, value) => page.evaluate(`(() => { const i = document.querySelector(${J(sel)}); i.value = ${J(value)}; i.dispatchEvent(new Event("change", { bubbles: true })); })()`);
  153. // CONNECTED = the server has READ the tab's `hello` (its mounts), not just an open socket: a push the server sends
  154. // before that reaches no component (mission 046: "inbox follows the state change live" failed ~1 in 5 when the API
  155. // write overtook the hello). A `ping` sent after the hello on the same socket is answered after it — its pong proves it.
  156. const helloRead = `new Promise((ok) => { const s = window.__hl.socket; const h = (ev) => { let f = null; try { f = JSON.parse(ev.data); } catch {} if (f && f.t === 'pong') { s.removeEventListener('message', h); ok(true); } }; s.addEventListener('message', h); s.send(JSON.stringify({ t: 'ping' })); setTimeout(() => ok(false), 10000); })`;
  157. const connected = async (page, label) => {
  158. await page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });
  159. if (!(await page.evaluate(helloRead))) throw new Error('no pong after the hello: ' + label);
  160. };
  161. // a client-side navigation by a REAL click, then the socket announced the new mounts
  162. const clickNav = async (page, sel, cond, label) => { await page.click(sel); await page.waitFor(cond, { label }); await sleep(300); };
  163. async function viewport(page, width, height) {
  164. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  165. await sleep(250);
  166. }
  167. async function shot(page, name) {
  168. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  169. writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));
  170. }
  171. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  172. // INSIDE ident's selector (shadow DOM): an expression over its root `r`, and a REAL click
  173. const sh = (expr) => `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;
  174. async function shClick(page, inner, name = null) {
  175. const find = `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; const el = ${name === null ? `r.querySelector(${J(inner)})` : `[...r.querySelectorAll(${J(inner)})].find(b => b.textContent === ${J(name)})`}; if (!el) return null; el.scrollIntoView({ block: 'center' }); const b = el.getBoundingClientRect(); if (!b.width) return null; return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; })()`;
  176. const box = await page.waitFor(find, { label: 'selector ' + inner + ' ' + (name || '') });
  177. const at = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };
  178. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at, buttons: 0 });
  179. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, buttons: 1 });
  180. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, buttons: 0 });
  181. }
  182. const connectedIdent = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'ident page hydrated' });
  183. // sign in to OUR ident on ident's own page (email → code from the sink → skip the names)
  184. async function identSignIn(page, email) {
  185. await page.goto(ident.base + '/');
  186. await page.waitForSelector('#email');
  187. await connectedIdent(page);
  188. await page.type('#email', email);
  189. await page.click('#sendcode');
  190. // ident#20: a sent code NAVIGATES to ident's /code page; type only once that page is hydrated
  191. await page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });
  192. await connectedIdent(page);
  193. await page.type('#code', ident.lastCode(email));
  194. await page.click('#verify');
  195. await page.waitForSelector('#signout', { timeout: 10000 });
  196. }
  197. // TICKET #11 (mission 014): the gate must stay green while other browsers load the machine.
  198. // Every wait of a page (waitFor / waitForSelector / click / goto) gets SLOW× its timeout
  199. // (TICKETS_GATE_SLOW, default 3: 10 s → 30 s) — a wait that succeeds returns at once, so a green
  200. // run costs nothing; only a real failure waits longer before it says so.
  201. const SLOW = Number(process.env.TICKETS_GATE_SLOW || 3);
  202. function patient(page) {
  203. const waitFor = page.waitFor.bind(page);
  204. page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });
  205. const goto = page.goto.bind(page);
  206. page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });
  207. return page;
  208. }
  209. let A, B, C;
  210. const range = (v, dflt) => (v || dflt).split('-').map(Number);
  211. try {
  212. // ---- ticket #7: our own ident, tickets registered in it ----------------------------------
  213. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  214. const alice = await ident.signIn('[email protected]');
  215. const bob = await ident.signIn('[email protected]');
  216. const gateAcct = await ident.signIn('[email protected]');
  217. APPKEY = await ident.registerApp(alice, 'tickets (gate)', [BASE]);
  218. check('ident: our own ident runs (a copy without .env), tickets is registered (key + secret)', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && /^sk_[0-9a-f]{48}$/.test(APPKEY.secret) && !existsSync(join(STORE, 'ident', 'ident-code', '.env')));
  219. // alice's per-app id — what the architect puts into TICKETS_CREATOR_IDENTITY
  220. const ALICE_ID = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));
  221. const GATE_ID = await ident.exchange(APPKEY, await ident.selectorCode(gateAcct, APPKEY, BASE));
  222. check('ident: one identity id per identity (a short id, or the old 32 hex)', /^[0-9a-z]{5,64}$/.test(ALICE_ID) && /^[0-9a-z]{5,64}$/.test(GATE_ID) && ALICE_ID !== GATE_ID, ALICE_ID + ' ' + GATE_ID);
  223. // ---- server #1: NO creator configured ---------------------------------------------------
  224. startTickets({});
  225. await ticketsUp();
  226. // the machine user "gate": the login button's SERVER half (ident → /login/callback?ident_code=)
  227. const first = await fetch(BASE + '/');
  228. const GATE_COOKIE = cookieOf(first);
  229. check('login: a page visit gets the tickets session cookie (hlsid)', /^hlsid=[0-9a-f]+$/.test(GATE_COOKIE), GATE_COOKIE);
  230. const noCode = await fetch(BASE + '/login/callback', { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  231. check('login: /login/callback without a code → 400 page, no redirect', noCode.status === 400 && !noCode.headers.get('location') && /no login code/.test(await noCode.text()));
  232. const badCode = await fetch(BASE + '/login/callback?ident_code=' + 'ab'.repeat(24), { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  233. check('login: an unknown code → 400 "ident refused the login", no redirect', badCode.status === 400 && !badCode.headers.get('location') && /ident refused the login \(400/.test(await badCode.text()));
  234. const gateCode = await ident.selectorCode(gateAcct, APPKEY, BASE);
  235. const cb = await fetch(BASE + '/login/callback?ident_code=' + gateCode, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  236. check('login: /login/callback exchanges the code → 302 to /', cb.status === 302 && cb.headers.get('location') === '/', cb.status + ' ' + cb.headers.get('location'));
  237. const again = await fetch(BASE + '/login/callback?ident_code=' + gateCode, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  238. check('login: the same code again → 400 (single use)', again.status === 400 && /already used/.test(await again.text()));
  239. // ticket #10: the button's return URL carries ?next=<page> (login.js); /login/callback follows
  240. // only a same-origin PATH, anything else → /
  241. for (const [next, want] of [
  242. ['/projects/alpha/2', '/projects/alpha/2'], ['/project/alpha/state/open?x=1&y=2', '/project/alpha/state/open?x=1&y=2'], ['/inbox', '/inbox'],
  243. ['/%2F%2Fevil.example', '/%2F%2Fevil.example'],
  244. ['//evil.example/x', '/'], ['https://evil.example/', '/'], ['http:/evil.example', '/'], ['/\\evil.example', '/'], ['javascript:alert(1)', '/'],
  245. ['evil.example', '/'], ['/a b', '/'], ['/x\r\nSet-Cookie: a=b', '/'], ['/x"><script>', '/'], ['/login/callback', '/'], ['/' + 'a'.repeat(500), '/'], ['', '/'],
  246. ]) {
  247. const c = await ident.selectorCode(gateAcct, APPKEY, BASE);
  248. const r = await fetch(BASE + '/login/callback?next=' + encodeURIComponent(next) + '&ident_code=' + c, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  249. check(`return (#10): next=${J(next.length > 40 ? next.slice(0, 20) + '…(' + next.length + ')' : next)} → 302 ${want}`, r.status === 302 && r.headers.get('location') === want, r.status + ' ' + r.headers.get('location'));
  250. }
  251. let html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();
  252. check('login: first login → the page asks for a display name, no write form yet', html.includes('id="nameform"') && !html.includes('id="newticketform"') && html.includes('class="in"'));
  253. check('login: the page never carries the identity id (only /you does)', !html.includes(GATE_ID));
  254. let f = await temit('commentOn', ['x', 'before the name'], GATE_COOKIE);
  255. check('login: no writing before a display name', f.value && /display name first/.test(f.value.error), f.raw);
  256. f = await temit('saveDisplayName', [' '], GATE_COOKIE);
  257. check('name: an empty display name is refused', f.value && /must not be empty/.test(f.value.error), f.raw);
  258. f = await temit('saveDisplayName', ['x'.repeat(61)], GATE_COOKIE);
  259. check('name: 61 characters are refused', f.value && /too long/.test(f.value.error), f.raw);
  260. f = await temit('saveDisplayName', ['gate'], GATE_COOKIE);
  261. check('name: "gate" saved (answer: name, named — no identity id)', f.value && f.value.name === 'gate' && f.value.named === true && !f.raw.includes(GATE_ID), f.raw);
  262. f = await temit('saveDisplayName', ['gate2'], GATE_COOKIE);
  263. check('name: asked once — a second name is refused', f.value && /already set/.test(f.value.error), f.raw);
  264. html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();
  265. check('name: afterwards no prompt; no role yet → no ticket form, a role hint (ticket #20)', !html.includes('id="nameform"') && !html.includes('id="newticketform"') && html.includes('id="rolehint"'));
  266. f = await temit('tokenCreate', ['gate machine'], GATE_COOKIE);
  267. TOKEN = f.value && f.value.token;
  268. check('token: created over the face — tkt_ + 48 hex, listed with its label', /^tkt_[0-9a-f]{48}$/.test(TOKEN || '') && f.value.tokens.length === 1 && f.value.tokens[0].label === 'gate machine' && !J(f.value.tokens).includes(TOKEN), f.raw);
  269. const you0 = await (await fetch(BASE + '/you', { headers: { cookie: GATE_COOKIE } })).text();
  270. check('/you shows the user its own ident id and "not a member of any project yet"', you0.includes(GATE_ID) && /not a member of any project yet/.test(you0) && !you0.includes(TOKEN));
  271. // ticket #20: the legacy projects have NO admin yet (no creator configured) and the gate user is no member: every write is refused
  272. const nm1 = await api('POST', '/api/tickets/1/comments', { text: 'not a member' });
  273. const nm2 = await api('POST', '/api/projects/alpha/tickets/1/state', { state: 'review' });
  274. const nm3 = await api('POST', '/api/projects/alpha/tickets', { subject: 'not a member' });
  275. check('roles: a non-member cannot comment / change the state / open a ticket → 403, nothing written', nm1.status === 403 && nm2.status === 403 && nm3.status === 403 && (await api('GET', '/api/tickets/1')).json.events.length === 3, J([nm1, nm2, nm3]) + J([(await api('GET', '/api/projects/alpha')).status, (await api('GET', '/api/tickets/1')).status]) + log.slice(-900) + log.slice(-600));
  276. check('migration: at the first start the log says what was migrated', /migrated: 2 project\(s\) turned into records/.test(log) && /migrated: 5 ticket\(s\) linked to their project by id/.test(log), log.slice(0, 400));
  277. await stopTickets();
  278. // ---- server #2: the gate user is the creator → admin of the legacy projects (its session + token survive) --
  279. startTickets({ TICKETS_CREATOR_IDENTITY: GATE_ID });
  280. await ticketsUp();
  281. html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();
  282. check('restart: the gate user is still logged in (session on disk)', /id="whoami"[^>]*>gate</.test(html), html.slice(0, 200));
  283. // ---- the migrated legacy tickets: old numbers still answer (ticket #38) ---------------
  284. const PT = (slug, n) => `/api/projects/${slug}/tickets/${n}`;
  285. const old1 = await api('GET', '/api/tickets/1');
  286. check('legacy: GET /api/tickets/1 (old number) answers alpha #1 with project, number, href, oldNumber',
  287. old1.status === 200 && old1.json.ticket.project === 'alpha' && old1.json.ticket.number === 1 && old1.json.ticket.ref === '#1' && old1.json.ticket.oldNumber === 1
  288. && old1.json.ticket.href === '/alpha/1' && old1.json.ticket.apiHref === PT('alpha', 1) && /^[0-9a-z]{8,}$/.test(old1.json.ticket.id), J(old1.json).slice(0, 500));
  289. check('legacy: history kept in order with authors, texts and times (same ms → old order)',
  290. J(old1.json.events.map(e => [e.seq, e.kind, e.author, e.text, e.to, e.createdMs])) === J([[1, 'created', 'creator', '', '', 1790000000000], [2, 'comment', 'worker', 'legacy comment on one', '', 1790000300000], [3, 'state', 'worker', 'started', 'progress', 1790000300000]]), J(old1.json.events));
  291. check('legacy: state, subject, times and event count kept', old1.json.ticket.state === 'progress' && old1.json.ticket.subject === 'Legacy alpha one' && old1.json.ticket.createdMs === 1790000000000 && old1.json.ticket.updatedMs === 1790000300000 && old1.json.ticket.events === 3, J(old1.json.ticket));
  292. const a2 = await api('GET', PT('alpha', 2));
  293. check('legacy: GET /api/projects/alpha/tickets/2 is old #3 (per-project form)', a2.status === 200 && a2.json.ticket.oldNumber === 3 && a2.json.ticket.subject === 'Question: legacy alpha two' && J(a2.json.events.map(e => e.kind)) === J(['created', 'comment']), J(a2.json).slice(0, 300));
  294. check('legacy: old #4 is alpha #3, old #5 is beta.example.org #2', (await api('GET', '/api/tickets/4')).json.ticket.href === '/alpha/3' && (await api('GET', '/api/tickets/5')).json.ticket.href === '/beta.example.org/2');
  295. const byUuid = await api('GET', '/api/tickets/' + old1.json.ticket.id);
  296. check('legacy: /api/tickets/<uuid> answers the same ticket', byUuid.status === 200 && byUuid.json.ticket.href === '/alpha/1');
  297. check('legacy: unknown old number / number / project → 404', (await api('GET', '/api/tickets/6')).status === 404 && (await api('GET', PT('alpha', 4))).status === 404 && (await api('GET', PT('nope', 1))).status === 404 && (await api('GET', '/api/projects/nope/tickets')).status === 404);
  298. const oldCmt = await api('POST', '/api/tickets/4/comments', { text: 'via the old number' });
  299. check('legacy: POST /api/tickets/4/comments (old number, gate token) → 201, lands on alpha #3, author = the token\'s user', oldCmt.status === 201 && oldCmt.json.event.author === 'gate' && oldCmt.json.ticket.href === '/alpha/3' && oldCmt.json.ticket.oldNumber === 4 && oldCmt.json.event.seq === 2 && oldCmt.json.event.number === 3 && oldCmt.json.event.project === 'alpha', J(oldCmt.json));
  300. const newCmt = await api('POST', PT('alpha', 3) + '/comments', { text: 'via the project number' });
  301. check('legacy: POST /api/projects/alpha/tickets/3/comments → 201 on the same ticket', newCmt.status === 201 && newCmt.json.ticket.id === oldCmt.json.ticket.id && newCmt.json.ticket.events === 3);
  302. const newSt = await api('POST', PT('beta.example.org', 2) + '/state', { state: 'on-hold', text: 'parked' });
  303. check('legacy: POST /api/projects/beta.example.org/tickets/2/state → 201', newSt.status === 201 && newSt.json.ticket.state === 'pending' && (await api('GET', '/api/tickets/5')).json.ticket.state === 'pending', J(newSt.json));
  304. const a4 = await api('POST', '/api/projects/alpha/tickets', { subject: 'New alpha after the migration' });
  305. check('numbers: POST /api/projects/alpha/tickets → alpha #4, no old number', a4.status === 201 && a4.json.ticket.number === 4 && a4.json.ticket.href === '/alpha/4' && !('oldNumber' in a4.json.ticket) && a4.json.ticket.hasOld === false, J(a4.json));
  306. for (const t of ['gamma', 'antcolony', 'tickets.worldapi.org', 'ident.worldapi.org', 'gitoria.worldapi.org']) await api('POST', '/api/projects', { title: t, slug: t });
  307. const g1 = await api('POST', '/api/tickets', { project: 'gamma', subject: 'first of a new project' });
  308. check('numbers: a new project starts at 1 (POST /api/tickets)', g1.status === 201 && g1.json.ticket.project === 'gamma' && g1.json.ticket.number === 1 && g1.json.ticket.href === '/gamma/1', J(g1.json));
  309. const g2 = await api('POST', '/api/tickets', { project: 'gamma', subject: 'second of gamma' });
  310. check('numbers: … and counts on (gamma #2)', g2.json.ticket.number === 2 && (await api('GET', PT('gamma', 2))).json.ticket.subject === 'second of gamma');
  311. check('numbers: the per-project POST refuses a project field (strict)', (await api('POST', '/api/projects/alpha/tickets', { subject: 's', project: 'alpha' })).json.field === 'project');
  312. const redir = await fetch(BASE + '/tickets/3', { redirect: 'manual' });
  313. check('legacy: GET /tickets/3 (old page URL) → 301 Location /alpha/2', redir.status === 301 && redir.headers.get('location') === '/alpha/2', redir.status + ' ' + redir.headers.get('location'));
  314. check('legacy: GET /tickets/99 → 404', (await fetch(BASE + '/tickets/99', { redirect: 'manual' })).status === 404);
  315. const LEGACY = (await api('GET', '/api/tickets')).json.tickets.length; // 5 migrated + alpha #4 + gamma #1, #2
  316. // ---- import: the five AntColony ticket files, twice ----------------------------------
  317. const imp0 = runImport(null);
  318. check('import: without TICKETS_TOKEN every file is refused (401), nothing written', /import: 0 new, 0 already there, 5 failed/.test(imp0) && /FAIL .*: 401/.test(imp0) && (await api('GET', '/api/tickets')).json.tickets.length === LEGACY, imp0);
  319. const imp1 = runImport();
  320. check('import: first run creates all five', /import: 5 new, 0 already there, 0 failed/.test(imp1), imp1);
  321. check('import: numbered per project (antcolony #1, #2)', /NEW 0004-scheduler\.md → antcolony #1/.test(imp1) && /NEW 0005-agent\.md → antcolony #2/.test(imp1) && /NEW 0001-tickets-app\.md → tickets\.worldapi\.org #1/.test(imp1), imp1);
  322. const imp2 = runImport();
  323. check('import: second run is idempotent (0 new, 5 already there)', /import: 0 new, 5 already there, 0 failed/.test(imp2), imp2);
  324. let list = await api('GET', '/api/tickets');
  325. const TOTAL = LEGACY + 5;
  326. check('api: GET /api/tickets lists five more after two imports', list.status === 200 && list.json.tickets.length === TOTAL && list.json.tickets.filter(t => t.source).length === 5, J(list).slice(0, 300));
  327. let one = await api('GET', PT('tickets.worldapi.org', 1));
  328. check('api: tickets.worldapi.org #1 is the first file (project, subject, summary, a created event)',
  329. one.status === 200 && one.json.ticket.project === 'tickets.worldapi.org' && one.json.ticket.subject === 'Build the World Ticket System in Hybriel'
  330. && one.json.ticket.summary.startsWith('Everyone can write everyone a ticket. This is the only state store of AntColony, so it comes first. Minimal scope:')
  331. && one.json.events.length === 1 && one.json.events[0].kind === 'created' && one.json.events[0].text === 'imported from 0001-tickets-app.md' && one.json.events[0].author === 'gate', J(one.json).slice(0, 400));
  332. check('api: antcolony #1 is the scheduler file', (await api('GET', PT('antcolony', 1))).json.ticket.source === '0004-scheduler.md');
  333. check('api: unknown ticket is a 404', (await api('GET', '/api/tickets/99')).status === 404 && (await api('GET', PT('antcolony', 99))).status === 404);
  334. check('api: a ticket without subject is refused (400)', (await api('POST', '/api/tickets', { project: 'x' })).status === 400);
  335. check('api: an unknown state is refused (400)', (await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'nope' })).status === 400);
  336. check('api: an empty comment is refused (400)', (await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: ' ' })).status === 400);
  337. check('api: filter by project', (await api('GET', '/api/tickets?project=antcolony')).json.tickets.length === 2 && (await api('GET', '/api/projects/antcolony/tickets')).json.tickets.length === 2);
  338. check('api: filter by project + state on the per-project list', (await api('GET', '/api/projects/alpha/tickets?state=in-progress')).json.tickets.map(t => t.number).join() === '1');
  339. const projects = await api('GET', '/api/projects');
  340. const imported = (await api('GET', '/api/tickets')).json.tickets.filter(t => t.source);
  341. check('import: hard-wrapped lines are joined (no line break left in the five)', imported.length === 5 && imported.every(t => !t.summary.includes('\n')));
  342. check('api: projects (creation order) and states', J(projects.json.projects) === J(['alpha', 'beta.example.org', 'gamma', 'antcolony', 'tickets.worldapi.org', 'ident.worldapi.org', 'gitoria.worldapi.org']) && projects.json.states.length === 7, J(projects.json));
  343. // ---- the API is strict (ticket #10): every refused body writes NOTHING ----------------
  344. const rawPost = async (path, raw, auth = 'Bearer ' + TOKEN) => {
  345. const r = await fetch(BASE + path, { method: 'POST', headers: { 'content-type': 'application/json', ...(auth ? { authorization: auth } : {}) }, body: raw });
  346. let json = null; try { json = await r.clone().json(); } catch {}
  347. return { status: r.status, json, text: json ? '' : await r.text() };
  348. };
  349. const strictCases = [
  350. // [path, raw body, expected status, expected `field` (null = none), error must include]
  351. ['/api/tickets', J({ project: 'x', subject: 's', bogus: '1' }), 400, 'bogus', "unknown field 'bogus'"],
  352. ['/api/tickets', J({ project: 'x', subject: 's', Subject: 't' }), 400, 'Subject', "unknown field 'Subject'"],
  353. ['/api/tickets', J({ project: 'x' }), 400, 'subject', "field 'subject' is required"],
  354. ['/api/tickets', J({ subject: 's' }), 400, 'project', "field 'project' is required"],
  355. ['/api/tickets', J({ project: 'x', subject: ' ' }), 400, 'subject', "field 'subject' must not be empty"],
  356. ['/api/tickets', J({ project: '', subject: 's' }), 400, 'project', "field 'project' must not be empty"],
  357. ['/api/tickets', J({ project: 'x', subject: 5 }), 400, 'subject', "field 'subject' must be a string"],
  358. ['/api/tickets', J({ project: 'x', subject: 's', summary: null }), 400, 'summary', "field 'summary' must be a string"],
  359. ['/api/tickets', J({ project: 'a b', subject: 's' }), 400, 'project', 'no such project'],
  360. // ticket #7: NO free author any more — the author is the token's user; a body naming one is refused
  361. ['/api/tickets', J({ project: 'x', subject: 's', author: ['a'] }), 400, 'author', "no field 'author' any more"],
  362. ['/api/tickets', J({ project: 'x', subject: 's', author: 'gate' }), 400, 'author', "no field 'author' any more: the author is the user of your API token"],
  363. ['/api/tickets', J({ project: 'x', subject: 's', summary: 'y', author: ' ' }), 400, 'author', "no field 'author' any more"],
  364. ['/api/tickets/2/comments', J({ text: 'x', author: 'creator' }), 400, 'author', "no field 'author' any more"],
  365. ['/api/tickets/2/comments', J({ text: 'x', author: '' }), 400, 'author', "no field 'author' any more"],
  366. ['/api/tickets/2/state', J({ state: 'in progress', author: 'creator' }), 400, 'author', "no field 'author' any more"],
  367. ['/api/tickets/2/state', J({ state: 'confirmed', text: 'n', author: ' ' }), 400, 'author', "no field 'author' any more"],
  368. ['/api/tickets', J([1, 2]), 400, '', 'must be a JSON object'],
  369. ['/api/tickets', J('text'), 400, '', 'must be a JSON object'],
  370. ['/api/tickets', '', 400, '', 'must be a JSON object'],
  371. ['/api/tickets/2/comments', J({ text: 'hi', state: 'open' }), 400, 'state', "unknown field 'state'"],
  372. ['/api/tickets/2/comments', J({}), 400, 'text', "field 'text' is required"],
  373. ['/api/tickets/2/comments', J({ text: ' ' }), 400, 'text', "field 'text' must not be empty"],
  374. ['/api/tickets/2/comments', J({ text: { a: 1 } }), 400, 'text', "field 'text' must be a string"],
  375. ['/api/tickets/2/comments', J({ text: true }), 400, 'text', "field 'text' must be a string"],
  376. ['/api/tickets/2/comments', J({ text: 'x', author: 7 }), 400, 'author', "no field 'author' any more"],
  377. ['/api/tickets/2/state', J({ state: 'open', note: 'x' }), 400, 'note', "unknown field 'note'"],
  378. ['/api/tickets/2/state', J({ text: 'x' }), 400, 'state', "field 'state' is required"],
  379. ['/api/tickets/2/state', J({ state: '' }), 400, 'state', "field 'state' must not be empty"],
  380. ['/api/tickets/2/state', J({ state: 3 }), 400, 'state', "field 'state' must be a string"],
  381. ['/api/tickets/2/state', J({ state: 'nope' }), 400, 'state', 'unknown state'],
  382. ['/api/tickets/2/state', J({ state: 'open' }), 400, 'state', 'already open'],
  383. ['/api/tickets/99/comments', J({ text: 'x' }), 404, null, 'no such ticket'],
  384. // ticket #38: the slug is the project name, so a new name must be URL-safe; the per-project form is as strict
  385. ['/api/tickets', J({ project: 'a#b', subject: 's' }), 400, 'project', 'no such project'],
  386. ['/api/tickets', J({ project: 'ä', subject: 's' }), 400, 'project', 'no such project'],
  387. ['/api/projects/beta.example.org/tickets/1/comments', J({ text: 'x', author: 'a' }), 400, 'author', "no field 'author' any more"],
  388. ['/api/projects/beta.example.org/tickets/1/comments', J({ text: 'x', bogus: '1' }), 400, 'bogus', "unknown field 'bogus'"],
  389. ['/api/projects/beta.example.org/tickets/1/state', J({ state: 'open' }), 400, 'state', 'already open'],
  390. ['/api/projects/beta.example.org/tickets/1/state', J({ state: 5 }), 400, 'state', "field 'state' must be a string"],
  391. ['/api/projects/beta.example.org/tickets', J({ subject: 's', author: 'a' }), 400, 'author', "no field 'author' any more"],
  392. ['/api/projects/beta.example.org/tickets/9/comments', J({ text: 'x' }), 404, null, 'no such ticket'],
  393. ['/api/projects/beta.example.org/tickets/x/comments', J({ text: 'x' }), 404, null, 'no such ticket'],
  394. ];
  395. for (const [path, raw, want, field, says] of strictCases) {
  396. const r = await rawPost(path, raw);
  397. const ok = r.status === want && r.json && typeof r.json.error === 'string' && r.json.error.includes(says) && (field === null || r.json.field === field);
  398. check(`strict: POST ${path} ${raw || '(empty)'} → ${want}${field ? ' naming ' + field : ''}`, ok, r.status + ' ' + J(r.json) + r.text);
  399. }
  400. // invalid JSON (ticket #15): refused by jsoncheck.hl BEFORE JSON.parse → 400 { error },
  401. // no source path (workaround for hybriel #12: an uncaught JSON.parse answered 500 + api.hl:31:9)
  402. const badJsonCases = [
  403. ['/api/tickets', '{"project":"x",', 15], ['/api/tickets', '{bad', 1], ['/api/tickets', "{'project':'x'}", 1],
  404. ['/api/tickets', '{"project":"x","subject":"s","summary":"a",}', 43], ['/api/tickets', '[1 2]', 3],
  405. ['/api/tickets/2/comments', '{"text":"x","n":"a"} trailing', 21], ['/api/tickets/2/comments', '{"text":"\\x","n":"a"}', 10],
  406. ['/api/tickets/2/state', '{"state":01}', 10], ['/api/tickets/2/state', '['.repeat(70), 64], ['/api/tickets/2/state', ' ', 3],
  407. // LONE \u surrogate escapes: hl's JSON.parse still refuses them (would be a 500) → refused by the check
  408. // (a valid pair parses since hybriel#15, mission 036: see the two checks below)
  409. ['/api/tickets/2/comments', '{"text":"\\ud800"}', 10], ['/api/tickets/2/comments', '{"text":"\\udc00"}', 10],
  410. ['/api/tickets/2/comments', '{"text":"\\ud83dx"}', 10], ['/api/tickets/2/comments', '{"text":"\\ud83d\\u0041"}', 10],
  411. ];
  412. for (const [path, raw, at] of badJsonCases) {
  413. const r = await rawPost(path, raw);
  414. const ok = r.status === 400 && r.json && J(Object.keys(r.json)) === J(['error']) && r.json.error === 'invalid JSON at character ' + at && !/\.hl|\//.test(r.json.error);
  415. check(`strict: invalid JSON POST ${path} ${raw.length > 40 ? raw.slice(0, 40) + '…' : raw} → 400 at ${at}, no source path`, ok, r.status + ' ' + J(r.json) + r.text);
  416. }
  417. // valid but unusual JSON is NOT mistaken for invalid (escapes, unicode, numbers, nesting) → normal field errors
  418. const oddValid = await rawPost('/api/tickets/2/comments', '{ "text" : "q\\"b\\\\s\\/\\u00e9\\n", "n": [-1.5e+3, 0, true, null, {"x":[]}] }');
  419. check('strict: valid unusual JSON passes the check (→ unknown field n, not invalid JSON)', oddValid.status === 400 && oddValid.json && oddValid.json.field === 'n', oddValid.status + ' ' + J(oddValid.json) + oddValid.text);
  420. const pairOnly = await rawPost('/api/tickets/2/comments', '{"text":"\\ud83d\\ude00","n":"a"}');
  421. check('strict: a valid \\u surrogate pair passes the check (hybriel#15 → unknown field n, not invalid JSON)', pairOnly.status === 400 && pairOnly.json && pairOnly.json.field === 'n', pairOnly.status + ' ' + J(pairOnly.json) + pairOnly.text);
  422. const escaped = await rawPost('/api/tickets/3/comments', '{"text":"q\\"b\\\\s\\/\\u00e9 \u{1F600} end \\ud83d\\ude00"}');
  423. check('strict: a comment with JSON escapes (\\" \\\\ \\/ \\u00e9 \\ud83d\\ude00) and a raw emoji is stored decoded (201)', escaped.status === 201 && escaped.json.event.text === 'q"b\\s/\u00e9 \u{1F600} end \u{1F600}', escaped.status + ' ' + J(escaped.json) + escaped.text);
  424. const afterStrict = await api('GET', '/api/tickets');
  425. check('strict: the refused requests wrote nothing (same ticket count, old #2 = beta.example.org #1 has one event)', afterStrict.json.tickets.length === TOTAL && (await api('GET', '/api/tickets/2')).json.events.length === 1 && (await api('GET', PT('beta.example.org', 1))).json.events.length === 1, J(afterStrict.json.tickets.map(t => t.ref)));
  426. check('strict: GET /api/inbox refuses POST (405)', (await rawPost('/api/inbox', '{}')).status === 405);
  427. // ---- ticket #7: API writes need a valid token — 401 BEFORE the body is looked at ----------
  428. const before401 = J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events]));
  429. const authCases = [
  430. // [path, raw body, Authorization header (null = none), label]
  431. ['/api/tickets', J({ project: 'x', subject: 's' }), null, 'no token'],
  432. ['/api/projects/alpha/tickets', J({ subject: 's' }), null, 'no token'],
  433. ['/api/tickets/2/comments', J({ text: 'x' }), null, 'no token'],
  434. ['/api/projects/alpha/tickets/1/comments', J({ text: 'x' }), null, 'no token'],
  435. ['/api/tickets/2/state', J({ state: 'in progress' }), null, 'no token'],
  436. ['/api/projects/alpha/tickets/1/state', J({ state: 'on hold' }), null, 'no token'],
  437. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer tkt_' + '0'.repeat(48), 'an unknown token'],
  438. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ' + TOKEN + '0', 'a token with one character more'],
  439. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ' + TOKEN.slice(0, -1), 'a token with one character less'],
  440. ['/api/tickets/2/comments', J({ text: 'x' }), TOKEN, 'the token without "Bearer"'],
  441. ['/api/tickets/2/comments', J({ text: 'x' }), 'Basic ' + TOKEN, 'Basic instead of Bearer'],
  442. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ', 'an empty Bearer'],
  443. ['/api/tickets/2/comments', '{bad', null, 'no token + invalid JSON (auth first)'],
  444. ['/api/tickets/2/comments', J({ text: 'x', author: 'creator' }), null, 'no token + an author field (auth first)'],
  445. ];
  446. for (const [path, raw, auth, label] of authCases) {
  447. const r = await rawPost(path, raw, auth);
  448. check(`auth: POST ${path} with ${label} → 401`, r.status === 401 && r.json && /Authorization: Bearer/.test(r.json.error), r.status + ' ' + J(r.json) + r.text);
  449. }
  450. check('auth: the refused writes wrote nothing', J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events])) === before401);
  451. const anonRead = await fetch(BASE + '/api/tickets/1');
  452. check('auth: reading needs no token (GET without Authorization → 200)', anonRead.status === 200);
  453. // ---- local time (ticket #10): Europe/Vienna, storage stays epoch ms -----------------------
  454. const vienna = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Vienna', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
  455. const lt = spawnSync(BIN, ['tests/localtime.hl'], { cwd: APP, encoding: 'utf8', timeout: 30000 });
  456. const ltLines = (lt.stdout || '').trim().split('\n').filter(Boolean);
  457. const ltBad = ltLines.filter(l => { const [ms, ...rest] = l.split(' '); return vienna.format(new Date(Number(ms))) !== rest.join(' '); });
  458. check('time: localtime.hl matches Intl Europe/Vienna on DST edges 2000–2100', ltLines.length === 11 && ltBad.length === 0, J(ltBad) + (lt.stderr || ''));
  459. const t2 = (await api('GET', PT('ident.worldapi.org', 1))).json;
  460. check('time: API rows render updatedMs / createdMs in Vienna time', t2.ticket.updated === vienna.format(new Date(t2.ticket.updatedMs)) && t2.events[0].when === vienna.format(new Date(t2.events[0].createdMs)), J([t2.ticket.updated, t2.ticket.updatedMs, t2.events[0].when]));
  461. // ---- two viewers ------------------------------------------------------------------
  462. A = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_A, '8620-8629') });
  463. B = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_B, '8630-8639') });
  464. const a = patient(await A.newPage());
  465. const b = patient(await B.newPage());
  466. // SSR of the list
  467. await a.goto(BASE + '/');
  468. await a.waitForSelector('#tickets li');
  469. await connected(a, 'A connected');
  470. check('list: SSR shows every ticket (migrated + new + imported)', (await texts(a, '#tickets li')).length === TOTAL, J(await texts(a, '#tickets li')));
  471. check('list: every row has a state badge, a #ref link and its project', await a.evaluate(`Array.from(document.querySelectorAll('#tickets li')).every(li => li.querySelector('ticket-state') && /^#\\d+/.test(li.querySelector('a.subject').textContent) && li.querySelector('ticket-meta a'))`));
  472. check('list: every ticket link is /<project>/<number> of its row (ticket #25)', await a.evaluate(`Array.from(document.querySelectorAll('#tickets li')).every(li => li.querySelector('a.subject').getAttribute('href') === '/' + li.querySelector('ticket-meta a').textContent + '/' + li.querySelector('ticket-ref').textContent.slice(1))`));
  473. check('list: the project filter offers all seven projects', (await texts(a, '#projectfilter a')).length === 8, J(await texts(a, '#projectfilter a')));
  474. check('list: palette — accent is purple #c586c0, danger #f44747', await a.evaluate(`(() => { const cs = getComputedStyle(document.documentElement); return cs.getPropertyValue('--color-accent').trim() === '#c586c0' && getComputedStyle(document.querySelector('.brand')).color === 'rgb(197, 134, 192)' && getComputedStyle(document.body).backgroundColor === 'rgb(25, 30, 35)'; })()`), await a.evaluate(`getComputedStyle(document.querySelector('.brand')).color + ' ' + getComputedStyle(document.body).backgroundColor`));
  475. check('list: danger token resolves to #f44747', await a.evaluate(`(() => { const s = document.createElement('span'); s.style.color = 'var(--color-danger)'; document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; })()`) === 'rgb(244, 71, 71)');
  476. // filters by real clicks (client-side navigation)
  477. await clickNav(a, '#projectfilter a[href="/project/antcolony"]', 'location.pathname === "/project/antcolony" && document.querySelectorAll("#tickets li").length === 2', 'project filter');
  478. check('filter: project antcolony shows its two tickets', J(await texts(a, '#tickets ticket-meta a')) === J(['antcolony', 'antcolony']), J(await texts(a, '#tickets ticket-meta a')));
  479. check('filter: the chosen project is marked selected', (await a.text('#projectfilter a.selected')) === 'antcolony');
  480. await clickNav(a, '#statefilter a[href="/project/antcolony/state/progress"]', 'location.pathname === "/project/antcolony/state/progress" && !!document.querySelector("#empty")', 'state filter');
  481. check('filter: project + state "progress" is empty (nothing in progress yet)', (await texts(a, '#tickets li')).length === 0);
  482. await clickNav(a, '#statefilter a[href="/project/antcolony"]', 'document.querySelectorAll("#tickets li").length === 2', 'state filter cleared');
  483. check('filter: clearing the state keeps the project', (await a.evaluate('location.pathname')) === '/project/antcolony');
  484. await a.goto(BASE + '/state/open');
  485. await a.waitForSelector('#tickets li');
  486. const OPEN = (await api('GET', '/api/tickets?state=open')).json.tickets.length;
  487. check('filter: /state/open by URL (SSR) shows every open ticket', (await texts(a, '#tickets li')).length === OPEN, OPEN);
  488. // ---- a migrated ticket in the browser: the old URL redirects, a real click opens the new one
  489. await a.goto(BASE + '/tickets/4');
  490. await a.waitForSelector('#subject');
  491. check('legacy: the old URL /tickets/4 lands on /alpha/3 (redirect in the browser)', (await a.evaluate('location.pathname')) === '/alpha/3' && (await a.text('#subject')) === 'Legacy alpha three' && (await a.text('#ref')) === '#3', await a.evaluate('location.href'));
  492. check('legacy: the page says "formerly #4"', (await a.text('#oldref')) === 'formerly #4', await a.text('#oldref'));
  493. check('legacy: its history holds the created event and both API comments, in order', J(await texts(a, '#events li event-text')) === J(['via the old number', 'via the project number']) && (await texts(a, '#events li')).length === 3, J(await texts(a, '#events li')));
  494. await a.goto(BASE + '/project/alpha');
  495. await a.waitForSelector('#tickets li');
  496. await connected(a, 'A on alpha');
  497. await clickNav(a, '#tickets a.subject[href="/alpha/1"]', 'location.pathname === "/alpha/1" && !!document.querySelector("#events li")', 'click alpha #1');
  498. check('legacy: a real click on a list row opens /alpha/1 (old #1, history in order)', (await a.text('#subject')) === 'Legacy alpha one' && (await a.text('#oldref')) === 'formerly #1' && J(await texts(a, '#events li event-text')) === J(['legacy comment on one', 'started']), J(await texts(a, '#events li')));
  499. await a.goto(BASE + '/gamma/1');
  500. await a.waitForSelector('#subject');
  501. check('numbers: a new ticket has no "formerly"', (await a.text('#ref')) === '#1' && !(await a.evaluate('!!document.querySelector("#oldref")')));
  502. await a.goto(BASE + '/alpha/99');
  503. await a.waitForSelector('#gone');
  504. check('numbers: an unknown number shows "no such ticket"', (await a.text('#gone')).includes('no such ticket'));
  505. await a.goto(BASE + '/gamma');
  506. await a.waitForSelector('#tickets li');
  507. check('numbers: /<slug> lists that project', (await texts(a, '#tickets li')).length === 2 && (await a.text('#heading')) === 'gamma');
  508. // ---- ticket #25: short URLs /<slug>, /<slug>/<number>; every old page URL answers 301; hl:web's own urls stay its own
  509. const SHORT_SSR = [['/gamma', 'id="heading">gamma<'], ['/gamma/1', 'first of a new project'], ['/beta.example.org/2', 'Legacy beta two'], ['/alpha/3', 'Legacy alpha three']];
  510. for (const [path, want] of SHORT_SSR) {
  511. const r = await fetch(BASE + path, { redirect: 'manual' });
  512. const body = await r.text();
  513. check(`short (#25): GET ${path} → 200, the page itself (SSR)`, r.status === 200 && body.includes(want), r.status + ' ' + body.slice(0, 200));
  514. }
  515. for (const [from, to] of [['/projects/gamma', '/gamma'], ['/projects/gamma/1', '/gamma/1'], ['/projects/gamma/tickets/2', '/gamma/2'], ['/projects/beta.example.org/tickets/2', '/beta.example.org/2'], ['/projects/alpha/3', '/alpha/3'], ['/tickets/5', '/beta.example.org/2']]) {
  516. const r = await fetch(BASE + from, { redirect: 'manual' });
  517. check(`short (#25): old ${from} → 301 Location ${to}`, r.status === 301 && r.headers.get('location') === to, r.status + ' ' + r.headers.get('location'));
  518. }
  519. for (const from of ['/projects/nope', '/projects/nope/1', '/projects/gamma/99', '/projects/gamma/tickets/99', '/projects/gamma/tickets/x', '/projects/gamma/%0d%0aSet-Cookie:x']) {
  520. const r = await fetch(BASE + from, { redirect: 'manual' });
  521. check(`short (#25): old ${from} names nothing → 404, no Location`, r.status === 404 && !r.headers.get('location'), r.status + ' ' + r.headers.get('location'));
  522. }
  523. const ssrNope = await (await fetch(BASE + '/nope')).text();
  524. check('short (#25): /nope (no such project) → the page says so', /no such project|nope/i.test(ssrNope) && ssrNope.includes('id="heading">nope<'), ssrNope.slice(0, 200));
  525. // hl:web appends its own urls AFTER the app's routes; /:projectSlug/:ticketNumber must not answer them (project.hl moves it last)
  526. for (const [path, type] of [['/__hl/app.css', 'text/css'], ['/__hl/hl-runtime.js', 'text/javascript'], ['/__hl/sw.js', 'text/javascript'], ['/__hl/manifest.webmanifest', 'application/manifest+json'], ['/__hl/web/client.js', 'text/javascript'], ['/components/ticket.hl', 'text/javascript'], ['/components/ticket_list.hl', 'text/javascript'], ['/components/main.hl', 'text/javascript']]) {
  527. const r = await fetch(BASE + path);
  528. const body = await r.text();
  529. check(`short (#25): hl:web's ${path} is still its own (${type}, no page)`, r.status === 200 && (r.headers.get('content-type') || '').startsWith(type) && !body.startsWith('<!doctype'), r.status + ' ' + r.headers.get('content-type'));
  530. }
  531. const em = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 1, event: 'nosuchface', payload: [] }) });
  532. check('short (#25): POST /__hl/emit is still hl:web\'s carrier (JSON ack)', em.status === 200 && /"ok":false/.test(await em.text()));
  533. // in the browser: an old link lands on the short URL; the project link on the ticket page is a CLIENT navigation to /<slug>
  534. await a.goto(BASE + '/projects/gamma/tickets/1');
  535. await a.waitForSelector('#subject');
  536. check('short (#25): Chrome on /projects/gamma/tickets/1 lands on /gamma/1', (await a.evaluate('location.pathname')) === '/gamma/1' && (await a.text('#subject')) === 'first of a new project', await a.evaluate('location.href'));
  537. await connected(a, 'A on /gamma/1');
  538. await a.evaluate('window.__w083 = 1');
  539. check('short (#25): the ticket page links its project as /gamma', (await a.evaluate('document.querySelector("#project").getAttribute("href")')) === '/gamma');
  540. await clickNav(a, '#project', 'location.pathname === "/gamma" && document.querySelectorAll("#tickets li").length === 2', 'click the project');
  541. check('short (#25): a click on the project → /gamma, its list, without a reload', (await a.evaluate('window.__w083')) === 1 && (await a.text('#heading')) === 'gamma');
  542. await clickNav(a, '#tickets a.subject[href="/gamma/2"]', 'location.pathname === "/gamma/2" && !!document.querySelector("#subject")', 'click gamma #2');
  543. check('short (#25): … and a click on a row → /gamma/2, without a reload', (await a.evaluate('window.__w083')) === 1 && (await a.text('#subject')) === 'second of gamma');
  544. // reserved slugs: no project may be named like a path the app answers itself
  545. for (const slug of ['api', 'Inbox', '__hl', 'components', 'projects', 'tickets', 'login.js', 'my', 'assets', 'sign-in']) {
  546. const r = await api('POST', '/api/projects', { title: 'Reserved ' + slug, slug });
  547. check(`short (#25): a new project with the slug ${slug} → 400 reserved`, r.status === 400 && r.json.field === 'slug' && /reserved/.test(r.json.error), J(r));
  548. }
  549. const toRes = await api('POST', '/api/projects/gamma', { slug: 'inbox' });
  550. check('short (#25): changing a slug to a reserved one → 400, gamma keeps its slug', toRes.status === 400 && /reserved/.test(toRes.json.error) && (await api('GET', '/api/projects/gamma')).json.project.slug === 'gamma', J(toRes));
  551. check('short (#25): no project was made by the refusals', !(await api('GET', '/api/projects')).json.projects.some(p => /reserved|^api$|^inbox$/i.test(p)));
  552. // every first segment a route of project.hl answers is a reserved slug (a new route must add its name)
  553. const routeHeads = [...readFileSync(join(APP, 'project.hl'), 'utf8').matchAll(/pattern = "\/([^/":]+)/g)].map(m => m[1]);
  554. const reservedSrc = readFileSync(join(APP, 'lib/projects.hl'), 'utf8').match(/static reservedSlugs = \[([^\]]*)\]/);
  555. const reserved = reservedSrc ? [...reservedSrc[1].matchAll(/'([^']*)'/g)].map(m => m[1]) : [];
  556. const missingHeads = [...new Set([...routeHeads, '__hl', 'components'])].filter(h => !reserved.includes(h));
  557. check(`short (#25): every first path segment of project.hl's routes (${new Set(routeHeads).size}) + __hl + components is reserved`, routeHeads.length > 10 && missingHeads.length === 0, J(missingHeads));
  558. // ---- old events keep their authors (ticket #7) --------------------------------------------
  559. await a.goto(BASE + '/projects/alpha/1');
  560. await a.waitForSelector('#events li');
  561. check('legacy: old events show their stored authors (creator, worker, worker)', J(await texts(a, '#events event-head strong')) === J(['creator', 'worker', 'worker']), J(await texts(a, '#events event-head strong')));
  562. // ---- signed out: reading only (ticket #7) --------------------------------------------------
  563. const IDENT1 = '/projects/ident.worldapi.org/1';
  564. await a.goto(BASE + IDENT1);
  565. await a.waitForSelector('#events li');
  566. await connected(a, 'A on ident #1 (signed out)');
  567. check('signed out: the ticket page shows the history, a login hint and NO write forms', (await texts(a, '#events li')).length === 1 && !!(await a.evaluate('!!document.querySelector("#loginhint")')) && !(await a.evaluate('!!document.querySelector("#commentform") || !!document.querySelector("#stateform")')));
  568. check('signed out: top right the selector ("choose ident") and "Log in with ident"', await a.evaluate(`(() => { const s = document.querySelector('#selector'); const r = s && s.shadowRoot; const btn = document.querySelector('#loginbutton'); const hb = document.querySelector('application-header').getBoundingClientRect(); const sb = s.getBoundingClientRect(); return !!r && /choose/.test(r.querySelector('#choose').textContent) && !s.hasAttribute('logged-in') && !!btn && sb.right > hb.right - 400; })()`));
  569. check('signed out: the login button goes to ident /login with the app key and the callback', (await a.evaluate('document.querySelector("#loginbutton").getAttribute("href")')) === `${ident.base}/login?key=${APPKEY.key}&return=${encodeURIComponent(BASE + '/login/callback')}`, await a.evaluate('document.querySelector("#loginbutton").getAttribute("href")'));
  570. check('signed out: the selector is restyled to tickets\' accent (purple)', await a.evaluate(sh('getComputedStyle(r.querySelector("#choose")).backgroundColor')) === 'rgb(197, 134, 192)');
  571. let anonCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  572. const anonTry = await temit('commentOn', [(await api('GET', PT('ident.worldapi.org', 1))).json.ticket.id, 'anonymous'], anonCookie);
  573. check('signed out: a web write (the face, with the browser\'s own cookie) is refused', anonTry.value && /log in with ident/.test(anonTry.value.error), anonTry.raw);
  574. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  575. await viewport(a, w, h);
  576. check(`layout ${name} ${w}px: signed-out header (selector + login button) has no horizontal overflow`, await noOverflow(a));
  577. await shot(a, `${name}-signedout`);
  578. }
  579. await viewport(a, 1280, 900);
  580. // ---- A = alice (the creator): sign in to ident, then the SELECTOR on tickets ---------------
  581. await identSignIn(a, '[email protected]');
  582. await a.goto(BASE + IDENT1);
  583. await a.waitForSelector('#events li');
  584. await connected(a, 'A on ident #1');
  585. await a.evaluate('window.__loginMarker = 1');
  586. await shClick(a, '#choose');
  587. await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list' });
  588. check('selector: lists alice\'s identity', J(await a.evaluate(sh(`[...r.querySelectorAll('[part~="identity"]')].map(b => b.textContent)`))) === J(['Default']));
  589. await shClick(a, '[part~="identity"]', 'Default');
  590. await a.waitForSelector('#nameform', { timeout: 10000 });
  591. check('selector login: no reload, the host set logged-in, the name prompt appears', (await a.evaluate('window.__loginMarker')) === 1 && await a.evaluate('document.querySelector("#selector").hasAttribute("logged-in") && document.querySelector("#selector").loggedIn === true') && /logged in with/.test(await a.evaluate(sh('r.querySelector("#status").textContent'))));
  592. check('selector login: still no write forms before the name', !(await a.evaluate('!!document.querySelector("#commentform")')));
  593. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  594. await viewport(a, w, h);
  595. check(`layout ${name} ${w}px: name prompt has no horizontal overflow`, await noOverflow(a));
  596. await shot(a, `${name}-name`);
  597. }
  598. await viewport(a, 1280, 900);
  599. await a.type('#displayname', 'alice');
  600. await a.click('#namesave');
  601. await a.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#memberhint")', { label: 'A named, no role yet: a member hint, no forms' });
  602. check('name: saved; no role yet → a role hint, no comment form; top right shows "alice"', (await a.evaluate('window.__loginMarker')) === 1 && (await a.text('#whoami')) === 'alice' && !(await a.evaluate('!!document.querySelector("#commentform")')));
  603. // ticket #20: the admin (the gate user, the creator) gives alice a role in every project; the page follows live
  604. const grant = async (name, role) => { for (const slug of (await api('GET', '/api/projects')).json.projects) { const g = await api('POST', `/api/projects/${slug}/members`, { user: name, role }); if (g.status !== 200 && g.status !== 201) throw new Error('grant failed ' + slug + ' ' + J(g)); } };
  605. await grant('alice', 'admin');
  606. await a.goto(BASE + IDENT1);
  607. await a.waitFor('!!document.querySelector("#commentform")', { label: 'A: the forms appear after alice got a role' });
  608. await connected(a, 'A on ident #1 with a role');
  609. await a.evaluate('window.__loginMarker = 1');
  610. check('roles: alice made admin → the comment form appears (page reloaded), no member hint', !(await a.evaluate('!!document.querySelector("#memberhint")')));
  611. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  612. await viewport(a, w, h);
  613. check(`layout ${name} ${w}px: logged-in header has no horizontal overflow`, await noOverflow(a));
  614. await shot(a, `${name}-loggedin`);
  615. }
  616. await viewport(a, 1280, 900);
  617. const aliceCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  618. const aliceHtml = await (await fetch(BASE + '/', { headers: { cookie: aliceCookie } })).text();
  619. const ALICE_UID = (/\\?"user\\?":\{\\?"id\\?":\\?"([0-9a-z]{12})/.exec(aliceHtml) || [])[1];
  620. check('session: the page knows alice\'s tickets user id, NEVER her identity id', /^[0-9a-z]{12}$/.test(ALICE_UID || '') && !aliceHtml.includes(ALICE_ID), ALICE_UID);
  621. // ---- B = bob: sign in to ident, then the LOGIN BUTTON ---------------------------------------
  622. await identSignIn(b, '[email protected]');
  623. await b.goto(BASE + '/');
  624. await b.waitForSelector('#loginbutton');
  625. await b.click('#loginbutton');
  626. await b.waitForSelector('#chooselist', { timeout: 10000 });
  627. check('button: tickets → ident /signin/<rid> (choose an identity)', (await b.evaluate('location.href')).startsWith(ident.base + '/signin/'));
  628. await connectedIdent(b);
  629. await b.click('#chooselist li:nth-child(1) .choose');
  630. await b.waitFor(`location.href === ${J(BASE + '/')} && !!document.querySelector('#nameform')`, { timeout: 10000, label: 'B back with the name prompt' });
  631. check('button: back on tickets (/login/callback → /), logged in, asked for a name', await b.evaluate('document.querySelector("#selector").classList.contains("in")') && !(await b.evaluate('!!document.querySelector("#newticket")')));
  632. await connected(b, 'B on / after the button login');
  633. await b.type('#displayname', 'bob');
  634. await b.click('#namesave');
  635. await b.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#rolehint")', { label: 'B named' });
  636. check('button: bob named (no role yet: a role hint, no new-ticket form)', (await b.text('#whoami')) === 'bob' && !(await b.evaluate('!!document.querySelector("#newticket")')));
  637. await grant('bob', 'edit');
  638. await b.goto(BASE + '/');
  639. await b.waitFor('!!document.querySelector("#newticket")', { label: 'B: the new-ticket form appears with the role edit' });
  640. await connected(b, 'B on / with a role');
  641. check('roles: bob made edit → the new-ticket form appears', (await b.text('#whoami')) === 'bob');
  642. const bobCookie = (await b.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  643. // ---- the ticket page, two viewers ---------------------------------------------------
  644. await b.goto(BASE + IDENT1);
  645. await b.waitForSelector('#events li');
  646. await connected(b, 'B on ident #1');
  647. await sleep(300);
  648. check('ticket: subject, ref, project, state', (await a.text('#subject')) === 'Rebuild ident in Hybriel: email OTP login' && (await a.text('#ref')) === '#1' && (await a.text('#project')) === 'ident.worldapi.org' && (await a.text('#state')) === 'open', [await a.text('#subject'), await a.text('#ref'), await a.text('#project'), await a.text('#state')].join(' | '));
  649. check('ticket: the imported summary is one paragraph (hard wraps joined)', await a.evaluate(`!document.querySelector('#summary').textContent.includes('\\n') && document.querySelector('#summary').textContent.startsWith('Login for all worldapi apps (tickets, gitoria, …). Only email one-time codes, no passwords. Built on hl:webex;')`), await a.text('#summary'));
  650. check('time: the ticket page shows Vienna time (opened / updated / history)', await a.evaluate(`[document.querySelector('ticket-view header time').textContent, document.querySelector('#updated').textContent, document.querySelector('#events li time').textContent]`).then(v => J(v) === J([t2.ticket.created, t2.ticket.updated, t2.events[0].when]) && t2.ticket.created === vienna.format(new Date(t2.events[0].createdMs))), J(t2.ticket));
  651. check('ticket: history starts with the import event (by the token\'s user "gate")', (await texts(a, '#events li')).length === 1 && (await a.text('#events li')).includes('gate opened the ticket'), await a.text('#events li'));
  652. // A comments (real typing into the form, real click) → B sees it without reload
  653. check('ticket: no author field (ticket #7: the author is the logged-in user)', await a.evaluate('!document.querySelector("#author") && !document.querySelector("input[name=author]")'));
  654. await type(a, '#commenttext', 'first comment from A\nsecond line');
  655. await a.click('#commentsend');
  656. await a.waitFor('document.querySelectorAll("#events li").length === 2', { label: 'A sees its comment' });
  657. check('comment: appended in the writing browser, author = the login (alice)', (await a.text('#events li:last-child')).includes('alice commented') && (await a.text('#events li:last-child event-text')) === 'first comment from A\nsecond line', await a.text('#events li:last-child'));
  658. check('comment: the textarea was cleared', await a.evaluate('document.querySelector("#commenttext").value === ""'));
  659. await b.waitFor('document.querySelectorAll("#events li").length === 2', { label: 'B got the comment live' });
  660. check('live: B sees A\'s comment without a reload', (await b.text('#events li:last-child event-text')) === 'first comment from A\nsecond line');
  661. // hybriel 64527baa+: a face taking `session` answers with a sync of the session-derived members — a list must
  662. // not get the new row twice (gitoria's keys/tokens did). Settle, then count.
  663. await sleep(600);
  664. for (const [p, who] of [[a, 'A'], [b, 'B']]) check(`once: ${who} shows A's comment exactly once (2 history rows)`, (await texts(p, '#events li')).length === 2 && (await texts(p, '#events li event-text')).filter(t => t === 'first comment from A\nsecond line').length === 1, J(await texts(p, '#events li')));
  665. check('live: B did not reload (no navigation entry)', await b.evaluate('performance.getEntriesByType("navigation").length === 1 && performance.getEntriesByType("navigation")[0].type === "navigate"'));
  666. await b.evaluate('window.__gateMarker = 42'); // survives only if B never reloads
  667. // ---- ticket #20: the new states, the roles, projects with members, the inbox (browser + API) ------------------
  668. // A (alice, admin) moves ident #1 to "review": B follows live; the ticket is assigned to the project's first admin (the gate user)
  669. check('shell: no inbox count yet (nothing assigned to bob)', !(await b.evaluate('!!document.querySelector("#inboxcount")')));
  670. check('state: the choices are the new state names', J((await texts(a, '#newstate option')).sort()) === J(['canceled', 'done', 'pending', 'progress', 'reopened', 'review']), J(await texts(a, '#newstate option')));
  671. await choose(a, '#newstate', 'review');
  672. await type(a, '#statenote', 'please test');
  673. await a.click('#statesend');
  674. await a.waitFor('document.querySelector("#state").textContent === "review"', { label: 'A state changed' });
  675. check('state: A shows the new state and a state event with the note', (await a.text('#events li:nth-last-child(2)')).includes('alice changed the state') && (await a.text('#events li:nth-last-child(2) ticket-state')) === 'review' && (await a.text('#events li:nth-last-child(2) event-text')) === 'please test' && (await a.text('#events li:last-child')).includes('assigned the ticket to gate'), await a.text('#events li:last-child'));
  676. await sleep(600);
  677. check('once: A shows the state change exactly once', (await texts(a, '#events li')).filter(t => t.includes('alice changed the state')).length === 1, J(await texts(a, '#events li')));
  678. await b.waitFor('document.querySelector("#state").textContent === "review"', { label: 'B state live' });
  679. check('live: B\'s state badge followed, no reload', (await b.evaluate('document.querySelector("#state").className')) === 'review' && (await b.evaluate('window.__gateMarker')) === 42);
  680. const inboxOf = async (tok) => { const r = await fetch(BASE + '/api/inbox', { headers: { authorization: 'Bearer ' + tok } }); let json = null; try { json = await r.json(); } catch {} return { status: r.status, json }; };
  681. const inbox0 = await inboxOf(TOKEN);
  682. check('inbox: the gate user (first admin) has ident #1 in review, only tickets assigned to them', inbox0.status === 200 && inbox0.json.review.some(t => t.subject === 'Rebuild ident in Hybriel: email OTP login') && [...inbox0.json.review, ...inbox0.json.pending].every(t => t.assignee === 'gate'), J(inbox0.json).slice(0, 400));
  683. const oldNames = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'awaiting creator' });
  684. check('states: the old name "awaiting creator" is an alias of review (already there → 400 "already")', oldNames.status === 400 && /already review/.test(oldNames.json.error), J(oldNames));
  685. const aliasBack = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'in progress' });
  686. check('states: "in progress" is an alias of progress (201)', aliasBack.status === 201 && aliasBack.json.ticket.state === 'progress', J(aliasBack).slice(0, 300));
  687. await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'open' });
  688. // roles: bob = use → comment + open ⇄ review, nothing else (web and API)
  689. let aliceTok0 = null;
  690. const bobTok = (await temit('tokenCreate', ['bob gate'], bobCookie)).value.token;
  691. await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'use' });
  692. await b.goto(BASE + IDENT1);
  693. await b.waitForSelector('#stateform');
  694. await connected(b, 'B on ident #1 as use');
  695. check('roles: bob (use) sees only review in the state choices (ticket is open)', J(await texts(b, '#newstate option')) === J(['review']), J(await texts(b, '#newstate option')));
  696. const useDone = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'done' }, bobTok);
  697. const useReview = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'review' }, bobTok);
  698. const useNew = await api('POST', '/api/projects/ident.worldapi.org/tickets', { subject: 'use may not' }, bobTok);
  699. const useSettings = await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'admin' }, bobTok);
  700. const useComment = await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: 'a use member comments' }, bobTok);
  701. check('roles: use → done 403, review 201, new ticket 403, members 403, comment 201', [useDone.status, useReview.status, useNew.status, useSettings.status, useComment.status].join() === '403,201,403,403,201', J([useDone, useNew, useSettings]).slice(0, 500));
  702. await b.goto(BASE + '/projects/ident.worldapi.org/settings');
  703. await b.waitForSelector('#notadmin');
  704. check('roles: the settings page of a non-admin says so and has no forms', !(await b.evaluate('!!document.querySelector("#detailsform")')));
  705. await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'edit' });
  706. const editAssign = await api('POST', PT('ident.worldapi.org', 1) + '/assign', { assignee: 'bob' }, bobTok);
  707. check('roles: edit may assign (201) and move to any state', editAssign.status === 201 || editAssign.status === 200, J(editAssign).slice(0, 300));
  708. const editPending = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'pending' }, bobTok);
  709. check('roles: edit → pending 201; "on hold" is an alias of pending', editPending.status === 201 && editPending.json.ticket.state === 'pending', J(editPending).slice(0, 300));
  710. // the inbox page of bob: pending and review, only what is assigned to him
  711. await b.goto(BASE + '/inbox');
  712. await b.waitForSelector('#pendingbox');
  713. await connected(b, 'B inbox');
  714. check('inbox: bob has ident #1 under pending, review is empty', (await texts(b, '#pending li a.subject')).length === 1 && (await b.text('#pending li a.subject')).includes('Rebuild ident') && !!(await b.evaluate('!!document.querySelector("#reviewempty")')), J(await texts(b, '#pendingbox')));
  715. await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'review' }, bobTok);
  716. await b.waitFor('document.querySelectorAll("#review li").length === 1 && document.querySelectorAll("#pending li").length === 0', { label: 'inbox follows the state change live' });
  717. check('inbox: the ticket moved from pending to review live, header count 1', (await b.text('#inboxcount')) === '1');
  718. aliceTok0 = (await temit('tokenCreate', ['alice #20'], aliceCookie)).value.token;
  719. check('inbox: alice (admin, nothing assigned) has an empty inbox', (await inboxOf(aliceTok0)).json.review.length === 0);
  720. // a new project by hand: title → slug proposed, admin can change it; old slugs keep working
  721. await a.goto(BASE + '/new-project');
  722. await a.waitForSelector('#projectform');
  723. await connected(a, 'A on /new-project');
  724. await type(a, '#projecttitle', 'My Project');
  725. await a.waitFor('document.querySelector("#projectslug").value === "my-project"', { label: 'slug proposed' });
  726. check('project: the slug my-project is proposed from the title', (await a.evaluate('document.querySelector("#projectslug").value')) === 'my-project');
  727. await a.click('#projectcreate');
  728. await a.waitForSelector('#created');
  729. const mp = await api('GET', '/api/projects/my-project');
  730. check('project: created; alice is its first admin; it has no tickets', mp.status === 200 && mp.json.project.title === 'My Project' && J(mp.json.members.map(m => [m.name, m.role])) === J([['alice', 'admin']]), J(mp.json).slice(0, 400));
  731. await a.goto(BASE + '/projects/my-project/settings');
  732. await a.waitForSelector('#detailsform');
  733. await connected(a, 'A on settings');
  734. await type(a, '#setslug', 'my-proj');
  735. await type(a, '#setdescription', 'The **first** project.');
  736. await a.click('#detailssave');
  737. await a.waitFor('!!document.querySelector("#savemessage") && document.querySelector("#savemessage").textContent.length > 0', { label: 'saved' });
  738. const mp2 = await api('GET', '/api/projects/my-proj');
  739. const mpOld = await api('GET', '/api/projects/my-project');
  740. check('project: the slug changed, the description is kept, the old slug still resolves', mp2.status === 200 && mp2.json.project.description === 'The **first** project.' && mpOld.status === 200 && mpOld.json.project.slug === 'my-proj', J([mp2.json.project, mpOld.status]).slice(0, 400));
  741. const oldSlugMoved = await fetch(BASE + '/projects/my-project', { redirect: 'manual' });
  742. check('short (#25): /projects/<old slug> → 301 to /<current slug>', oldSlugMoved.status === 301 && oldSlugMoved.headers.get('location') === '/my-proj', oldSlugMoved.status + ' ' + oldSlugMoved.headers.get('location'));
  743. await a.goto(BASE + '/my-project');
  744. await a.waitForSelector('#heading');
  745. check('project: the old address still opens the project page', (await a.text('#heading')) === 'My Project' || (await a.text('#heading')).includes('My Project'), await a.text('#heading'));
  746. // members in the settings page: add bob as use; remove; the last admin cannot go
  747. await a.goto(BASE + '/projects/my-proj/settings');
  748. await a.waitForSelector('#addform');
  749. await connected(a, 'A on settings again');
  750. await type(a, '#addref', 'bob');
  751. await choose(a, '#addrole', 'use');
  752. await a.click('#addsave');
  753. await a.waitFor('document.querySelectorAll("#members li").length === 2', { label: 'bob added' });
  754. await sleep(600);
  755. check('once: the settings page lists bob exactly once (2 members)', (await texts(a, '#members li')).length === 2 && (await texts(a, '#members li .membername')).filter(t => t === 'bob').length === 1, J(await texts(a, '#members li')));
  756. check('members: bob added as use in the settings page', J((await api('GET', '/api/projects/my-proj')).json.members.map(m => [m.name, m.role])) === J([['alice', 'admin'], ['bob', 'use']]));
  757. const lastAdmin = await api('POST', '/api/projects/my-proj/members', { user: 'alice', role: 'use' }, aliceTok0);
  758. check('members: the last admin cannot be demoted (400)', lastAdmin.status === 400 && /at least one admin/.test(lastAdmin.json.error), J(lastAdmin));
  759. const badRole = await api('POST', '/api/projects/my-proj/members', { user: 'bob', role: 'boss' }, aliceTok0);
  760. check('members: an unknown role is refused (400)', badRole.status === 400 && badRole.json.field === 'role', J(badRole));
  761. const rm = await api('POST', '/api/projects/my-proj/members/remove', { user: 'bob' }, aliceTok0);
  762. check('members: an admin removes a member (200)', rm.status === 200 && rm.json.members.length === 1, J(rm).slice(0, 300));
  763. // an invite link through ident
  764. const inv = await api('POST', '/api/projects/my-proj/invites', { role: 'use' }, aliceTok0);
  765. check('invite: an admin gets an ident invite link for the role use', inv.status === 201 && /^https?:\/\//.test(inv.json.url || ''), J(inv).slice(0, 400));
  766. // carol opens the link, chooses her identity at ident → ident sends her back to tickets with the invite id → she is a member
  767. const carol = await ident.signIn('[email protected]');
  768. const invPage = await fetch(inv.json.url, { redirect: 'manual' });
  769. const rid = (invPage.headers.get('location') || '').split('/').pop();
  770. const chosen = await ident.emit('chooseIdentity', [rid, carol.identities[0].id], carol.cookie);
  771. const back = chosen.value && chosen.value.url ? await fetch(chosen.value.url.replace(/^https?:\/\/[^/]+/, BASE), { redirect: 'manual' }) : { status: 0, headers: new Headers() };
  772. const carolCookie = (back.headers.get('set-cookie') || '').split(';')[0];
  773. const carolFace = await temit('saveDisplayName', ['carol'], carolCookie);
  774. const mp3 = (await api('GET', '/api/projects/my-proj')).json;
  775. check('invite: carol follows the link, picks an identity at ident, is sent back → member with the role use', invPage.status === 302 && back.status === 302 && J(mp3.members.map(m => [m.name, m.role]).filter(x => x[0] === 'carol' || x[1] === 'use')) === J([['carol', 'use']]), J([invPage.status, chosen.raw.slice(0, 200), back.status, mp3.members]).slice(0, 600));
  776. const invBob = await api('POST', '/api/projects/my-proj/invites', { role: 'use' }, bobTok);
  777. check('invite: a non-admin gets 403', invBob.status === 403, J(invBob).slice(0, 200));
  778. // the new pages fit the screen (phone and desktop), with the project's data on them
  779. for (const [path, sel, name] of [['/projects/my-proj/settings', '#addform', 'settings'], ['/new-project', '#projectform', 'new-project'], ['/my-proj', '#heading', 'project']]) {
  780. await a.goto(BASE + path);
  781. await a.waitForSelector(sel);
  782. await connected(a, 'A on ' + path);
  783. for (const [w, h, dev] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  784. await viewport(a, w, h);
  785. check(`layout ${dev} ${w}px: ${name} page has no horizontal overflow`, await noOverflow(a));
  786. await shot(a, `${dev}-${name}`);
  787. }
  788. }
  789. await viewport(a, 1280, 900);
  790. await b.goto(BASE + '/inbox');
  791. await b.waitForSelector('#pendingbox');
  792. for (const [w, h, dev] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  793. await viewport(b, w, h);
  794. check(`layout ${dev} ${w}px: inbox page has no horizontal overflow`, await noOverflow(b));
  795. }
  796. await viewport(b, 1280, 900);
  797. // ---- the browsers said nothing bad (checked BEFORE ident goes down, ticket #11) ------------------
  798. const problems = [...a.problems(), ...b.problems()].map(m => m.text).filter(t => !/favicon/.test(t));
  799. check('console: no errors or warnings in either browser', problems.length === 0, J(problems).slice(0, 800));
  800. // ---- ident down: a login fails politely, the server keeps serving ----------------------------
  801. // TICKET #11: no page may be loading while ident stops (a tickets page loads ident's
  802. // selector.js → ERR_CONNECTION_REFUSED in the console). Both browsers park on about:blank
  803. // first and stay there; the phase is checked with fetch only, and the console again after.
  804. for (const p of [a, b]) { await p.goto('about:blank'); }
  805. await sleep(300);
  806. const quietBefore = [...a.problems(), ...b.problems()].length;
  807. await ident.stop();
  808. const down = await fetch(BASE + '/login/callback?ident_code=' + 'cd'.repeat(24), { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  809. const downText = await down.text();
  810. const downFace = await temit('identLogin', ['cd'.repeat(24)], GATE_COOKIE);
  811. check('ident down: /login/callback → 400 "ident did not answer" (no 500, no source path)', down.status === 400 && /ident did not answer/.test(downText) && !/\.hl/.test(downText), down.status + ' ' + downText.slice(0, 300));
  812. check('ident down: the selector face answers "ident did not answer", the server keeps serving', downFace.value && downFace.value.error === 'ident did not answer' && (await fetch(BASE + '/')).status === 200, downFace.raw);
  813. const lateProblems = [...a.problems(), ...b.problems()].slice(quietBefore).map(m => m.text);
  814. check('console: nothing new while ident was down (the browsers were parked, #11)', lateProblems.length === 0, J(lateProblems).slice(0, 800));
  815. // ---- THE INSTALLABLE APP (mission 046): manifest, icons, service worker, offline shell ----------------
  816. // Its own fresh Chrome C (no worker from A/B), at the END because it stops the server: the offline reload must
  817. // not reach the server even through the service worker (the CDP offline emulation is the page's, and it is what
  818. // makes navigator.onLine false for the shell's note). ident is already down here (its selector.js fails to load).
  819. const home = await (await fetch(BASE + '/')).text();
  820. const mhref = (home.match(/<link rel="manifest" href="([^"]+)"/) || [])[1];
  821. check('pwa: the page head links the manifest, the apple-touch-icon, the favicon and the theme colour (the header darker)', !!mhref && /<link rel="apple-touch-icon" href="\/icons\/apple-touch-icon.png">/.test(home) && /<link rel="icon" href="\/icons\/icon.svg">/.test(home) && /<meta name="theme-color" content="rgb\(15, 20, 25\)">/.test(home), J([mhref, (home.match(/<link rel="(icon|apple-touch-icon)"[^>]*>|<meta name="theme-color"[^>]*>/g) || [])]));
  822. const mres = await fetch(BASE + (mhref || '/__hl/manifest.webmanifest'));
  823. let man = {}; try { man = await mres.json(); } catch {}
  824. check('pwa: the manifest is served as a web manifest: name tickets, start_url /, display standalone, theme rgb(15, 20, 25)', /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'tickets' && man.start_url === '/' && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', J(man).slice(0, 400));
  825. const pngSize = async (src) => { const r = await fetch(BASE + src); const buf = Buffer.from(await r.arrayBuffer()); return r.ok && /image\/png/.test(r.headers.get('content-type') || '') && buf.readUInt32BE(0) === 0x89504e47 ? buf.readUInt32BE(16) : 0; };
  826. const iconOk = [];
  827. for (const ic of man.icons || []) iconOk.push(ic.type === 'image/png' && await pngSize(ic.src) === Number(ic.sizes.split('x')[0]));
  828. check('pwa: every manifest icon is a real PNG of its declared size (192 and 512, any and maskable)', iconOk.length === 4 && iconOk.every(Boolean) && ['any', 'maskable'].every(p => (man.icons || []).some(i => i.sizes === '512x512' && i.purpose === p)), J(man.icons));
  829. const fav = await fetch(BASE + '/favicon.ico');
  830. const favBuf = Buffer.from(await fav.arrayBuffer());
  831. const svg = await fetch(BASE + '/icons/icon.svg');
  832. check('pwa: apple-touch-icon is a 180px PNG, /favicon.ico an icon file, /icons/icon.svg an SVG', await pngSize('/icons/apple-touch-icon.png') === 180 && fav.ok && /icon/.test(fav.headers.get('content-type') || '') && favBuf.readUInt32LE(0) === 0x00010000 && svg.ok && /svg/.test(svg.headers.get('content-type') || '') && /<svg/.test(await svg.text()));
  833. C = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_C, '8640-8649') });
  834. const c = patient(await C.newPage());
  835. await viewport(c, 390, 844);
  836. await c.goto(BASE + '/');
  837. await c.waitForSelector('#tickets li');
  838. await connected(c, 'C connected');
  839. await c.waitFor('navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))', { label: 'service worker active' });
  840. await c.waitFor('!!navigator.serviceWorker.controller', { label: 'the worker controls the page' });
  841. check('pwa: a service worker is registered for the whole app and controls the page', await c.evaluate(`navigator.serviceWorker.getRegistration().then(r => !!r && new URL(r.scope).pathname === '/' && /\\/__hl\\/sw\\.js/.test(r.active.scriptURL))`));
  842. const inst = await c.send('Page.getInstallabilityErrors');
  843. check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, J(inst));
  844. const online = await c.evaluate(`[!!document.querySelector('#offline'), document.querySelectorAll('#tickets li').length]`);
  845. check('pwa: online the shell shows no offline note', online[0] === false && online[1] === TOTAL, J(online));
  846. const cProblems = c.problems().map(m => m.text).filter(t => !/favicon/.test(t) && !t.includes(':' + IDENT_PORT) && !/Failed to load resource/.test(t));
  847. check('pwa: no console errors in the installed app (ident is down: its selector.js is not counted)', cProblems.length === 0, J(cProblems).slice(0, 600));
  848. await sleep(1500);
  849. await shot(c, 'pwa-phone-online');
  850. // OFFLINE: the network of the page is cut AND the server is gone; `/` reloads from the worker's cache
  851. await stopTickets();
  852. await c.setOffline(true);
  853. await c.goto(BASE + '/');
  854. await c.waitForSelector('#tickets li');
  855. await c.waitFor('!!document.querySelector("#offline")', { label: 'the offline note' });
  856. const off = await c.evaluate(`[document.querySelector('application-header .brand').textContent, document.querySelector('#offline').textContent, document.querySelectorAll('#tickets li').length, getComputedStyle(document.querySelector('#offline')).display]`);
  857. check('pwa: offline reload of / shows the shell (header), the last loaded list and says it is offline', off[0] === 'tickets' && /You are offline/.test(off[1]) && off[2] === TOTAL && off[3] !== 'none', J(off));
  858. await sleep(300);
  859. await shot(c, 'pwa-phone-offline');
  860. check('layout phone 390px: the offline shell has no horizontal overflow', await noOverflow(c));
  861. await c.goto(BASE + '/inbox');
  862. await c.waitForSelector('main[data-hl-offline]');
  863. check('pwa: offline a page that is not kept (/inbox) gets "Unavailable offline"', /Unavailable offline/.test(await c.text('main')), await c.text('main'));
  864. await c.goto(BASE + '/');
  865. await c.waitForSelector('#offline');
  866. await c.setOffline(false);
  867. await c.waitFor('!document.querySelector("#offline")', { label: 'the note goes when the network is back' });
  868. check('pwa: the offline note goes when the browser is online again', true);
  869. } catch (e) {
  870. check('gate ran to the end', false, e.stack || String(e));
  871. } finally {
  872. for (const br of [A, B, C]) { if (br) { try { await br.close(); } catch {} } }
  873. await stopTickets();
  874. if (ident) { await ident.stop(); writeFileSync(join(SCRATCH, 'gate-ident.log'), ident.log()); }
  875. exchProxy.close();
  876. writeFileSync(join(SCRATCH, 'gate-server.log'), log);
  877. }
  878. console.log(`\n${passes} passed, ${failures} failed`);
  879. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • 7538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre
  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre