tickets
All repositories: gitoria
72.9 KB
// tests/browser.mjs — THE GATE of tickets.worldapi.org: its own server on its own// storage, the import command, the JSON API, and TWO real headless Chromes (two// viewers) for the live push. Everything is checked in the browser's DOM, not in// curl output. Ticket #10 added: the strict API table (unknown / missing / empty /// wrong-type fields → 400 naming the field, nothing written), Vienna local time (checked// against node's Intl, also tests/localtime.hl's DST edges), and the paragraph import// (a CRLF fixture in .scratch/gate-import → #7, rendered with a blank line between).// Ticket #15 added: `author` required on every POST (missing/empty → 400 naming author),// invalid JSON → 400 { error } without source paths (jsoncheck.hl), the web forms have no// name field (author 'creator'), and list lines kept by the import (.scratch/gate-import-lists → hybriel #3).// Ticket #38 (mission 008) added: UUID keys + storage in <store>/mpackdb, per-project// numbers and URLs /projects/<slug>/<number>. The gate FIRST writes an old-format store// (tests/oldstore.hl: 5 tickets, old #1–#5 in projects alpha / beta.example.org), migrates// it TWICE with tools/migrate-008.hl (the second run must write nothing), and runs the// server on the migrated tables: old numbers answer on /api/tickets/<n>, /tickets/<n>// redirects (301) to the new URL, the per-project API, live pushes carrying the new hrefs.// Ticket #7 (mission 011) added LOGIN VIA IDENT: the gate runs its OWN ident (a copy of// ident's code without .env / storage — codes go to a mail sink, never real mail; tests/identkit.mjs)// on :8353, registers tickets there (origin = this gate's server), and then: every API write// needs `Authorization: Bearer <token>` (401 otherwise, checked before the body; `author` in a// body → 400), a machine user "gate" logs in through the login button's server half// (/login/callback) and makes its token over the face; the FIRST server runs without// TICKETS_CREATOR_IDENTITY (nobody may confirm/reject → 403), the second with alice's per-app id.// In the browsers: signed out = reading only; A (alice, the creator) logs in with the identity// SELECTOR, B (bob) with the LOGIN BUTTON; both get the display-name prompt; writes show the// user as author; bob cannot confirm/reject, alice can; bob's token on /you: shown once → API// write as bob → revoke → 401; forged face sessions (#31) are refused; old events keep their// authors; logout resets the selector.// Ticket #12 (mission 024) added THE MARKDOWN EDITOR: comment, state note, edit summary and new-ticket// summary are <md-editor>s around their textareas (shared/md-editor.js). Real typing (formatting// while typing, "- " lists, Ctrl+Enter sends), the toolbar by click / tap at 390px, a hostile rich// paste reduced to the subset, the edit form opening the stored Markdown byte for byte, SSR keeps// the plain textareas (no JS = as before). The component's own test: worldapi-components/test/run.mjs.//// node tests/browser.mjs (TICKETS_GATE_PORT to move the server, default 8352;// TICKETS_GATE_IDENT_PORT the gate's ident, default 8353;// TICKETS_GATE_IDENT_DIR ident's code, default ../ident.worldapi.org)//// Debug ports: 8620-8629 (browser A) and 8630-8639 (browser B) — TICKETS_GATE_CHROME_A /// TICKETS_GATE_CHROME_B ("8810-8814") move them (mission 017: parallel workers get disjoint// port ranges). Screenshots at 390px// and 1280px land in .scratch/gate-*.png — LOOK at them. The whole server log is kept in// .scratch/gate-server.log. Every process started here (server, import runs, Chromes)// is stopped by PID in `finally`.import { spawn, spawnSync } from 'node:child_process';import http from 'node:http';import { rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, copyFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser } from './cdp.mjs';import { startIdent } from './identkit.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const BIN = join(APP, 'bin/hybriel');const PORT = Number(process.env.TICKETS_GATE_PORT || 8352);const BASE = `http://127.0.0.1:${PORT}`;const SCRATCH = join(APP, '.scratch');const STORE = join(SCRATCH, 'gate-store');const IDENT_PORT = Number(process.env.TICKETS_GATE_IDENT_PORT || 8353);// ident's CODE (read only — copied without .env/storage by identkit): the sibling folder,// or the Loreana path when the gate runs in a copy (.scratch/dev…)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')));rmSync(STORE, { recursive: true, force: true });mkdirSync(join(STORE, 'mpackdb'), { recursive: true });let failures = 0, passes = 0;function check(label, ok, detail = '') {console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);if (ok) passes++; else failures++;}const sleep = (ms) => new Promise(r => setTimeout(r, ms));const J = JSON.stringify;// ---- the legacy fixture (ticket #20): a store from before projects were own data; the server's migrate.hl// turns it into projects with ids, members and the new states at its first start ----------------------const hl = (script, env) => { const r = spawnSync(BIN, [script], { cwd: APP, env: { ...process.env, ...env }, encoding: 'utf8', timeout: 60000 }); return (r.stdout || '') + (r.stderr || ''); };const fixture = hl('tests/oldstore.hl', { TICKETS_STORAGE: join(STORE, 'mpackdb') });check('migrate: the pre-#20 fixture is written (2 projects, 5 tickets, 8 events)', /oldstore: 2 projects, 5 tickets, 8 events/.test(fixture), fixture);// ---- the servers: our own ident, then tickets (twice: without and with a creator) ----------let log = '';let server = null;let ident = null;// ticket #9 (mission 012): the exchanges ident RECEIVES from tickets, counted by a small proxy// between the two (tickets' IDENT_EXCHANGE_URL → here → our ident)const EXCH_PORT = Number(process.env.TICKETS_GATE_EXCHANGE_PORT || 8357);const exchanges = [];const exchProxy = http.createServer((req, res) => {let body = '';req.on('data', d => body += d);req.on('end', async () => {try {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 });const t = await r.text();let code = ''; try { code = JSON.parse(body).code || ''; } catch {}exchanges.push({ path: req.url, code, status: r.status });res.writeHead(r.status, { 'content-type': r.headers.get('content-type') || 'application/json' });res.end(t);} catch (e) { req.socket.destroy(); } // ident stopped: tickets must see NO answer, like a direct connection});});await new Promise((ok, bad) => { exchProxy.once('error', bad); exchProxy.listen(EXCH_PORT, '127.0.0.1', ok); });let APPKEY = null;function startTickets(extraEnv) {log += `\n==== tickets server start ${J(Object.keys(extraEnv))}\n`;server = spawn(BIN, ['project.hl'], {cwd: APP,env: { ...process.env, TICKETS_PORT: String(PORT), TICKETS_STORAGE: join(STORE, 'mpackdb'), TICKETS_SESSIONS: join(STORE, 'sessions'),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,TICKETS_CREATOR_IDENTITY: '', TICKETS_WATCH: '0', ...extraEnv },stdio: ['ignore', 'pipe', 'pipe'],});server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);}async function stopTickets() {if (!server) return;const s = server;try { s.kill('SIGTERM'); } catch {}await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });}async function ticketsUp() {for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/'); if (r.ok) return; } catch {} await sleep(250); }throw new Error('server did not come up\n' + log);}// THE API: reads never carry a token (reading is public); writes carry the gate user's// token unless another (or none: null) is givenlet TOKEN = null;const api = async (method, path, body, token = TOKEN) => {const headers = body ? { 'content-type': 'application/json' } : {};if (method !== 'GET' && token) headers.authorization = 'Bearer ' + token;const r = await fetch(BASE + path, { method, headers, body: body ? J(body) : undefined });let json = null; try { json = await r.json(); } catch {}return { status: r.status, json };};const runImport = (token = TOKEN) => {const r = spawnSync(BIN, ['import.hl'], { cwd: APP, env: { ...process.env, TICKETS_URL: BASE, TICKETS_TOKEN: token || '' }, encoding: 'utf8', timeout: 60000 });return (r.stdout || '') + (r.stderr || '');};// a face over the REST carrier (POST /__hl/emit) with a cookie (or none)let emitI = 0;// hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is// ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.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; } };const temit = async (event, payload, cookie) => {const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });const raw = await r.text();let j = null; try { j = JSON.parse(raw); } catch {}return { status: r.status, value: j ? j.value : undefined, raw };};const cookieOf = (r) => (r.headers.get('set-cookie') || '').split(';')[0];const texts = (page, sel) => page.evaluate(`Array.from(document.querySelectorAll(${J(sel)})).map(e => e.textContent.trim())`);const type = (page, sel, value) => page.evaluate(`(() => { const i = document.querySelector(${J(sel)}); i.value = ${J(value)}; i.dispatchEvent(new Event("input", { bubbles: true })); })()`);const choose = (page, sel, value) => page.evaluate(`(() => { const i = document.querySelector(${J(sel)}); i.value = ${J(value)}; i.dispatchEvent(new Event("change", { bubbles: true })); })()`);const connected = (page, label) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });// a client-side navigation by a REAL click, then the socket announced the new mountsconst clickNav = async (page, sel, cond, label) => { await page.click(sel); await page.waitFor(cond, { label }); await sleep(300); };async function viewport(page, width, height) {await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });await sleep(250);}async function shot(page, name) {const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));}const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');// INSIDE ident's selector (shadow DOM): an expression over its root `r`, and a REAL clickconst sh = (expr) => `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;async function shClick(page, inner, name = null) {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 }; })()`;const box = await page.waitFor(find, { label: 'selector ' + inner + ' ' + (name || '') });const at = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at, buttons: 0 });await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, buttons: 1 });await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, buttons: 0 });}const connectedIdent = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'ident page hydrated' });// sign in to OUR ident on ident's own page (email → code from the sink → skip the names)async function identSignIn(page, email) {await page.goto(ident.base + '/');await page.waitForSelector('#email');await connectedIdent(page);await page.type('#email', email);await page.click('#sendcode');// ident#20: a sent code NAVIGATES to ident's /code page; type only once that page is hydratedawait page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });await connectedIdent(page);await page.type('#code', ident.lastCode(email));await page.click('#verify');await page.waitForSelector('#signout', { timeout: 10000 });}// TICKET #11 (mission 014): the gate must stay green while other browsers load the machine.// Every wait of a page (waitFor / waitForSelector / click / goto) gets SLOW× its timeout// (TICKETS_GATE_SLOW, default 3: 10 s → 30 s) — a wait that succeeds returns at once, so a green// run costs nothing; only a real failure waits longer before it says so.const SLOW = Number(process.env.TICKETS_GATE_SLOW || 3);function patient(page) {const waitFor = page.waitFor.bind(page);page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });const goto = page.goto.bind(page);page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });return page;}let A, B;try {// ---- ticket #7: our own ident, tickets registered in it ----------------------------------ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });const alice = await ident.signIn('[email protected]');const bob = await ident.signIn('[email protected]');const gateAcct = await ident.signIn('[email protected]');APPKEY = await ident.registerApp(alice, 'tickets (gate)', [BASE]);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')));// alice's per-app id — what the architect puts into TICKETS_CREATOR_IDENTITYconst ALICE_ID = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));const GATE_ID = await ident.exchange(APPKEY, await ident.selectorCode(gateAcct, APPKEY, BASE));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);// ---- server #1: NO creator configured ---------------------------------------------------startTickets({});await ticketsUp();// the machine user "gate": the login button's SERVER half (ident → /login/callback?ident_code=)const first = await fetch(BASE + '/');const GATE_COOKIE = cookieOf(first);check('login: a page visit gets the tickets session cookie (hlsid)', /^hlsid=[0-9a-f]+$/.test(GATE_COOKIE), GATE_COOKIE);const noCode = await fetch(BASE + '/login/callback', { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });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()));const badCode = await fetch(BASE + '/login/callback?ident_code=' + 'ab'.repeat(24), { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });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()));const gateCode = await ident.selectorCode(gateAcct, APPKEY, BASE);const cb = await fetch(BASE + '/login/callback?ident_code=' + gateCode, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });check('login: /login/callback exchanges the code → 302 to /', cb.status === 302 && cb.headers.get('location') === '/', cb.status + ' ' + cb.headers.get('location'));const again = await fetch(BASE + '/login/callback?ident_code=' + gateCode, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });check('login: the same code again → 400 (single use)', again.status === 400 && /already used/.test(await again.text()));// ticket #10: the button's return URL carries ?next=<page> (login.js); /login/callback follows// only a same-origin PATH, anything else → /for (const [next, want] of [['/projects/alpha/2', '/projects/alpha/2'], ['/project/alpha/state/open?x=1&y=2', '/project/alpha/state/open?x=1&y=2'], ['/inbox', '/inbox'],['/%2F%2Fevil.example', '/%2F%2Fevil.example'],['//evil.example/x', '/'], ['https://evil.example/', '/'], ['http:/evil.example', '/'], ['/\\evil.example', '/'], ['javascript:alert(1)', '/'],['evil.example', '/'], ['/a b', '/'], ['/x\r\nSet-Cookie: a=b', '/'], ['/x"><script>', '/'], ['/login/callback', '/'], ['/' + 'a'.repeat(500), '/'], ['', '/'],]) {const c = await ident.selectorCode(gateAcct, APPKEY, BASE);const r = await fetch(BASE + '/login/callback?next=' + encodeURIComponent(next) + '&ident_code=' + c, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });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'));}let html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();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"'));check('login: the page never carries the identity id (only /you does)', !html.includes(GATE_ID));let f = await temit('commentOn', ['x', 'before the name'], GATE_COOKIE);check('login: no writing before a display name', f.value && /display name first/.test(f.value.error), f.raw);f = await temit('saveDisplayName', [' '], GATE_COOKIE);check('name: an empty display name is refused', f.value && /must not be empty/.test(f.value.error), f.raw);f = await temit('saveDisplayName', ['x'.repeat(61)], GATE_COOKIE);check('name: 61 characters are refused', f.value && /too long/.test(f.value.error), f.raw);f = await temit('saveDisplayName', ['gate'], GATE_COOKIE);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);f = await temit('saveDisplayName', ['gate2'], GATE_COOKIE);check('name: asked once — a second name is refused', f.value && /already set/.test(f.value.error), f.raw);html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();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"'));f = await temit('tokenCreate', ['gate machine'], GATE_COOKIE);TOKEN = f.value && f.value.token;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);const you0 = await (await fetch(BASE + '/you', { headers: { cookie: GATE_COOKIE } })).text();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));// ticket #20: the legacy projects have NO admin yet (no creator configured) and the gate user is no member: every write is refusedconst nm1 = await api('POST', '/api/tickets/1/comments', { text: 'not a member' });const nm2 = await api('POST', '/api/projects/alpha/tickets/1/state', { state: 'review' });const nm3 = await api('POST', '/api/projects/alpha/tickets', { subject: 'not a member' });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));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));await stopTickets();// ---- server #2: the gate user is the creator → admin of the legacy projects (its session + token survive) --startTickets({ TICKETS_CREATOR_IDENTITY: GATE_ID });await ticketsUp();html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();check('restart: the gate user is still logged in (session on disk)', /id="whoami"[^>]*>gate</.test(html), html.slice(0, 200));// ---- the migrated legacy tickets: old numbers still answer (ticket #38) ---------------const PT = (slug, n) => `/api/projects/${slug}/tickets/${n}`;const old1 = await api('GET', '/api/tickets/1');check('legacy: GET /api/tickets/1 (old number) answers alpha #1 with project, number, href, oldNumber',old1.status === 200 && old1.json.ticket.project === 'alpha' && old1.json.ticket.number === 1 && old1.json.ticket.ref === '#1' && old1.json.ticket.oldNumber === 1&& old1.json.ticket.href === '/projects/alpha/1' && old1.json.ticket.apiHref === PT('alpha', 1) && /^[0-9a-z]{8,}$/.test(old1.json.ticket.id), J(old1.json).slice(0, 500));check('legacy: history kept in order with authors, texts and times (same ms → old order)',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));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));const a2 = await api('GET', PT('alpha', 2));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));check('legacy: old #4 is alpha #3, old #5 is beta.example.org #2', (await api('GET', '/api/tickets/4')).json.ticket.href === '/projects/alpha/3' && (await api('GET', '/api/tickets/5')).json.ticket.href === '/projects/beta.example.org/2');const byUuid = await api('GET', '/api/tickets/' + old1.json.ticket.id);check('legacy: /api/tickets/<uuid> answers the same ticket', byUuid.status === 200 && byUuid.json.ticket.href === '/projects/alpha/1');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);const oldCmt = await api('POST', '/api/tickets/4/comments', { text: 'via the old number' });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 === '/projects/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));const newCmt = await api('POST', PT('alpha', 3) + '/comments', { text: 'via the project number' });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);const newSt = await api('POST', PT('beta.example.org', 2) + '/state', { state: 'on-hold', text: 'parked' });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));const a4 = await api('POST', '/api/projects/alpha/tickets', { subject: 'New alpha after the migration' });check('numbers: POST /api/projects/alpha/tickets → alpha #4, no old number', a4.status === 201 && a4.json.ticket.number === 4 && a4.json.ticket.href === '/projects/alpha/4' && !('oldNumber' in a4.json.ticket) && a4.json.ticket.hasOld === false, J(a4.json));for (const t of ['gamma', 'antcolony', 'tickets.worldapi.org', 'ident.worldapi.org', 'gitoria.worldapi.org']) await api('POST', '/api/projects', { title: t, slug: t });const g1 = await api('POST', '/api/tickets', { project: 'gamma', subject: 'first of a new project' });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 === '/projects/gamma/1', J(g1.json));const g2 = await api('POST', '/api/tickets', { project: 'gamma', subject: 'second of gamma' });check('numbers: … and counts on (gamma #2)', g2.json.ticket.number === 2 && (await api('GET', PT('gamma', 2))).json.ticket.subject === 'second of gamma');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');const redir = await fetch(BASE + '/tickets/3', { redirect: 'manual' });check('legacy: GET /tickets/3 (old page URL) → 301 Location /projects/alpha/2', redir.status === 301 && redir.headers.get('location') === '/projects/alpha/2', redir.status + ' ' + redir.headers.get('location'));check('legacy: GET /tickets/99 → 404', (await fetch(BASE + '/tickets/99', { redirect: 'manual' })).status === 404);const LEGACY = (await api('GET', '/api/tickets')).json.tickets.length; // 5 migrated + alpha #4 + gamma #1, #2// ---- import: the five AntColony ticket files, twice ----------------------------------const imp0 = runImport(null);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);const imp1 = runImport();check('import: first run creates all five', /import: 5 new, 0 already there, 0 failed/.test(imp1), imp1);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);const imp2 = runImport();check('import: second run is idempotent (0 new, 5 already there)', /import: 0 new, 5 already there, 0 failed/.test(imp2), imp2);let list = await api('GET', '/api/tickets');const TOTAL = LEGACY + 5;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));let one = await api('GET', PT('tickets.worldapi.org', 1));check('api: tickets.worldapi.org #1 is the first file (project, subject, summary, a created event)',one.status === 200 && one.json.ticket.project === 'tickets.worldapi.org' && one.json.ticket.subject === 'Build the World Ticket System in Hybriel'&& 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:')&& 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));check('api: antcolony #1 is the scheduler file', (await api('GET', PT('antcolony', 1))).json.ticket.source === '0004-scheduler.md');check('api: unknown ticket is a 404', (await api('GET', '/api/tickets/99')).status === 404 && (await api('GET', PT('antcolony', 99))).status === 404);check('api: a ticket without subject is refused (400)', (await api('POST', '/api/tickets', { project: 'x' })).status === 400);check('api: an unknown state is refused (400)', (await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'nope' })).status === 400);check('api: an empty comment is refused (400)', (await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: ' ' })).status === 400);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);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');const projects = await api('GET', '/api/projects');const imported = (await api('GET', '/api/tickets')).json.tickets.filter(t => t.source);check('import: hard-wrapped lines are joined (no line break left in the five)', imported.length === 5 && imported.every(t => !t.summary.includes('\n')));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));// ---- the API is strict (ticket #10): every refused body writes NOTHING ----------------const rawPost = async (path, raw, auth = 'Bearer ' + TOKEN) => {const r = await fetch(BASE + path, { method: 'POST', headers: { 'content-type': 'application/json', ...(auth ? { authorization: auth } : {}) }, body: raw });let json = null; try { json = await r.clone().json(); } catch {}return { status: r.status, json, text: json ? '' : await r.text() };};const strictCases = [// [path, raw body, expected status, expected `field` (null = none), error must include]['/api/tickets', J({ project: 'x', subject: 's', bogus: '1' }), 400, 'bogus', "unknown field 'bogus'"],['/api/tickets', J({ project: 'x', subject: 's', Subject: 't' }), 400, 'Subject', "unknown field 'Subject'"],['/api/tickets', J({ project: 'x' }), 400, 'subject', "field 'subject' is required"],['/api/tickets', J({ subject: 's' }), 400, 'project', "field 'project' is required"],['/api/tickets', J({ project: 'x', subject: ' ' }), 400, 'subject', "field 'subject' must not be empty"],['/api/tickets', J({ project: '', subject: 's' }), 400, 'project', "field 'project' must not be empty"],['/api/tickets', J({ project: 'x', subject: 5 }), 400, 'subject', "field 'subject' must be a string"],['/api/tickets', J({ project: 'x', subject: 's', summary: null }), 400, 'summary', "field 'summary' must be a string"],['/api/tickets', J({ project: 'a b', subject: 's' }), 400, 'project', 'no such project'],// ticket #7: NO free author any more — the author is the token's user; a body naming one is refused['/api/tickets', J({ project: 'x', subject: 's', author: ['a'] }), 400, 'author', "no field 'author' any more"],['/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"],['/api/tickets', J({ project: 'x', subject: 's', summary: 'y', author: ' ' }), 400, 'author', "no field 'author' any more"],['/api/tickets/2/comments', J({ text: 'x', author: 'creator' }), 400, 'author', "no field 'author' any more"],['/api/tickets/2/comments', J({ text: 'x', author: '' }), 400, 'author', "no field 'author' any more"],['/api/tickets/2/state', J({ state: 'in progress', author: 'creator' }), 400, 'author', "no field 'author' any more"],['/api/tickets/2/state', J({ state: 'confirmed', text: 'n', author: ' ' }), 400, 'author', "no field 'author' any more"],['/api/tickets', J([1, 2]), 400, '', 'must be a JSON object'],['/api/tickets', J('text'), 400, '', 'must be a JSON object'],['/api/tickets', '', 400, '', 'must be a JSON object'],['/api/tickets/2/comments', J({ text: 'hi', state: 'open' }), 400, 'state', "unknown field 'state'"],['/api/tickets/2/comments', J({}), 400, 'text', "field 'text' is required"],['/api/tickets/2/comments', J({ text: ' ' }), 400, 'text', "field 'text' must not be empty"],['/api/tickets/2/comments', J({ text: { a: 1 } }), 400, 'text', "field 'text' must be a string"],['/api/tickets/2/comments', J({ text: true }), 400, 'text', "field 'text' must be a string"],['/api/tickets/2/comments', J({ text: 'x', author: 7 }), 400, 'author', "no field 'author' any more"],['/api/tickets/2/state', J({ state: 'open', note: 'x' }), 400, 'note', "unknown field 'note'"],['/api/tickets/2/state', J({ text: 'x' }), 400, 'state', "field 'state' is required"],['/api/tickets/2/state', J({ state: '' }), 400, 'state', "field 'state' must not be empty"],['/api/tickets/2/state', J({ state: 3 }), 400, 'state', "field 'state' must be a string"],['/api/tickets/2/state', J({ state: 'nope' }), 400, 'state', 'unknown state'],['/api/tickets/2/state', J({ state: 'open' }), 400, 'state', 'already open'],['/api/tickets/99/comments', J({ text: 'x' }), 404, null, 'no such ticket'],// ticket #38: the slug is the project name, so a new name must be URL-safe; the per-project form is as strict['/api/tickets', J({ project: 'a#b', subject: 's' }), 400, 'project', 'no such project'],['/api/tickets', J({ project: 'ä', subject: 's' }), 400, 'project', 'no such project'],['/api/projects/beta.example.org/tickets/1/comments', J({ text: 'x', author: 'a' }), 400, 'author', "no field 'author' any more"],['/api/projects/beta.example.org/tickets/1/comments', J({ text: 'x', bogus: '1' }), 400, 'bogus', "unknown field 'bogus'"],['/api/projects/beta.example.org/tickets/1/state', J({ state: 'open' }), 400, 'state', 'already open'],['/api/projects/beta.example.org/tickets/1/state', J({ state: 5 }), 400, 'state', "field 'state' must be a string"],['/api/projects/beta.example.org/tickets', J({ subject: 's', author: 'a' }), 400, 'author', "no field 'author' any more"],['/api/projects/beta.example.org/tickets/9/comments', J({ text: 'x' }), 404, null, 'no such ticket'],['/api/projects/beta.example.org/tickets/x/comments', J({ text: 'x' }), 404, null, 'no such ticket'],];for (const [path, raw, want, field, says] of strictCases) {const r = await rawPost(path, raw);const ok = r.status === want && r.json && typeof r.json.error === 'string' && r.json.error.includes(says) && (field === null || r.json.field === field);check(`strict: POST ${path} ${raw || '(empty)'} → ${want}${field ? ' naming ' + field : ''}`, ok, r.status + ' ' + J(r.json) + r.text);}// invalid JSON (ticket #15): refused by jsoncheck.hl BEFORE JSON.parse → 400 { error },// no source path (workaround for hybriel #12: an uncaught JSON.parse answered 500 + api.hl:31:9)const badJsonCases = [['/api/tickets', '{"project":"x",', 15], ['/api/tickets', '{bad', 1], ['/api/tickets', "{'project':'x'}", 1],['/api/tickets', '{"project":"x","subject":"s","summary":"a",}', 43], ['/api/tickets', '[1 2]', 3],['/api/tickets/2/comments', '{"text":"x","n":"a"} trailing', 21], ['/api/tickets/2/comments', '{"text":"\\x","n":"a"}', 10],['/api/tickets/2/state', '{"state":01}', 10], ['/api/tickets/2/state', '['.repeat(70), 64], ['/api/tickets/2/state', ' ', 3],// LONE \u surrogate escapes: hl's JSON.parse still refuses them (would be a 500) → refused by the check// (a valid pair parses since hybriel#15, mission 036: see the two checks below)['/api/tickets/2/comments', '{"text":"\\ud800"}', 10], ['/api/tickets/2/comments', '{"text":"\\udc00"}', 10],['/api/tickets/2/comments', '{"text":"\\ud83dx"}', 10], ['/api/tickets/2/comments', '{"text":"\\ud83d\\u0041"}', 10],];for (const [path, raw, at] of badJsonCases) {const r = await rawPost(path, raw);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);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);}// valid but unusual JSON is NOT mistaken for invalid (escapes, unicode, numbers, nesting) → normal field errorsconst oddValid = await rawPost('/api/tickets/2/comments', '{ "text" : "q\\"b\\\\s\\/\\u00e9\\n", "n": [-1.5e+3, 0, true, null, {"x":[]}] }');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);const pairOnly = await rawPost('/api/tickets/2/comments', '{"text":"\\ud83d\\ude00","n":"a"}');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);const escaped = await rawPost('/api/tickets/3/comments', '{"text":"q\\"b\\\\s\\/\\u00e9 \u{1F600} end \\ud83d\\ude00"}');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);const afterStrict = await api('GET', '/api/tickets');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)));check('strict: GET /api/inbox refuses POST (405)', (await rawPost('/api/inbox', '{}')).status === 405);// ---- ticket #7: API writes need a valid token — 401 BEFORE the body is looked at ----------const before401 = J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events]));const authCases = [// [path, raw body, Authorization header (null = none), label]['/api/tickets', J({ project: 'x', subject: 's' }), null, 'no token'],['/api/projects/alpha/tickets', J({ subject: 's' }), null, 'no token'],['/api/tickets/2/comments', J({ text: 'x' }), null, 'no token'],['/api/projects/alpha/tickets/1/comments', J({ text: 'x' }), null, 'no token'],['/api/tickets/2/state', J({ state: 'in progress' }), null, 'no token'],['/api/projects/alpha/tickets/1/state', J({ state: 'on hold' }), null, 'no token'],['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer tkt_' + '0'.repeat(48), 'an unknown token'],['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ' + TOKEN + '0', 'a token with one character more'],['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ' + TOKEN.slice(0, -1), 'a token with one character less'],['/api/tickets/2/comments', J({ text: 'x' }), TOKEN, 'the token without "Bearer"'],['/api/tickets/2/comments', J({ text: 'x' }), 'Basic ' + TOKEN, 'Basic instead of Bearer'],['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ', 'an empty Bearer'],['/api/tickets/2/comments', '{bad', null, 'no token + invalid JSON (auth first)'],['/api/tickets/2/comments', J({ text: 'x', author: 'creator' }), null, 'no token + an author field (auth first)'],];for (const [path, raw, auth, label] of authCases) {const r = await rawPost(path, raw, auth);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);}check('auth: the refused writes wrote nothing', J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events])) === before401);const anonRead = await fetch(BASE + '/api/tickets/1');check('auth: reading needs no token (GET without Authorization → 200)', anonRead.status === 200);// ---- local time (ticket #10): Europe/Vienna, storage stays epoch ms -----------------------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' });const lt = spawnSync(BIN, ['tests/localtime.hl'], { cwd: APP, encoding: 'utf8', timeout: 30000 });const ltLines = (lt.stdout || '').trim().split('\n').filter(Boolean);const ltBad = ltLines.filter(l => { const [ms, ...rest] = l.split(' '); return vienna.format(new Date(Number(ms))) !== rest.join(' '); });check('time: localtime.hl matches Intl Europe/Vienna on DST edges 2000–2100', ltLines.length === 11 && ltBad.length === 0, J(ltBad) + (lt.stderr || ''));const t2 = (await api('GET', PT('ident.worldapi.org', 1))).json;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]));// ---- two viewers ------------------------------------------------------------------const range = (v, dflt) => (v || dflt).split('-').map(Number);A = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_A, '8620-8629') });B = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_B, '8630-8639') });const a = patient(await A.newPage());const b = patient(await B.newPage());// SSR of the listawait a.goto(BASE + '/');await a.waitForSelector('#tickets li');await connected(a, 'A connected');check('list: SSR shows every ticket (migrated + new + imported)', (await texts(a, '#tickets li')).length === TOTAL, J(await texts(a, '#tickets li')));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'))`));check('list: every ticket link is /projects/<project>/<number> of its row', await a.evaluate(`Array.from(document.querySelectorAll('#tickets li')).every(li => li.querySelector('a.subject').getAttribute('href') === '/projects/' + li.querySelector('ticket-meta a').textContent + '/' + li.querySelector('ticket-ref').textContent.slice(1))`));check('list: the project filter offers all seven projects', (await texts(a, '#projectfilter a')).length === 8, J(await texts(a, '#projectfilter a')));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`));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)');// filters by real clicks (client-side navigation)await clickNav(a, '#projectfilter a[href="/project/antcolony"]', 'location.pathname === "/project/antcolony" && document.querySelectorAll("#tickets li").length === 2', 'project filter');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')));check('filter: the chosen project is marked selected', (await a.text('#projectfilter a.selected')) === 'antcolony');await clickNav(a, '#statefilter a[href="/project/antcolony/state/progress"]', 'location.pathname === "/project/antcolony/state/progress" && !!document.querySelector("#empty")', 'state filter');check('filter: project + state "progress" is empty (nothing in progress yet)', (await texts(a, '#tickets li')).length === 0);await clickNav(a, '#statefilter a[href="/project/antcolony"]', 'document.querySelectorAll("#tickets li").length === 2', 'state filter cleared');check('filter: clearing the state keeps the project', (await a.evaluate('location.pathname')) === '/project/antcolony');await a.goto(BASE + '/state/open');await a.waitForSelector('#tickets li');const OPEN = (await api('GET', '/api/tickets?state=open')).json.tickets.length;check('filter: /state/open by URL (SSR) shows every open ticket', (await texts(a, '#tickets li')).length === OPEN, OPEN);// ---- a migrated ticket in the browser: the old URL redirects, a real click opens the new oneawait a.goto(BASE + '/tickets/4');await a.waitForSelector('#subject');check('legacy: the old URL /tickets/4 lands on /projects/alpha/3 (redirect in the browser)', (await a.evaluate('location.pathname')) === '/projects/alpha/3' && (await a.text('#subject')) === 'Legacy alpha three' && (await a.text('#ref')) === '#3', await a.evaluate('location.href'));check('legacy: the page says "formerly #4"', (await a.text('#oldref')) === 'formerly #4', await a.text('#oldref'));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')));await a.goto(BASE + '/project/alpha');await a.waitForSelector('#tickets li');await connected(a, 'A on alpha');await clickNav(a, '#tickets a.subject[href="/projects/alpha/1"]', 'location.pathname === "/projects/alpha/1" && !!document.querySelector("#events li")', 'click alpha #1');check('legacy: a real click on a list row opens /projects/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')));await a.goto(BASE + '/projects/gamma/1');await a.waitForSelector('#subject');check('numbers: a new ticket has no "formerly"', (await a.text('#ref')) === '#1' && !(await a.evaluate('!!document.querySelector("#oldref")')));await a.goto(BASE + '/projects/alpha/99');await a.waitForSelector('#gone');check('numbers: an unknown number shows "no such ticket"', (await a.text('#gone')).includes('no such ticket'));await a.goto(BASE + '/projects/gamma');await a.waitForSelector('#tickets li');check('numbers: /projects/<slug> lists that project', (await texts(a, '#tickets li')).length === 2 && (await a.text('#heading')) === 'gamma');// ---- old events keep their authors (ticket #7) --------------------------------------------await a.goto(BASE + '/projects/alpha/1');await a.waitForSelector('#events li');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')));// ---- signed out: reading only (ticket #7) --------------------------------------------------const IDENT1 = '/projects/ident.worldapi.org/1';await a.goto(BASE + IDENT1);await a.waitForSelector('#events li');await connected(a, 'A on ident #1 (signed out)');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")')));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; })()`));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")'));check('signed out: the selector is restyled to tickets\' accent (purple)', await a.evaluate(sh('getComputedStyle(r.querySelector("#choose")).backgroundColor')) === 'rgb(197, 134, 192)');let anonCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];const anonTry = await temit('commentOn', [(await api('GET', PT('ident.worldapi.org', 1))).json.ticket.id, 'anonymous'], anonCookie);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);for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(a, w, h);check(`layout ${name} ${w}px: signed-out header (selector + login button) has no horizontal overflow`, await noOverflow(a));await shot(a, `${name}-signedout`);}await viewport(a, 1280, 900);// ---- A = alice (the creator): sign in to ident, then the SELECTOR on tickets ---------------await identSignIn(a, '[email protected]');await a.goto(BASE + IDENT1);await a.waitForSelector('#events li');await connected(a, 'A on ident #1');await a.evaluate('window.__loginMarker = 1');await shClick(a, '#choose');await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list' });check('selector: lists alice\'s identity', J(await a.evaluate(sh(`[...r.querySelectorAll('[part~="identity"]')].map(b => b.textContent)`))) === J(['Default']));await shClick(a, '[part~="identity"]', 'Default');await a.waitForSelector('#nameform', { timeout: 10000 });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'))));check('selector login: still no write forms before the name', !(await a.evaluate('!!document.querySelector("#commentform")')));for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(a, w, h);check(`layout ${name} ${w}px: name prompt has no horizontal overflow`, await noOverflow(a));await shot(a, `${name}-name`);}await viewport(a, 1280, 900);await a.type('#displayname', 'alice');await a.click('#namesave');await a.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#memberhint")', { label: 'A named, no role yet: a member hint, no forms' });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")')));// ticket #20: the admin (the gate user, the creator) gives alice a role in every project; the page follows liveconst 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)); } };await grant('alice', 'admin');await a.goto(BASE + IDENT1);await a.waitFor('!!document.querySelector("#commentform")', { label: 'A: the forms appear after alice got a role' });await connected(a, 'A on ident #1 with a role');await a.evaluate('window.__loginMarker = 1');check('roles: alice made admin → the comment form appears (page reloaded), no member hint', !(await a.evaluate('!!document.querySelector("#memberhint")')));for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(a, w, h);check(`layout ${name} ${w}px: logged-in header has no horizontal overflow`, await noOverflow(a));await shot(a, `${name}-loggedin`);}await viewport(a, 1280, 900);const aliceCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];const aliceHtml = await (await fetch(BASE + '/', { headers: { cookie: aliceCookie } })).text();const ALICE_UID = (/\\?"user\\?":\{\\?"id\\?":\\?"([0-9a-z]{12})/.exec(aliceHtml) || [])[1];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);// ---- B = bob: sign in to ident, then the LOGIN BUTTON ---------------------------------------await identSignIn(b, '[email protected]');await b.goto(BASE + '/');await b.waitForSelector('#loginbutton');await b.click('#loginbutton');await b.waitForSelector('#chooselist', { timeout: 10000 });check('button: tickets → ident /signin/<rid> (choose an identity)', (await b.evaluate('location.href')).startsWith(ident.base + '/signin/'));await connectedIdent(b);await b.click('#chooselist li:nth-child(1) .choose');await b.waitFor(`location.href === ${J(BASE + '/')} && !!document.querySelector('#nameform')`, { timeout: 10000, label: 'B back with the name prompt' });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")')));await connected(b, 'B on / after the button login');await b.type('#displayname', 'bob');await b.click('#namesave');await b.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#rolehint")', { label: 'B named' });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")')));await grant('bob', 'edit');await b.goto(BASE + '/');await b.waitFor('!!document.querySelector("#newticket")', { label: 'B: the new-ticket form appears with the role edit' });await connected(b, 'B on / with a role');check('roles: bob made edit → the new-ticket form appears', (await b.text('#whoami')) === 'bob');const bobCookie = (await b.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];// ---- the ticket page, two viewers ---------------------------------------------------await b.goto(BASE + IDENT1);await b.waitForSelector('#events li');await connected(b, 'B on ident #1');await sleep(300);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(' | '));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'));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));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'));// A comments (real typing into the form, real click) → B sees it without reloadcheck('ticket: no author field (ticket #7: the author is the logged-in user)', await a.evaluate('!document.querySelector("#author") && !document.querySelector("input[name=author]")'));await type(a, '#commenttext', 'first comment from A\nsecond line');await a.click('#commentsend');await a.waitFor('document.querySelectorAll("#events li").length === 2', { label: 'A sees its comment' });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'));check('comment: the textarea was cleared', await a.evaluate('document.querySelector("#commenttext").value === ""'));await b.waitFor('document.querySelectorAll("#events li").length === 2', { label: 'B got the comment live' });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');check('live: B did not reload (no navigation entry)', await b.evaluate('performance.getEntriesByType("navigation").length === 1 && performance.getEntriesByType("navigation")[0].type === "navigate"'));await b.evaluate('window.__gateMarker = 42'); // survives only if B never reloads// ---- ticket #20: the new states, the roles, projects with members, the inbox (browser + API) ------------------// A (alice, admin) moves ident #1 to "review": B follows live; the ticket is assigned to the project's first admin (the gate user)check('shell: no inbox count yet (nothing assigned to bob)', !(await b.evaluate('!!document.querySelector("#inboxcount")')));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')));await choose(a, '#newstate', 'review');await type(a, '#statenote', 'please test');await a.click('#statesend');await a.waitFor('document.querySelector("#state").textContent === "review"', { label: 'A state changed' });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'));await b.waitFor('document.querySelector("#state").textContent === "review"', { label: 'B state live' });check('live: B\'s state badge followed, no reload', (await b.evaluate('document.querySelector("#state").className')) === 'review' && (await b.evaluate('window.__gateMarker')) === 42);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 }; };const inbox0 = await inboxOf(TOKEN);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));const oldNames = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'awaiting creator' });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));const aliasBack = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'in progress' });check('states: "in progress" is an alias of progress (201)', aliasBack.status === 201 && aliasBack.json.ticket.state === 'progress', J(aliasBack).slice(0, 300));await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'open' });// roles: bob = use → comment + open ⇄ review, nothing else (web and API)let aliceTok0 = null;const bobTok = (await temit('tokenCreate', ['bob gate'], bobCookie)).value.token;await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'use' });await b.goto(BASE + IDENT1);await b.waitForSelector('#stateform');await connected(b, 'B on ident #1 as use');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')));const useDone = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'done' }, bobTok);const useReview = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'review' }, bobTok);const useNew = await api('POST', '/api/projects/ident.worldapi.org/tickets', { subject: 'use may not' }, bobTok);const useSettings = await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'admin' }, bobTok);const useComment = await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: 'a use member comments' }, bobTok);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));await b.goto(BASE + '/projects/ident.worldapi.org/settings');await b.waitForSelector('#notadmin');check('roles: the settings page of a non-admin says so and has no forms', !(await b.evaluate('!!document.querySelector("#detailsform")')));await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'edit' });const editAssign = await api('POST', PT('ident.worldapi.org', 1) + '/assign', { assignee: 'bob' }, bobTok);check('roles: edit may assign (201) and move to any state', editAssign.status === 201 || editAssign.status === 200, J(editAssign).slice(0, 300));const editPending = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'pending' }, bobTok);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));// the inbox page of bob: pending and review, only what is assigned to himawait b.goto(BASE + '/inbox');await b.waitForSelector('#pendingbox');await connected(b, 'B inbox');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')));await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'review' }, bobTok);await b.waitFor('document.querySelectorAll("#review li").length === 1 && document.querySelectorAll("#pending li").length === 0', { label: 'inbox follows the state change live' });check('inbox: the ticket moved from pending to review live, header count 1', (await b.text('#inboxcount')) === '1');aliceTok0 = (await temit('tokenCreate', ['alice #20'], aliceCookie)).value.token;check('inbox: alice (admin, nothing assigned) has an empty inbox', (await inboxOf(aliceTok0)).json.review.length === 0);// a new project by hand: title → slug proposed, admin can change it; old slugs keep workingawait a.goto(BASE + '/new-project');await a.waitForSelector('#projectform');await connected(a, 'A on /new-project');await type(a, '#projecttitle', 'My Project');await a.waitFor('document.querySelector("#projectslug").value === "my-project"', { label: 'slug proposed' });check('project: the slug my-project is proposed from the title', (await a.evaluate('document.querySelector("#projectslug").value')) === 'my-project');await a.click('#projectcreate');await a.waitForSelector('#created');const mp = await api('GET', '/api/projects/my-project');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));await a.goto(BASE + '/projects/my-project/settings');await a.waitForSelector('#detailsform');await connected(a, 'A on settings');await type(a, '#setslug', 'my-proj');await type(a, '#setdescription', 'The **first** project.');await a.click('#detailssave');await a.waitFor('!!document.querySelector("#savemessage") && document.querySelector("#savemessage").textContent.length > 0', { label: 'saved' });const mp2 = await api('GET', '/api/projects/my-proj');const mpOld = await api('GET', '/api/projects/my-project');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));await a.goto(BASE + '/projects/my-project');await a.waitForSelector('#heading');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'));// members in the settings page: add bob as use; remove; the last admin cannot goawait a.goto(BASE + '/projects/my-proj/settings');await a.waitForSelector('#addform');await connected(a, 'A on settings again');await type(a, '#addref', 'bob');await choose(a, '#addrole', 'use');await a.click('#addsave');await a.waitFor('document.querySelectorAll("#members li").length === 2', { label: 'bob added' });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']]));const lastAdmin = await api('POST', '/api/projects/my-proj/members', { user: 'alice', role: 'use' }, aliceTok0);check('members: the last admin cannot be demoted (400)', lastAdmin.status === 400 && /at least one admin/.test(lastAdmin.json.error), J(lastAdmin));const badRole = await api('POST', '/api/projects/my-proj/members', { user: 'bob', role: 'boss' }, aliceTok0);check('members: an unknown role is refused (400)', badRole.status === 400 && badRole.json.field === 'role', J(badRole));const rm = await api('POST', '/api/projects/my-proj/members/remove', { user: 'bob' }, aliceTok0);check('members: an admin removes a member (200)', rm.status === 200 && rm.json.members.length === 1, J(rm).slice(0, 300));// an invite link through identconst inv = await api('POST', '/api/projects/my-proj/invites', { role: 'use' }, aliceTok0);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));// carol opens the link, chooses her identity at ident → ident sends her back to tickets with the invite id → she is a memberconst carol = await ident.signIn('[email protected]');const invPage = await fetch(inv.json.url, { redirect: 'manual' });const rid = (invPage.headers.get('location') || '').split('/').pop();const chosen = await ident.emit('chooseIdentity', [rid, carol.identities[0].id], carol.cookie);const back = chosen.value && chosen.value.url ? await fetch(chosen.value.url.replace(/^https?:\/\/[^/]+/, BASE), { redirect: 'manual' }) : { status: 0, headers: new Headers() };const carolCookie = (back.headers.get('set-cookie') || '').split(';')[0];const carolFace = await temit('saveDisplayName', ['carol'], carolCookie);const mp3 = (await api('GET', '/api/projects/my-proj')).json;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));const invBob = await api('POST', '/api/projects/my-proj/invites', { role: 'use' }, bobTok);check('invite: a non-admin gets 403', invBob.status === 403, J(invBob).slice(0, 200));// the new pages fit the screen (phone and desktop), with the project's data on themfor (const [path, sel, name] of [['/projects/my-proj/settings', '#addform', 'settings'], ['/new-project', '#projectform', 'new-project'], ['/projects/my-proj', '#heading', 'project']]) {await a.goto(BASE + path);await a.waitForSelector(sel);await connected(a, 'A on ' + path);for (const [w, h, dev] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(a, w, h);check(`layout ${dev} ${w}px: ${name} page has no horizontal overflow`, await noOverflow(a));await shot(a, `${dev}-${name}`);}}await viewport(a, 1280, 900);await b.goto(BASE + '/inbox');await b.waitForSelector('#pendingbox');for (const [w, h, dev] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(b, w, h);check(`layout ${dev} ${w}px: inbox page has no horizontal overflow`, await noOverflow(b));}await viewport(b, 1280, 900);// ---- the browsers said nothing bad (checked BEFORE ident goes down, ticket #11) ------------------const problems = [...a.problems(), ...b.problems()].map(m => m.text).filter(t => !/favicon/.test(t));check('console: no errors or warnings in either browser', problems.length === 0, J(problems).slice(0, 800));// ---- ident down: a login fails politely, the server keeps serving ----------------------------// TICKET #11: no page may be loading while ident stops (a tickets page loads ident's// selector.js → ERR_CONNECTION_REFUSED in the console). Both browsers park on about:blank// first and stay there; the phase is checked with fetch only, and the console again after.for (const p of [a, b]) { await p.goto('about:blank'); }await sleep(300);const quietBefore = [...a.problems(), ...b.problems()].length;await ident.stop();const down = await fetch(BASE + '/login/callback?ident_code=' + 'cd'.repeat(24), { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });const downText = await down.text();const downFace = await temit('identLogin', ['cd'.repeat(24)], GATE_COOKIE);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));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);const lateProblems = [...a.problems(), ...b.problems()].slice(quietBefore).map(m => m.text);check('console: nothing new while ident was down (the browsers were parked, #11)', lateProblems.length === 0, J(lateProblems).slice(0, 800));} catch (e) {check('gate ran to the end', false, e.stack || String(e));} finally {for (const br of [A, B]) { if (br) { try { await br.close(); } catch {} } }await stopTickets();if (ident) { await ident.stop(); writeFileSync(join(SCRATCH, 'gate-ident.log'), ident.log()); }exchProxy.close();writeFileSync(join(SCRATCH, 'gate-server.log'), log);}console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);
Branches
- mainmain branch