tickets
All repositories: gitoria
84.1 KB
PARKED at ticket #20 (2026-09-26): the browser checks of the pre-roles gate (creator-only confirm, author-only edit, 'awaiting creator' inbox,relations by author, tokens page, layouts …). They assume the old permission model and state names; port them to the roles, then move them backinto browser.mjs after the '#20' section.// A changes state → B's badge and history follow, B's shell inbox count appearscheck('shell: no inbox count yet', !(await b.evaluate('!!document.querySelector("#inboxcount")')));await choose(a, '#newstate', 'awaiting creator');await type(a, '#statenote', 'please test');await a.click('#statesend');await a.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'A state changed' });check('state: A shows the new state and a state event with the note', (await a.text('#events li:last-child')).includes('alice changed the state') && (await a.text('#events li:last-child ticket-state')) === 'awaiting creator' && (await a.text('#events li:last-child event-text')) === 'please test', await a.text('#events li:last-child'));check('state: the badge takes the state colour (purple for awaiting creator)', await a.evaluate('getComputedStyle(document.querySelector("#state")).color') === 'rgb(197, 134, 192)');await b.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'B state live' });check('live: B\'s state badge followed', (await b.evaluate('document.querySelector("#state").className')) === 'awaiting-creator');check('live: B\'s history has the state event', (await texts(b, '#events li')).length === 3);await b.waitFor('!!document.querySelector("#inboxcount") && document.querySelector("#inboxcount").textContent === "1"', { label: 'B inbox count' });check('live: B\'s header inbox count shows 1', true);check('live: B never reloaded', (await b.evaluate('window.__gateMarker')) === 42);// an API write reaches the open browsersconst apiComment = await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: 'comment via the API' });check('api: POST comment answers 201 with the event', apiComment.status === 201 && apiComment.json.event.kind === 'comment', J(apiComment));await a.waitFor('document.querySelectorAll("#events li").length === 4', { label: 'API comment reached A' });await b.waitFor('document.querySelectorAll("#events li").length === 4', { label: 'API comment reached B' });check('live: an API comment appears in both browsers', (await a.text('#events li:last-child')).includes('gate commented') && (await b.text('#events li:last-child event-text')) === 'comment via the API');// B opens the inbox (real click on the header link)await clickNav(b, '#navinbox', 'location.pathname === "/inbox" && document.querySelectorAll("#tickets li").length === 1', 'B inbox');check('inbox: lists the one ticket awaiting creator (ident #1, new href)', (await b.text('#tickets li a.subject')).includes('#1') && (await b.evaluate('document.querySelector("#tickets li a.subject").getAttribute("href")')) === IDENT1, await b.text('#tickets li'));// A moves a MIGRATED ticket into the inbox via its OLD number, then ident #1 out of it via the webawait api('POST', '/api/tickets/3/state', { state: 'awaiting-creator', text: 'done, verified' });await b.waitFor('document.querySelectorAll("#tickets li").length === 2', { label: 'inbox grows live' });check('live: inbox grows when a ticket enters "awaiting creator" (API, old number #3)', (await b.text('#tickets li:first-child a.subject')).includes('Legacy alpha two'), J(await texts(b, '#tickets li')));check('live: the pushed row carries the NEW href /projects/alpha/2', (await b.evaluate('document.querySelector("#tickets li:first-child a.subject").getAttribute("href")')) === '/projects/alpha/2' && (await b.text('#tickets li:first-child ticket-ref')) === '#2');check('live: header count is 2', (await b.text('#inboxcount')) === '2');await choose(a, '#newstate', 'confirmed');await a.click('#statesend');await b.waitFor('document.querySelectorAll("#tickets li").length === 1', { label: 'inbox shrinks live' });check('live: inbox drops ident #1 when A (alice, the creator) confirms it', (await b.text('#tickets li a.subject')).includes('Legacy alpha two'));check('live: header count back to 1', (await b.text('#inboxcount')) === '1');check('creator: the confirm event is alice\'s', (await api('GET', PT('ident.worldapi.org', 1))).json.events.pop().author === 'alice');// ---- bob is not the creator: confirm / reject refused (web and API) -------------------------await clickNav(b, '#tickets li a.subject', 'location.pathname === "/projects/alpha/2" && !!document.querySelector("#stateform")', 'B opens alpha #2');const alpha2Before = (await api('GET', PT('alpha', 2))).json.events.length;for (const st of ['confirmed', 'rejected']) {await choose(b, '#newstate', st);await b.click('#statesend');await b.waitFor(`document.querySelector("#message").textContent.includes(${J('only the creator can set a ticket to ' + st)})`, { label: 'B refused ' + st });check(`not creator: bob's "${st}" is refused on the page, the state stays`, (await b.text('#state')) === 'awaiting creator' && (await b.text('#message')).includes('only the creator can set a ticket to ' + st));}check('not creator: nothing was written', (await api('GET', PT('alpha', 2))).json.events.length === alpha2Before);await choose(b, '#newstate', 'in progress');await b.click('#statesend');await b.waitFor('document.querySelector("#state").textContent === "in progress"', { label: 'B other state' });check('not creator: bob may set other states (in progress, author bob)', (await b.text('#events li:last-child')).includes('bob changed the state'));await choose(b, '#newstate', 'awaiting creator');await b.click('#statesend');await b.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'B back to awaiting' });// ---- ticket #19: the creator's comment ANSWERS a ticket waiting for him; another user's does not ----await type(b, '#commenttext', 'bob asks again');await b.click('#commentsend');await b.waitFor('document.querySelector("#events li:last-child event-text") && document.querySelector("#events li:last-child event-text").textContent === "bob asks again"', { label: 'B comment' });check('#19: a non-creator comment leaves "awaiting creator" as it is', (await b.text('#state')) === 'awaiting creator');await a.goto(BASE + '/projects/alpha/2');await a.waitForSelector('#commenttext');await connected(a, 'A alpha #2');await type(a, '#commenttext', 'alice answers');await a.click('#commentsend');await b.waitFor('document.querySelector("#state").textContent === "answered"', { label: 'B sees answered' });const ans19 = (await api('GET', PT('alpha', 2))).json;const last19 = ans19.events.slice(-2);check('#19: the creator comment answers: state "answered", comment + state event, out of the inbox', last19[0].kind === 'comment' && last19[1].kind === 'state' && last19[1].to === 'answered' && last19[1].from === 'awaiting creator' && !(await b.evaluate('!!document.querySelector("#inboxcount") && document.querySelector("#inboxcount").textContent !== "0"')), J(last19));await b.waitFor('document.querySelector("#state").textContent === "answered"');await choose(b, '#newstate', 'awaiting creator');await b.click('#statesend');await b.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'asker sets awaiting again' });check('#19: the asker can set "awaiting creator" again after an answer', (await b.text('#inboxcount')) === '1');// ---- bob's API token on /you: shown once → API write as bob → revoke → 401 -----------------await clickNav(b, '#whoami', 'location.pathname === "/you" && !!document.querySelector("#tokenform")', 'B /you');const bobId = (await b.text('#youidentity')).trim();check('/you: bob sees his own per-app id (32 hex, not alice\'s) and "creator only"', /^[0-9a-f]{32}$/.test(bobId) && bobId !== ALICE_ID && (await b.text('#youname')) === 'bob' && /for the creator only/.test(await b.text('#youcreator')) && !!(await b.evaluate('!!document.querySelector("#notokens")')));await b.type('#tokenlabel', 'bob laptop');await b.click('#tokencreate');await b.waitForSelector('#newtoken');const BOB_TOKEN = (await b.text('#newtoken')).trim();check('token: shown once (tkt_ + 48 hex), listed with its label', /^tkt_[0-9a-f]{48}$/.test(BOB_TOKEN) && J(await texts(b, '#tokenlist .label')) === J(['bob laptop']));for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(b, w, h);check(`layout ${name} ${w}px: /you with the new token has no horizontal overflow`, await noOverflow(b));await shot(b, `${name}-tokens`);}await viewport(b, 1280, 900);await b.click('#tokendone');await b.waitFor('!document.querySelector("#newtoken")');await b.goto(BASE + '/you');await b.waitForSelector('#tokenlist li');// mission 017: the Revoke click below needs the hydrated page (a click on the bare SSR button// did nothing once: "waitFor timed out: selector #notokens", .scratch/m017/gate-dev-4.txt)await connected(b, 'B /you after the reload');check('token: after a reload it is listed but never shown again', !(await b.evaluate(`document.documentElement.outerHTML.includes(${J(BOB_TOKEN)})`)) && (await texts(b, '#tokenlist li')).length === 1);const asBob = await api('POST', PT('alpha', 2) + '/comments', { text: 'written with bob\'s token' }, BOB_TOKEN);check('token: an API write with it acts as bob (201, author bob)', asBob.status === 201 && asBob.json.event.author === 'bob', J(asBob));const bobConfirm = await api('POST', PT('alpha', 2) + '/state', { state: 'confirmed' }, BOB_TOKEN);check('token: bob\'s token cannot confirm (403 naming state)', bobConfirm.status === 403 && bobConfirm.json.field === 'state', J(bobConfirm));await b.click('#tokenlist li .revoke');await b.waitForSelector('#notokens');check('token: revoked on the page (the list is empty)', (await texts(b, '#tokenlist li')).length === 0);const afterRevoke = await api('POST', PT('alpha', 2) + '/comments', { text: 'after the revoke' }, BOB_TOKEN);check('token: the revoked token → 401, nothing written', afterRevoke.status === 401 && (await api('GET', PT('alpha', 2))).json.events.filter(e => e.text === 'after the revoke').length === 0, J(afterRevoke));const bobTok2 = await temit('tokenCreate', ['second'], bobCookie);const aliceRevokesBob = await temit('tokenRevoke', [bobTok2.value.tokens[0].id], aliceCookie);check('token: another user cannot revoke it (alice → "no such token"), it still works', aliceRevokesBob.value && aliceRevokesBob.value.error === 'no such token' && (await api('POST', PT('alpha', 2) + '/comments', { text: 'second token works' }, bobTok2.value.token)).status === 201, aliceRevokesBob.raw);// alice's /you: she is the creator; the id is the one ident gave tickets for her identityawait a.goto(BASE + '/you');await a.waitForSelector('#youidentity');check('/you: alice is the creator, her id = the configured TICKETS_CREATOR_IDENTITY', (await a.text('#youidentity')).trim() === ALICE_ID && /You are the creator/.test(await a.text('#youcreator')));// ---- #31: a forged trailing session argument is never a session -----------------------------const forged = { id: 'x', user: { id: ALICE_UID }, data: { tag: 'x' } };const tid = (await api('GET', PT('alpha', 2))).json.ticket.id;const beforeForge = J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events]));for (const [ev, args, re] of [['openTicket', ['alpha', 'forged', ''], /log in with ident/], ['commentOn', [tid, 'forged'], /log in with ident/],['setTicketState', [tid, 'confirmed', ''], /log in with ident/], ['tokenCreate', ['forged'], /log in with ident/],['tokenRevoke', ['x'], /log in with ident/], ['saveDisplayName', ['mallory'], /log in with ident/],['identLogin', ['ab'.repeat(24)], /no session/], ['logOut', [], /no session/],]) {const r = await temit(ev, [...args, forged], null);check(`forged session refused (#31): ${ev}`, framework_refused(r.raw) || (r.value && re.test(r.value.error || '')), r.raw);}const yd = await temit('youData', [forged], null);check('forged session refused (#31): youData answers nothing', yd.value === null || yd.value === undefined, yd.raw);check('forged: nothing was written', J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events])) === beforeForge);// ---- ticket #8 (mission 014): the AUTHOR edits subject + summary; history keeps the old ----// ---- ticket #6: Markdown rendered safely; `Accept: text/markdown` read view -----------------const MD_RICH = ['# Plan', '','Some *em*, **strong**, `inline <code>` and a [safe link](https://example.org/a?b=1).','Second line of the same paragraph, see https://example.org/x.', '','- item one', '- item **two**', '','1. first', '2. second', '','```', '<script>window.__xss = 1</script>', '```', '','<img src=x onerror="window.__xss=2"> <script>window.__xss=3</script>',// the old hl:webex </script> hole (mission 014; hl:web escapes the seed itself, hybriel#34): this// line used to END the page's seed <script> and run as HTML'</script><img src=x onerror=window.__xss=5>','[bad](javascript:window.__xss=4) [data](data:text/html,x) [ok](/projects/gamma/1)',].join('\n');const gm = await api('POST', '/api/projects/gamma/tickets', { subject: 'Markdown and edits', summary: MD_RICH });check('#8: the gate user opens gamma #3 with a Markdown summary (201)', gm.status === 201 && gm.json.ticket.number === 3 && gm.json.ticket.summary === MD_RICH, J(gm));const GM = PT('gamma', 3);const e1 = await api('POST', GM + '/edit', { subject: 'Edited subject' });check('#8 api: the author edits the subject (201, an "edit" event with the previous values, summary kept)',e1.status === 201 && e1.json.ticket.subject === 'Edited subject' && e1.json.ticket.summary === MD_RICH && e1.json.event.kind === 'edit' && e1.json.event.label === 'edited the ticket'&& e1.json.event.oldSubject === 'Markdown and edits' && e1.json.event.newSubject === 'Edited subject' && e1.json.event.subjectChanged === true && e1.json.event.summaryChanged === false && e1.json.event.author === 'gate' && e1.json.event.seq === 2, J(e1));const e2 = await api('POST', '/api/tickets/' + gm.json.ticket.id + '/edit', { summary: MD_RICH + '\n\nAdded **later**.' });check('#8 api: … and the summary via /api/tickets/<uuid>/edit (201, oldSummary = the previous one)', e2.status === 201 && e2.json.event.oldSummary === MD_RICH && e2.json.event.newSummary === MD_RICH + '\n\nAdded **later**.' && e2.json.event.summaryChanged === true && e2.json.event.subjectChanged === false && e2.json.ticket.subject === 'Edited subject', J(e2));const gmHist = (await api('GET', GM)).json;check('#8 api: the history is append-only: created, edit, edit — every earlier version is still there', J(gmHist.events.map(e => [e.seq, e.kind])) === J([[1, 'created'], [2, 'edit'], [3, 'edit']]) && gmHist.events[1].oldSubject === 'Markdown and edits' && gmHist.events[2].oldSummary === MD_RICH && gmHist.ticket.events === 3, J(gmHist.events));const gmBefore = J((await api('GET', GM)).json);const aliceTok = (await temit('tokenCreate', ['alice gate'], aliceCookie)).value.token;for (const [label, body, token, want, field] of [['no token', { subject: 'x' }, null, 401, null],['bob\'s token (not the author)', { subject: 'bob was here' }, bobTok2.value.token, 403, null],['alice\'s token (the creator, but not the author)', { subject: 'alice was here' }, aliceTok, 403, null],['an empty body {}', {}, TOKEN, 400, ''],['an empty subject', { subject: ' ' }, TOKEN, 400, 'subject'],['the same values (nothing changed)', { subject: 'Edited subject' }, TOKEN, 400, 'subject'],['an unknown field', { subject: 'y', text: 'x' }, TOKEN, 400, 'text'],['a non-string subject', { subject: 5 }, TOKEN, 400, 'subject'],['an author field', { subject: 'y', author: 'mallory' }, TOKEN, 400, 'author'],]) {const r = await api('POST', GM + '/edit', body, token);check(`#8 api: edit with ${label} → ${want}`, r.status === want && r.json && typeof r.json.error === 'string' && (field === null || r.json.field === field), r.status + ' ' + J(r.json));}check('#8 api: an edit of an unknown ticket → 404', (await api('POST', PT('gamma', 99) + '/edit', { subject: 'x' })).status === 404 && (await api('POST', '/api/tickets/nope/edit', { subject: 'x' })).status === 404);check('#8 api: the refused edits wrote nothing', J((await api('GET', GM)).json) === gmBefore);// a ticket from BEFORE the login (its 'created' event has only the free-text author "creator")const legBefore = J((await api('GET', PT('alpha', 1))).json);const legGate = await api('POST', PT('alpha', 1) + '/edit', { subject: 'gate edits legacy' });const legBob = await api('POST', PT('alpha', 1) + '/edit', { subject: 'bob edits legacy' }, bobTok2.value.token);check('#8 api: a ticket from before the login: not the gate user, not bob (403), nothing written', legGate.status === 403 && legBob.status === 403 && J((await api('GET', PT('alpha', 1))).json) === legBefore, J([legGate, legBob]));const legAlice = await api('POST', PT('alpha', 1) + '/edit', { summary: 'Summary added by the creator.' }, aliceTok);check('#8 api: … only the creator (alice) may edit it (201)', legAlice.status === 201 && legAlice.json.ticket.summary === 'Summary added by the creator.' && legAlice.json.event.oldSummary === 'first legacy ticket' && legAlice.json.event.author === 'alice', J(legAlice));// JSON is unchanged: an event that is not an edit carries no new keys; Accept: application/json = no Acceptconst EVENT_KEYS = J(['author', 'createdMs', 'from', 'hasText', 'id', 'isComment', 'isCreated', 'isState', 'kind', 'label', 'number', 'project', 'seq', 'text', 'ticket', 'to', 'toSlug', 'when']);const plainJson = await (await fetch(BASE + PT('alpha', 2))).text();const acceptJson = await fetch(BASE + PT('alpha', 2), { headers: { accept: 'application/json' } });check('#6: JSON unchanged — application/json (and no Accept) answer JSON, non-edit events have exactly the old keys', /application\/json/.test(acceptJson.headers.get('content-type')) && (await acceptJson.text()) === plainJson && JSON.parse(plainJson).events.every(e => J(Object.keys(e).sort()) === EVENT_KEYS), plainJson.slice(0, 300));// THE MARKDOWN READ VIEW (Accept: text/markdown) — one ticket with its history, the listsconst mdGet = async (path, accept = 'text/markdown') => { const r = await fetch(BASE + path, { headers: { accept } }); return { status: r.status, type: r.headers.get('content-type') || '', vary: r.headers.get('vary') || '', text: await r.text() }; };const md1 = await mdGet(GM);check('#6 read view: GET one ticket with Accept: text/markdown → 200 text/markdown, Vary: Accept', md1.status === 200 && /^text\/markdown/.test(md1.type) && /accept/i.test(md1.vary), J(md1).slice(0, 300));check('#6 read view: title, meta line, URL, the summary as written, the history', md1.text.startsWith('# gamma #3: Edited subject\n\nstate: open · opened ') && md1.text.includes(' by gate · updated ') && md1.text.includes(`\n${BASE}/projects/gamma/3\n`) && md1.text.includes('\n' + MD_RICH + '\n\nAdded **later**.\n') && md1.text.includes('\n## History\n'), md1.text);check('#6 read view: one "### <seq> · <when> · <author> <what>" line per event, edits with the previous values', /\n### 1 · \d{4}-\d\d-\d\d \d\d:\d\d · gate opened the ticket\n/.test(md1.text) && /\n### 2 · [^\n]* · gate edited the subject\n\nSubject before: Markdown and edits\n/.test(md1.text) && /\n### 3 · [^\n]* · gate edited the summary\n\nSummary before:\n\n> # Plan\n>\n> Some \*em\*/.test(md1.text) && (md1.text.match(/\n### /g) || []).length === 3, md1.text);check('#6 read view: /api/tickets/<uuid> gives the same document', (await mdGet('/api/tickets/' + gm.json.ticket.id)).text === md1.text);const mdL = await mdGet('/api/projects/gamma/tickets');const gammaRows = (await api('GET', '/api/projects/gamma/tickets')).json.tickets;const mdLines = mdL.text.trim().split('\n');check('#6 read view: a project list → a heading + one line per ticket (project #n [state] subject · updated · URL)', mdL.status === 200 && /^text\/markdown/.test(mdL.type) && mdLines[0] === `# Tickets (project gamma): ${gammaRows.length}` && mdLines.length === gammaRows.length + 2 && mdLines.slice(2).every((l, i) => l === `- gamma ${gammaRows[i].ref} [${gammaRows[i].state}] ${gammaRows[i].subject} · updated ${gammaRows[i].updated} · ${BASE}${gammaRows[i].href}`), mdL.text);const mdAll = await mdGet('/api/tickets?state=open');check('#6 read view: GET /api/tickets?state=open → "# Tickets (state open): N" + N lines', mdAll.text.startsWith(`# Tickets (state open): ${(await api('GET', '/api/tickets?state=open')).json.tickets.length}\n`) && mdAll.text.trim().split('\n').length === (await api('GET', '/api/tickets?state=open')).json.tickets.length + 2, mdAll.text.slice(0, 300));for (const [accept, isMd] of [['application/json, text/markdown', false], ['text/markdown, application/json', true], ['text/markdown;q=0.5, application/json', false], ['text/markdown;q=0', false], ['*/*', false], ['text/*', false], ['text/plain, text/markdown', true]]) {const r = await mdGet(GM, accept);check(`#6 read view: Accept "${accept}" → ${isMd ? 'Markdown' : 'JSON'}`, isMd ? /^text\/markdown/.test(r.type) : /application\/json/.test(r.type) && JSON.parse(r.text).ticket.subject === 'Edited subject', r.type);}check('#6 read view: errors stay JSON (404 with Accept: text/markdown)', await mdGet(PT('gamma', 99)).then(r => r.status === 404 && /application\/json/.test(r.type)));// the parser alone (markdown.hl via tests/markdown.hl): block kinds + spansconst mdCases = ['a *b* **c** ***d*** _e_ __f__ snake_case 2*3 \\*g\\*', '- x\n- y\nwrapped\n\nafter', '1) one\n2) two', '## H ##', '```\n# not a heading\n```', '[x](JAVASCRIPT:alert(1)) [y](vbscript:x) [z](//evil.org) [w](http://ok.org/) <https://a.org/b> (https://c.org/d).'];const mdOut = spawnSync(BIN, ['tests/markdown.hl'], { cwd: APP, env: { ...process.env, MD_INPUT: J(mdCases) }, encoding: 'utf8', timeout: 30000 });let mdParsed = null; try { mdParsed = JSON.parse(mdOut.stdout.trim().split('\n').pop()); } catch {}const flat = (spans) => spans.map(s => s.isCode ? `<code>${s.text}</code>` : s.isLink ? `<a ${s.href}>${s.text}</a>` : s.isStrong ? `<b>${s.text}</b>` : s.isEm ? `<i>${s.text}</i>` : s.isStrongEm ? `<bi>${s.text}</bi>` : s.text).join('');check('#6 parser: emphasis, strong, both; snake_case, 2*3 and \\* stay text', mdParsed && flat(mdParsed[0][0].spans) === 'a <i>b</i> <b>c</b> <bi>d</bi> <i>e</i> <b>f</b> snake_case 2*3 *g*', mdParsed && J(mdParsed[0]));check('#6 parser: a list, a wrapped item, then a paragraph', mdParsed && mdParsed[1].length === 2 && mdParsed[1][0].isUl && mdParsed[1][0].items.length === 2 && flat(mdParsed[1][0].items[1].spans) === 'y\nwrapped' && mdParsed[1][1].isP, mdParsed && J(mdParsed[1]));check('#6 parser: ordered list with ")"; closing #s dropped; a fence keeps "#" as code', mdParsed && mdParsed[2][0].isOl && mdParsed[2][0].items.length === 2 && mdParsed[3][0].isH2 && flat(mdParsed[3][0].spans) === 'H' && mdParsed[4][0].isCode && mdParsed[4][0].code === '# not a heading', mdParsed && J(mdParsed.slice(2, 5)));check('#6 parser: javascript: / vbscript: / protocol-relative links stay text; http, <autolink>, bare URL minus the ")." are links', mdParsed && flat(mdParsed[5][0].spans) === '[x](JAVASCRIPT:alert(1)) [y](vbscript:x) [z](//evil.org) <a http://ok.org/>w</a> <a https://a.org/b>https://a.org/b</a> (<a https://c.org/d>https://c.org/d</a>).', mdParsed && flat(mdParsed[5][0].spans) + mdOut.stderr);// ---- in the browsers: bob's own ticket (bob = B, alice = A) ----------------------------------const bt = await api('POST', '/api/projects/gamma/tickets', { subject: 'Bob\'s Markdown ticket', summary: MD_RICH }, bobTok2.value.token);const BT = '/projects/gamma/' + bt.json.ticket.number;check('#8: bob opens ' + BT + ' (his token)', bt.status === 201 && bt.json.ticket.number === 4, J(bt));await b.goto(BASE + BT);await b.waitForSelector('#summary markdown-text');await connected(b, 'B on bob\'s ticket');await a.goto(BASE + BT);await a.waitForSelector('#summary markdown-text');await connected(a, 'A on bob\'s ticket');await sleep(300);check('#8 page: the author (bob) sees "Edit"; alice (creator, not the author) does not', await b.evaluate('!!document.querySelector("#editbutton")') && !(await a.evaluate('!!document.querySelector("#editbutton")')));check('#8 page: signed out (no cookie) there is no "Edit"', !(await (await fetch(BASE + BT)).text()).includes('id="editbutton"'));const rendered = await b.evaluate(`(() => { const s = document.querySelector('#summary'); return {h3: [...s.querySelectorAll('h3')].map(e => e.textContent), em: [...s.querySelectorAll('p em')].map(e => e.textContent), strong: [...s.querySelectorAll('strong')].map(e => e.textContent),code: [...s.querySelectorAll('p code')].map(e => e.textContent), ul: s.querySelectorAll('ul > li').length, ol: s.querySelectorAll('ol > li').length,pre: (s.querySelector('pre code') || {}).textContent, hrefs: [...s.querySelectorAll('a')].map(a => a.getAttribute('href')),bad: s.querySelectorAll('script, img, iframe, [onerror]').length, text: s.textContent, xss: window.__xss === undefined ? null : window.__xss }; })()`);check('#6 page: headings, emphasis, strong, inline code, lists and a code block are rendered',J(rendered.h3) === J(['Plan']) && J(rendered.em) === J(['em']) && J(rendered.strong) === J(['strong', 'two']) && J(rendered.code) === J(['inline <code>']) && rendered.ul === 2 && rendered.ol === 2 && rendered.pre === '<script>window.__xss = 1</script>', J(rendered));check('#6 page: SAFE — only the http(s) and same-site links are links; no script / img / handler element; raw HTML and the javascript: link show as text; nothing ran',J(rendered.hrefs) === J(['https://example.org/a?b=1', 'https://example.org/x', '/projects/gamma/1']) && rendered.bad === 0 && rendered.xss === null&& rendered.text.includes('<img src=x onerror="window.__xss=2"> <script>window.__xss=3</script>') && rendered.text.includes('</script><img src=x onerror=window.__xss=5>') && rendered.text.includes('[bad](javascript:window.__xss=4) [data](data:text/html,x)'), J(rendered));check('#6 page: the single line break inside a paragraph stays a line break', await b.evaluate(`(() => { const p = document.querySelector('#summary p'); return getComputedStyle(p).whiteSpace === 'pre-wrap' && p.textContent.includes('.\\nSecond line'); })()`));// a Markdown comment through the API → pushed live, rendered in both browsersawait b.evaluate('window.__mdMarker = 1');await a.evaluate('window.__mdMarker = 1');await api('POST', PT('gamma', 4) + '/comments', { text: 'Looks **good**, see [the plan](https://example.org/plan) and `x < y`.\n\n- a\n- b' });for (const [p, who] of [[a, 'A'], [b, 'B']]) {await p.waitFor('document.querySelectorAll("#events > li").length === 2', { label: who + ' got the Markdown comment' });check(`#6 live: ${who} renders the pushed Markdown comment (strong, link, code, list) without a reload`, await p.evaluate(`(() => { const t = document.querySelector('#events > li:last-child event-text'); return t.querySelector('strong').textContent === 'good' && t.querySelector('a').getAttribute('href') === 'https://example.org/plan' && t.querySelector('code').textContent === 'x < y' && t.querySelectorAll('ul li').length === 2 && window.__mdMarker === 1; })()`), await p.evaluate('document.querySelector("#events > li:last-child").innerHTML'));}// a Markdown state note from bob's formawait choose(b, '#newstate', 'in progress');await type(b, '#statenote', 'started, **step 1** in `markdown.hl`');await b.click('#statesend');await a.waitFor('document.querySelectorAll("#events > li").length === 3', { label: 'A got the state note' });check('#6 live: a state note is Markdown too (strong + code, in A)', await a.evaluate(`(() => { const t = document.querySelector('#events > li:last-child event-text'); return !!t && t.querySelector('strong').textContent === 'step 1' && t.querySelector('code').textContent === 'markdown.hl'; })()`), await a.evaluate('document.querySelector("#events > li:last-child").innerHTML'));// bob edits in the web form: subject + summary; alice follows liveawait shot(b, 'desktop-editbutton');await b.click('#editbutton');await b.waitForSelector('#editform');check('#8 page: the form holds the current subject and summary (raw Markdown)', await b.evaluate(`document.querySelector('#editsubject').value === ${J('Bob\'s Markdown ticket')} && document.querySelector('#editsummary').value === ${J(MD_RICH)} && !document.querySelector('#editbutton')`));for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(b, w, h);check(`layout ${name} ${w}px: the edit form has no horizontal overflow`, await noOverflow(b));await shot(b, `${name}-edit`);}await viewport(b, 1280, 900);await b.click('#editcancel');await b.waitFor('!document.querySelector("#editform") && !!document.querySelector("#editbutton")', { label: 'B edit cancelled' });check('#8 page: Cancel closes the form and writes nothing', (await api('GET', PT('gamma', 4))).json.events.length === 3);await b.click('#editbutton');await b.waitForSelector('#editform');await type(b, '#editsubject', ' ');await b.click('#editsave');await b.waitFor('document.querySelector("#editmessage") && document.querySelector("#editmessage").textContent === "subject is required"', { label: 'B empty subject refused' });check('#8 page: an empty subject is refused on the page, nothing written', (await api('GET', PT('gamma', 4))).json.events.length === 3);const NEW_SUMMARY = '## New plan\n\n- one\n- *two*\n\nDone.';await type(b, '#editsubject', 'Bob\'s edited ticket');await type(b, '#editsummary', NEW_SUMMARY);await b.click('#editsave');await b.waitFor('!document.querySelector("#editform") && document.querySelector("#subject").textContent === "Bob\'s edited ticket"', { label: 'B saved the edit' });await a.waitFor('document.querySelector("#subject").textContent === "Bob\'s edited ticket" && document.querySelectorAll("#events > li").length === 4', { label: 'A got the edit live' });for (const [p, who] of [[b, 'B'], [a, 'A']]) {const v = await p.evaluate(`(() => { const s = document.querySelector('#summary'); const li = document.querySelector('#events > li:last-child'); return { h4: [...s.querySelectorAll('h4')].map(e => e.textContent), items: [...s.querySelectorAll('ul li')].map(e => e.innerHTML.replace(/<!--[^>]*-->/g, '')), head: li.querySelector('event-head').textContent, was: (li.querySelector('.was s') || {}).textContent, oldSum: (li.querySelector('details.was markdown-text h3') || {}).textContent, marker: window.__mdMarker }; })()`);check(`#8 live: ${who} shows the new subject + rendered summary, and "bob edited the ticket" with the previous subject and summary (no reload)`, J(v.h4) === J(['New plan']) && J(v.items) === J(['one', '<em>two</em>']) && /bob edited the ticket/.test(v.head) && v.was === 'Bob\'s Markdown ticket' && v.oldSum === 'Plan' && v.marker === 1, J(v));}const btApi = (await api('GET', PT('gamma', 4))).json;check('#8: stored — ticket has the new values, the edit event (author bob) the old ones', btApi.ticket.subject === 'Bob\'s edited ticket' && btApi.ticket.summary === NEW_SUMMARY && btApi.events[3].kind === 'edit' && btApi.events[3].author === 'bob' && btApi.events[3].oldSubject === 'Bob\'s Markdown ticket' && btApi.events[3].oldSummary === MD_RICH, J(btApi.events[3]));await a.evaluate(`document.querySelector('#events > li:last-child details').open = true`);for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(a, w, h);check(`layout ${name} ${w}px: the Markdown ticket with an edit has no horizontal overflow`, await noOverflow(a));await shot(a, `${name}-markdown`);}await viewport(a, 1280, 900);// the faces: alice may not edit bob's ticket; a forged session neverconst btBefore = J((await api('GET', PT('gamma', 4))).json);const aliceEdit = await temit('ticketEdit', [bt.json.ticket.id, 'alice edits', 'x'], aliceCookie);const forgedEdit = await temit('ticketEdit', [bt.json.ticket.id, 'forged', 'x', forged], null);const forgedMay = await temit('ticketMayEdit', [bt.json.ticket.id, forged], null);const bobMay = await temit('ticketMayEdit', [bt.json.ticket.id], bobCookie);check('#8 faces: alice (not the author) refused, a forged session (#31) refused / may not, bob may; nothing written', aliceEdit.value && /only the author/.test(aliceEdit.value.error) && (framework_refused(forgedEdit.raw) || (forgedEdit.value && /log in with ident/.test(forgedEdit.value.error))) && (framework_refused(forgedMay.raw) || (forgedMay.value && forgedMay.value.may === false)) && bobMay.value && bobMay.value.may === true && J((await api('GET', PT('gamma', 4))).json) === btBefore, J([aliceEdit.raw, forgedEdit.raw, forgedMay.raw, bobMay.raw]));// a ticket from before the login: the creator (alice) gets "Edit", bob does not; after a// logout + selector login (no reload) alice's "Edit" comes backawait a.goto(BASE + '/projects/alpha/1');await a.waitForSelector('#events > li');await connected(a, 'A on alpha #1');await b.goto(BASE + '/projects/alpha/1');await b.waitForSelector('#events > li');check('#8 page: legacy alpha #1 — "Edit" for alice (creator), not for bob', await a.evaluate('!!document.querySelector("#editbutton")') && !(await b.evaluate('!!document.querySelector("#editbutton")')));check('#6 page: the edit of a legacy ticket shows as "alice edited the ticket"', (await a.text('#events > li:last-child event-head')).includes('alice edited the ticket'));await a.evaluate('window.__editMarker = 1');await a.click('#logout');await a.waitFor('!document.querySelector("#editbutton") && !!document.querySelector("#loginbutton")', { label: 'A logged out, no Edit' });await shClick(a, '#choose');await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list (edit)' });await shClick(a, '[part~="identity"]', 'Default');await a.waitFor('!!document.querySelector("#whoami") && !!document.querySelector("#editbutton")', { label: 'A back with Edit' });check('#8 page: logout hides "Edit", a selector login brings it back without a reload', (await a.evaluate('window.__editMarker')) === 1 && (await a.text('#whoami')) === 'alice');// ---- ticket #4 (mission 017): parent / child, blocked by — API, Markdown, two browsers --------// Project rel.example: the gate user opens P (#1) and C1–C3 (#2–#4, like ident #1 with its// pieces), bob opens X (#5). Who may: the author of either ticket, or the creator (alice).const RP = (n) => PT('rel.example', n);const relMk = async (subject, token = TOKEN) => (await api('POST', '/api/projects/rel.example/tickets', { subject }, token)).json.ticket;const P = await api('POST', '/api/tickets', { project: 'rel.example', subject: 'Parent: rebuild it in pieces' });const C1 = await relMk('piece 1/3');const C2 = await relMk('piece 2/3');const C3 = await relMk('piece 3/3');const X = await relMk('bob\'s blocker', bobTok2.value.token);check('#4: rel.example #1–#5 opened (P by gate, C1–C3 by gate, X by bob); a new row has empty relations', P.status === 201 && C3.number === 4 && X.number === 5&& P.json.ticket.parent === null && J(P.json.ticket.children) === '[]' && J(P.json.ticket.blockedBy) === '[]' && J(P.json.ticket.blocks) === '[]' && P.json.ticket.allChildrenConfirmed === false, J([P, X]));const PID = P.json.ticket.id;const s1 = await api('POST', RP(2) + '/parent', { parent: 'rel.example#1' });check('#4 api: POST …/parent {parent:"rel.example#1"} → 201, a "link" event on the child, the row names the parent with its state',s1.status === 201 && s1.json.event.kind === 'link' && s1.json.event.isLink === true && s1.json.event.linkKind === 'parent' && s1.json.event.linkAction === 'set' && s1.json.event.label === 'set the parent to rel.example #1'&& s1.json.event.otherHref === '/projects/rel.example/1' && s1.json.event.author === 'gate' && s1.json.ticket.parent.key === 'rel.example#1' && s1.json.ticket.parent.state === 'open' && s1.json.ticket.parent.href === '/projects/rel.example/1' && s1.json.ticket.parent.id === PID && s1.json.ticket.events === 2, J(s1));const s2 = await api('POST', '/api/tickets/' + C2.id + '/parent', { parent: PID });const s3 = await api('POST', RP(4) + '/parent', { parent: ' rel.example # 1 ' });check('#4 api: … by UUID route + parent UUID, and "rel.example # 1" with spaces → 201', s2.status === 201 && s3.status === 201 && s2.json.ticket.parent.key === 'rel.example#1' && s3.json.ticket.parent.key === 'rel.example#1', J([s2, s3]));let pg = (await api('GET', RP(1))).json;check('#4 api: the parent lists its children (oldest link first) with states; allChildrenConfirmed false; its own history untouched',J(pg.ticket.children.map(c => [c.key, c.state, c.subject])) === J([['rel.example#2', 'open', 'piece 1/3'], ['rel.example#3', 'open', 'piece 2/3'], ['rel.example#4', 'open', 'piece 3/3']])&& pg.ticket.allChildrenConfirmed === false && pg.ticket.parent === null && pg.events.length === 1 && pg.ticket.state === 'open', J(pg.ticket));const relList = (await api('GET', '/api/projects/rel.example/tickets')).json.tickets;const rowOf = (n) => relList.find(t => t.number === n);check('#4 api: the LIST rows carry parent / children / blockedBy / blocks too', rowOf(1).children.length === 3 && rowOf(2).parent.key === 'rel.example#1' && J(rowOf(5).blocks) === '[]', J(relList.map(t => [t.number, t.parent, t.children.length])));const ROW_KEYS = J(['allChildrenConfirmed', 'apiHref', 'blockedBy', 'blocks', 'children', 'created', 'createdMs', 'events', 'hasOld', 'href', 'id', 'number', 'oldRef', 'parent', 'project', 'projectHref', 'ref', 'source', 'state', 'stateSlug', 'subject', 'summary', 'updated', 'updatedMs']);check('#4 api: a row = the old keys + exactly parent, children, blockedBy, blocks, allChildrenConfirmed; a ref has id/project/number/ref/key/subject/state/stateSlug/href/apiHref',relList.every(t => J(Object.keys(t).sort()) === ROW_KEYS) && J(Object.keys(rowOf(2).parent).sort()) === J(['apiHref', 'href', 'id', 'key', 'number', 'project', 'ref', 'state', 'stateSlug', 'subject']), J(Object.keys(relList[0]).sort()));// refused parent writes: nothing writtenconst relBefore = J((await api('GET', '/api/projects/rel.example/tickets')).json);for (const [label, path, body, token, want, field] of [['no token', RP(2) + '/parent', { parent: '' }, null, 401, null],['bob (author of neither)', RP(2) + '/parent', { parent: '' }, bobTok2.value.token, 403, null],['bob setting a parent he does not own on a ticket he does not own', RP(3) + '/parent', { parent: 'gamma#1' }, bobTok2.value.token, 403, null],['itself', RP(1) + '/parent', { parent: 'rel.example#1' }, TOKEN, 400, 'parent'],['an unknown ticket', RP(2) + '/parent', { parent: 'rel.example#99' }, TOKEN, 400, 'parent'],['no project#number', RP(2) + '/parent', { parent: 'nonsense' }, TOKEN, 400, 'parent'],['a loop (P under its own child)', RP(1) + '/parent', { parent: 'rel.example#2' }, TOKEN, 400, 'parent'],['the same parent again', RP(2) + '/parent', { parent: 'rel.example#1' }, TOKEN, 400, 'parent'],['removing a parent that is not there', RP(1) + '/parent', { parent: '' }, TOKEN, 400, 'parent'],['no parent field {}', RP(2) + '/parent', {}, TOKEN, 400, 'parent'],['an unknown field', RP(2) + '/parent', { parent: 'rel.example#1', child: 'x' }, TOKEN, 400, 'child'],['a non-string', RP(2) + '/parent', { parent: 1 }, TOKEN, 400, 'parent'],['an author field', RP(2) + '/parent', { parent: 'rel.example#1', author: 'x' }, TOKEN, 400, 'author'],['an unknown ticket in the URL', RP(99) + '/parent', { parent: 'rel.example#1' }, TOKEN, 404, null],]) {const r = await api('POST', path, body, token);check(`#4 api: parent refused — ${label} → ${want}${field ? ' naming ' + field : ''}`, r.status === want && r.json && typeof r.json.error === 'string' && (field === null || r.json.field === field), r.status + ' ' + J(r.json));}check('#4 api: the refused parent writes wrote nothing', J((await api('GET', '/api/projects/rel.example/tickets')).json) === relBefore);// who may: bob (author of X) may put X under the gate's P and take it out again; the creator may// link a ticket from before the login; a legacy ticket + a ticket bob does not own → 403 for bobconst bx1 = await api('POST', RP(5) + '/parent', { parent: 'rel.example#1' }, bobTok2.value.token);const bx2 = await api('POST', RP(5) + '/parent', { parent: '' }, bobTok2.value.token);check('#4 who: bob (author of the child only) sets and removes its parent (201, 201; label "removed the parent rel.example #1")', bx1.status === 201 && bx2.status === 201 && bx2.json.event.label === 'removed the parent rel.example #1' && bx2.json.ticket.parent === null, J([bx1.json, bx2.json]));const relLegBob = await api('POST', PT('alpha', 3) + '/parent', { parent: 'gamma#1' }, bobTok2.value.token);const legAliceP = await api('POST', PT('alpha', 3) + '/parent', { parent: 'gamma#1' }, aliceTok);const legAliceR = await api('POST', PT('alpha', 3) + '/parent', { parent: '' }, aliceTok);check('#4 who: a legacy ticket under a ticket bob does not own → 403 for bob; the creator (alice) may (201) and removes it again', relLegBob.status === 403 && legAliceP.status === 201 && legAliceP.json.ticket.parent.key === 'gamma#1' && legAliceR.status === 201, J([relLegBob, legAliceP.status, legAliceR.status]));const rep1 = await api('POST', RP(4) + '/parent', { parent: 'rel.example#5' });check('#4 api: replacing a parent → label names the old one ("… (was rel.example #1)"); P has two children, X one', rep1.status === 201 && rep1.json.event.label === 'set the parent to rel.example #5 (was rel.example #1)' && (await api('GET', RP(1))).json.ticket.children.length === 2 && (await api('GET', RP(5))).json.ticket.children.length === 1, J(rep1.json));await api('POST', RP(4) + '/parent', { parent: 'rel.example#1' });// grandchild loop: X under C1, then P under X → P would be its own ancestorawait api('POST', RP(5) + '/parent', { parent: 'rel.example#2' }, bobTok2.value.token);const loop2 = await api('POST', RP(1) + '/parent', { parent: 'rel.example#5' });check('#4 api: a loop through a grandchild (P → X → C1 → P) is refused', loop2.status === 400 && /loop/.test(loop2.json.error), J(loop2));await api('POST', RP(5) + '/parent', { parent: '' }, bobTok2.value.token);// BLOCKED BYconst b1 = await api('POST', RP(2) + '/blocked-by', { add: 'rel.example#5' });check('#4 api: POST …/blocked-by {add} → 201 "marked it blocked by rel.example #5"; blockedBy on C1, blocks on X, with states',b1.status === 201 && b1.json.event.label === 'marked it blocked by rel.example #5' && b1.json.event.linkKind === 'blockedBy' && J(b1.json.ticket.blockedBy.map(r => [r.key, r.state])) === J([['rel.example#5', 'open']])&& J((await api('GET', RP(5))).json.ticket.blocks.map(r => [r.key, r.state])) === J([['rel.example#2', 'open']]), J(b1));const b2 = await api('POST', '/api/tickets/' + C2.id + '/blocked-by', { add: 'rel.example#2' });check('#4 api: C2 blocked by C1 (UUID route) → 201', b2.status === 201, J(b2));const bBefore = J((await api('GET', '/api/projects/rel.example/tickets')).json);for (const [label, path, body, token, want, field] of [['no token', RP(2) + '/blocked-by', { add: 'rel.example#4' }, null, 401, null],['bob (author of neither)', RP(3) + '/blocked-by', { add: 'rel.example#4' }, bobTok2.value.token, 403, null],['itself', RP(2) + '/blocked-by', { add: 'rel.example#2' }, TOKEN, 400, 'add'],['twice', RP(2) + '/blocked-by', { add: 'rel.example#5' }, TOKEN, 400, 'add'],['a direct loop (X blocked by C1)', RP(5) + '/blocked-by', { add: 'rel.example#2' }, bobTok2.value.token, 400, 'add'],['a loop over two (X blocked by C2 → C1 → X)', RP(5) + '/blocked-by', { add: 'rel.example#3' }, bobTok2.value.token, 400, 'add'],['add AND remove', RP(2) + '/blocked-by', { add: 'rel.example#4', remove: 'rel.example#5' }, TOKEN, 400, null],['neither {}', RP(2) + '/blocked-by', {}, TOKEN, 400, 'add'],['an empty add', RP(2) + '/blocked-by', { add: ' ' }, TOKEN, 400, 'add'],['an unknown ticket', RP(2) + '/blocked-by', { add: 'nope#1' }, TOKEN, 400, 'add'],['removing one that does not block', RP(2) + '/blocked-by', { remove: 'rel.example#4' }, TOKEN, 400, 'remove'],['an unknown field', RP(2) + '/blocked-by', { blocker: 'rel.example#4' }, TOKEN, 400, 'blocker'],['an unknown ticket in the URL', '/api/tickets/nope/blocked-by', { add: 'rel.example#4' }, TOKEN, 404, null],]) {const r = await api('POST', path, body, token);check(`#4 api: blocked-by refused — ${label} → ${want}${field ? ' naming ' + field : ''}`, r.status === want && r.json && typeof r.json.error === 'string' && (field === null || r.json.field === field), r.status + ' ' + J(r.json));}check('#4 api: the refused blocked-by writes wrote nothing', J((await api('GET', '/api/projects/rel.example/tickets')).json) === bBefore);const bRm = await api('POST', RP(3) + '/blocked-by', { remove: 'rel.example#2' });check('#4 api: {remove} → 201 "removed the blocker rel.example #2"; C1 blocks nothing any more', bRm.status === 201 && bRm.json.event.label === 'removed the blocker rel.example #2' && J(bRm.json.ticket.blockedBy) === '[]' && J((await api('GET', RP(2))).json.ticket.blocks) === '[]', J(bRm.json));check('#4 JSON: non-link events still carry exactly the old keys (P\'s history)', (await api('GET', RP(1))).json.events.every(e => J(Object.keys(e).sort()) === EVENT_KEYS));// the Markdown read viewconst mdC1 = await mdGet(RP(2));check('#4 read view: a child says "Parent: …" and "Blocked by:" with states and URLs; its history names the link',mdC1.text.includes(`\n${BASE}/projects/rel.example/2\n\nParent: rel.example #1 [open] Parent: rebuild it in pieces · ${BASE}/projects/rel.example/1\n\nBlocked by:\n- rel.example #5 [open] bob's blocker · ${BASE}/projects/rel.example/5\n`)&& /\n### 2 · [^\n]* · gate set the parent to rel\.example #1\n/.test(mdC1.text) && /\n### 3 · [^\n]* · gate marked it blocked by rel\.example #5\n/.test(mdC1.text), mdC1.text);const mdP = await mdGet(RP(1));check('#4 read view: the parent lists "Children (0 of 3 confirmed):" with one line per child', mdP.text.includes(`\nChildren (0 of 3 confirmed):\n- rel.example #2 [open] piece 1/3 · ${BASE}/projects/rel.example/2\n- rel.example #3 [open] piece 2/3 · ${BASE}/projects/rel.example/3\n- rel.example #4 [open] piece 3/3 · ${BASE}/projects/rel.example/4\n`), mdP.text);const mdX = await mdGet(RP(5));check('#4 read view: the blocker says "Blocks:"', mdX.text.includes(`\nBlocks:\n- rel.example #2 [open] piece 1/3 · ${BASE}/projects/rel.example/2\n`), mdX.text);const mdRel = (await mdGet('/api/projects/rel.example/tickets')).text;check('#4 read view: list lines carry " · parent …", " · children 3, 0 confirmed", " · blocked by … [state]", " · blocks …"',mdRel.includes(`/projects/rel.example/1 · children 3, 0 confirmed\n`) && mdRel.includes(`/projects/rel.example/2 · parent rel.example #1 · blocked by rel.example #5 [open]\n`) && mdRel.includes(`/projects/rel.example/5 · blocks rel.example #2\n`), mdRel);// ---- in the browsers: B (bob) watches P, A (alice, the creator) works on the children --------const PP = '/projects/rel.example/1';await b.goto(BASE + PP);await b.waitForSelector('#children li');await connected(b, 'B on P');await b.evaluate('window.__relMarker = 1');const pView = await b.evaluate(`(() => ({ kids: [...document.querySelectorAll('#children li')].map(li => [li.querySelector('ticket-state').textContent, li.querySelector('a').textContent, li.querySelector('a').getAttribute('href'), li.querySelector('.refsubject').textContent]), note: document.querySelector('#childnote').textContent, done: document.querySelector('#childnote').classList.contains('done'), parent: !!document.querySelector('#parent') }))()`);check('#4 page: the parent lists its three children (state, ref link, subject) and "0 of 3 children confirmed"',J(pView.kids) === J([['open', 'rel.example #2', '/projects/rel.example/2', 'piece 1/3'], ['open', 'rel.example #3', '/projects/rel.example/3', 'piece 2/3'], ['open', 'rel.example #4', '/projects/rel.example/4', 'piece 3/3']]) && pView.note === '0 of 3 children confirmed' && !pView.done && !pView.parent, J(pView));const anonP = await (await fetch(BASE + PP)).text();check('#4 page: signed out the relations are shown, the relation forms are not', anonP.includes('id="children"') && !anonP.includes('id="parentform"') && !anonP.includes('id="blockerform"'));await a.goto(BASE + '/projects/rel.example/3');await a.waitForSelector('#parent');await connected(a, 'A on C2');check('#4 page: a child shows "Part of rel.example #1 <subject> <state>" linking to the parent', (await a.text('#parent')).replace(/\s+/g, ' ') === 'Part of rel.example #1 Parent: rebuild it in pieces open' && (await a.evaluate('document.querySelector("#parent a").getAttribute("href")')) === PP);// A removes C2's parent with the web button → B's parent page drops it liveawait a.click('#parentremove');await a.waitFor('!document.querySelector("#parent") && !document.querySelector("#parentremove")', { label: 'A parent removed' });await b.waitFor('document.querySelectorAll("#children li").length === 2', { label: 'B children 2 live' });check('#4 live: "Remove parent" (alice) → C2 has no parent, B\'s parent page shows 2 children, "0 of 2", no reload', (await b.text('#childnote')) === '0 of 2 children confirmed' && (await b.evaluate('window.__relMarker')) === 1 && (await a.text('#events > li:last-child event-head')).includes('alice removed the parent rel.example #1'));// A sets it again by typing into the formawait type(a, '#parentkey', 'rel.example#1');await a.click('#parentsave');await a.waitFor('!!document.querySelector("#parent")', { label: 'A parent set' });await b.waitFor('document.querySelectorAll("#children li").length === 3', { label: 'B children 3 live' });check('#4 live: "Set parent" (typed rel.example#1) → C2 is "Part of rel.example #1" again, B sees 3 children (C2 last), the input is cleared', (await b.text('#children li:last-child a')) === 'rel.example #3' && (await a.evaluate('document.querySelector("#parentkey").value')) === '' && (await b.evaluate('window.__relMarker')) === 1);// refused on the page: an unknown ticket, a loopawait type(a, '#parentkey', 'rel.example#99');await a.click('#parentsave');await a.waitFor('document.querySelector("#linkmessage").textContent.includes("no such ticket: rel.example#99")', { label: 'A unknown parent refused' });check('#4 page: an unknown parent is refused with a message, nothing written', (await api('GET', RP(3))).json.ticket.parent.key === 'rel.example#1');// blocked by from the web: C2 blocked by X; B watches X's page ("Blocks")await b.goto(BASE + '/projects/rel.example/5');await b.waitForSelector('#blocks li');await connected(b, 'B on X');check('#4 page: X shows "Blocks" rel.example #2 (state, link)', J(await texts(b, '#blocks li a')) === J(['rel.example #2']) && !(await b.evaluate('!!document.querySelector("#blockedby")')));await type(a, '#blockerkey', 'rel.example#5');await a.click('#blockeradd');await a.waitFor('!!document.querySelector("#blockedby li")', { label: 'A blocker added' });await b.waitFor('document.querySelectorAll("#blocks li").length === 2', { label: 'B blocks 2 live' });check('#4 live: "Add blocker" on C2 → A lists "Blocked by rel.example #5 open", B\'s X page "Blocks" #2 and #3', J(await texts(a, '#blockedby li a')) === J(['rel.example #5']) && (await a.text('#blockedby li ticket-state')) === 'open' && J(await texts(b, '#blocks li a')) === J(['rel.example #2', 'rel.example #3']));// bob may not change C2's blockers (author of X — the blocker — so he MAY remove this one); on C1 he may not add gamma#1const bobC1 = await temit('ticketAddBlocker', [C1.id, 'gamma#1'], bobCookie);check('#4 face: bob adding gamma#1 as a blocker of C1 (neither his) → refused, nothing written', bobC1.value && /only the author of either ticket or the creator/.test(bobC1.value.error) && (await api('GET', RP(2))).json.ticket.blockedBy.length === 1, bobC1.raw);for (const [ev, args] of [['ticketSetParent', [C1.id, '']], ['ticketAddBlocker', [C1.id, 'rel.example#4']], ['ticketRemoveBlocker', [C1.id, 'rel.example#5']]]) {const r = await temit(ev, [...args, forged], null);check(`#4 forged session refused (#31): ${ev}`, framework_refused(r.raw) || (r.value && /log in with ident/.test(r.value.error || '')), r.raw);}// the per-row "Remove" button on the blocked pageawait a.click('#blockedby li .unblock');await a.waitFor('!document.querySelector("#blockedby")', { label: 'A blocker removed' });await b.waitFor('document.querySelectorAll("#blocks li").length === 1', { label: 'B blocks 1 live' });check('#4 live: the "Remove" button → C2 no longer blocked, B\'s X page back to one', (await a.text('#events > li:last-child event-head')).includes('alice removed the blocker rel.example #5'));// PARENT STATE: alice (the creator) confirms the three children → P says "all children confirmed", its state stays openawait b.goto(BASE + PP);await b.waitForSelector('#children li');await connected(b, 'B on P again');await b.evaluate('window.__relMarker = 2');for (const n of [2, 3]) await api('POST', RP(n) + '/state', { state: 'confirmed' }, aliceTok);await b.waitFor('document.querySelector("#childnote").textContent === "2 of 3 children confirmed"', { label: 'B 2 of 3' });check('#4 live: a child\'s state change reaches the parent page ("2 of 3 children confirmed", badges follow; C2 last since its link was re-made)', J(await b.evaluate(`[...document.querySelectorAll('#children li')].map(li => [li.querySelector('a').textContent, li.querySelector('ticket-state').textContent, li.querySelector('ticket-state').className])`)) === J([['rel.example #2', 'confirmed', 'confirmed'], ['rel.example #4', 'open', 'open'], ['rel.example #3', 'confirmed', 'confirmed']]), J(await texts(b, '#children li')) + ' ' + await b.evaluate('document.querySelector("#children").outerHTML'));await a.goto(BASE + '/projects/rel.example/4');await a.waitForSelector('#stateform');await connected(a, 'A on C3');await choose(a, '#newstate', 'confirmed');await a.click('#statesend');await b.waitFor('document.querySelector("#childnote").classList.contains("done")', { label: 'B all confirmed' });const pAll = (await api('GET', RP(1))).json;check('#4 parent state: all children confirmed → the page says "all children confirmed (3)" (green), P itself stays open (no auto-confirm), no reload',(await b.text('#childnote')) === 'all children confirmed (3)' && (await b.evaluate('getComputedStyle(document.querySelector("#childnote")).color === getComputedStyle(document.querySelector("#children ticket-state.confirmed")).color')) && (await b.text('#state')) === 'open' && pAll.ticket.state === 'open' && pAll.ticket.allChildrenConfirmed === true && (await b.evaluate('window.__relMarker')) === 2,(await b.text('#childnote')) + ' ' + (await b.evaluate('getComputedStyle(document.querySelector("#childnote")).color')));check('#4 read view: "Children (all 3 confirmed):" and the list line " · children 3, all confirmed"', (await mdGet(RP(1))).text.includes('\nChildren (all 3 confirmed):\n') && (await mdGet('/api/projects/rel.example/tickets')).text.includes('/projects/rel.example/1 · children 3, all confirmed\n'));// screenshots: the parent with its children (B), a blocked ticket (C1, A) at 390 / 1280await a.goto(BASE + '/projects/rel.example/2');await a.waitForSelector('#blockedby li');for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(b, w, h);check(`layout ${name} ${w}px: the parent page with its children has no horizontal overflow`, await noOverflow(b));await shot(b, `${name}-parent`);await viewport(a, w, h);check(`layout ${name} ${w}px: the blocked child page (parent, blocked by, forms) has no horizontal overflow`, await noOverflow(a));await shot(a, `${name}-blocked`);}await viewport(a, 1280, 900);await viewport(b, 1280, 900);// ---- a new ticket from the web form; the other list follows --------------------------await b.goto(BASE + '/');await b.waitForSelector('#tickets li');await connected(b, 'B on list');await a.goto(BASE + '/');await a.waitForSelector('#tickets li');await connected(a, 'A on list');await sleep(300);await a.click('#newticket summary');check('new: the form has no author field (ticket #7: the author is the login)', await a.evaluate('!document.querySelector("#newauthor") && !document.querySelector("#newticketform [name=author]")'));await type(a, '#newproject', 'hybriel');await type(a, '#newsubject', 'A ticket from the web form');await type(a, '#newsummary', 'made by the gate');await a.click('#newsubmit');await a.waitFor('!!document.querySelector("#notice")', { label: 'A notice' });check('new: the form says which ticket it opened (hybriel #1)', (await a.text('#notice')).startsWith('Opened hybriel #1.') && (await a.evaluate('document.querySelector("#noticelink").getAttribute("href")')) === '/projects/hybriel/1', await a.text('#notice'));check('new: A\'s list has it on top', (await a.text('#tickets li:first-child a.subject')).includes('A ticket from the web form'));const NOW = (await api('GET', '/api/tickets')).json.tickets.length; // TOTAL + the tickets of the #8/#6 part + this oneawait b.waitFor(`document.querySelectorAll("#tickets li").length === ${NOW}`, { label: 'B list grows' });check('live: B\'s list got the new ticket on top without a reload', (await b.text('#tickets li:first-child a.subject')).includes('#1'));check('live: B\'s pushed row links to /projects/hybriel/1', (await b.evaluate('document.querySelector("#tickets li:first-child a.subject").getAttribute("href")')) === '/projects/hybriel/1' && (await b.text('#tickets li:first-child ticket-meta a')) === 'hybriel');const six = await api('GET', PT('hybriel', 1));check('new: stored with project, summary and the login as author (alice)', six.json.ticket.project === 'hybriel' && six.json.ticket.summary === 'made by the gate' && six.json.events[0].author === 'alice', J(six.json));await clickNav(a, '#noticelink', 'location.pathname === "/projects/hybriel/1" && !!document.querySelector("#subject")', 'notice link');check('new: the notice link opens the ticket', (await a.text('#subject')) === 'A ticket from the web form');// ---- import: paragraphs (blank line = new paragraph, CRLF file) --------------------------const PARA = join(SCRATCH, 'gate-import');rmSync(PARA, { recursive: true, force: true });mkdirSync(PARA, { recursive: true });writeFileSync(join(PARA, '0100-paragraphs.md'), ['project: hybriel', 'subject: Paragraph import', '', 'First paragraph, line one', ' line two.', '', '', 'Second paragraph', 'line two', 'line three.', ''].join('\r\n'));const runImportFrom = (dir) => { const r = spawnSync(BIN, ['import.hl'], { cwd: APP, env: { ...process.env, TICKETS_URL: BASE, TICKETS_IMPORT_DIR: dir, TICKETS_TOKEN: TOKEN }, encoding: 'utf8', timeout: 60000 }); return (r.stdout || '') + (r.stderr || ''); };const impP = runImportFrom(PARA);check('import: a paragraph file becomes hybriel #2', /NEW 0100-paragraphs\.md → hybriel #2/.test(impP) && /1 new, 0 already there, 0 failed/.test(impP), impP);const seven = await api('GET', PT('hybriel', 2));check('import: hard wraps joined, blank line = new paragraph (stored "…\\n\\n…")', seven.json.ticket.summary === 'First paragraph, line one line two.\n\nSecond paragraph line two line three.', J(seven.json.ticket.summary));await a.goto(BASE + '/projects/hybriel/2');await a.waitForSelector('#summary');// ticket #6: the summary is Markdown — a blank line = a new <p>, the two sit apartcheck('ticket: the summary renders two paragraphs (two <p>, apart)', await a.evaluate(`(() => { const ps = [...document.querySelectorAll('#summary markdown-text > p')]; if (ps.length !== 2 || ps[0].textContent !== 'First paragraph, line one line two.' || ps[1].textContent !== 'Second paragraph line two line three.') return false; const a = ps[0].getBoundingClientRect(); const b = ps[1].getBoundingClientRect(); return b.top - a.bottom > 4; })()`), await a.evaluate('document.querySelector("#summary").outerHTML'));await viewport(a, 390, 844);await shot(a, 'phone-paragraphs');await viewport(a, 1280, 900);// ---- import: Markdown list lines stay separate lines (ticket #15) ------------------------const LISTS = join(SCRATCH, 'gate-import-lists');rmSync(LISTS, { recursive: true, force: true });mkdirSync(LISTS, { recursive: true });writeFileSync(join(LISTS, '0101-lists.md'), ['project: hybriel', 'subject: List import', '', 'Intro line one', 'wrapped.', '- first item', ' wrapped item.', '* second item', '1. numbered one', '12. numbered twelve', '', 'After the list, a paragraph', '3.5 kg stays joined,', '-not a list either.', ''].join('\r\n'));const impL = runImportFrom(LISTS);check('import: a list file becomes hybriel #3', /NEW 0101-lists\.md → hybriel #3/.test(impL) && /1 new, 0 already there, 0 failed/.test(impL), impL);const LIST_SUMMARY = 'Intro line one wrapped.\n- first item wrapped item.\n* second item\n1. numbered one\n12. numbered twelve\n\nAfter the list, a paragraph 3.5 kg stays joined, -not a list either.';const eight = await api('GET', PT('hybriel', 3));check('import: list lines kept as lines, wrapped lines still joined', eight.json.ticket.summary === LIST_SUMMARY, J(eight.json.ticket.summary));await a.goto(BASE + '/projects/hybriel/3');await a.waitForSelector('#summary');// ticket #6: the stored lines are Markdown — the intro paragraph, a bullet list (2), a numbered// list (2), the closing paragraph ("3.5 kg" and "-not" are no list items)const listLines = await a.evaluate(`(() => { const s = document.querySelector('#summary markdown-text'); return { kids: [...s.children].map(e => e.tagName.toLowerCase() + (e.matches('ul,ol') ? ':' + [...e.children].map(li => li.textContent).join('|') : '')), last: s.lastElementChild.textContent }; })()`);check('ticket: the imported list renders as Markdown lists (p, ul 2, ol 2, p)', J(listLines.kids) === J(['p', 'ul:first item wrapped item.|second item', 'ol:numbered one|numbered twelve', 'p']) && listLines.last === 'After the list, a paragraph 3.5 kg stays joined, -not a list either.', J(listLines));await viewport(a, 390, 844);await shot(a, 'phone-lists');await viewport(a, 1280, 900);// ---- layout: phone and desktop ------------------------------------------------------for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(b, w, h);await b.goto(BASE + '/');await b.waitForSelector('#tickets li');check(`layout ${name} ${w}px: list has no horizontal overflow`, await noOverflow(b), await b.evaluate('document.documentElement.scrollWidth'));await shot(b, `${name}-list`);await b.goto(BASE + IDENT1);await b.waitForSelector('#events li');check(`layout ${name} ${w}px: ticket page has no horizontal overflow`, await noOverflow(b), await b.evaluate('document.documentElement.scrollWidth'));await shot(b, `${name}-ticket`);await b.goto(BASE + '/tickets/1');await b.waitForSelector('#oldref');check(`layout ${name} ${w}px: migrated ticket (via old URL) has no horizontal overflow`, await noOverflow(b) && (await b.evaluate('location.pathname')) === '/projects/alpha/1');await shot(b, `${name}-legacy`);await b.goto(BASE + '/inbox');await b.waitForSelector('#tickets li');check(`layout ${name} ${w}px: inbox has no horizontal overflow`, await noOverflow(b));await shot(b, `${name}-inbox`);}await viewport(b, 1280, 900);await b.goto(BASE + '/');await b.waitForSelector('#tickets li');check('layout desktop: a ticket row is one line (state | subject | meta side by side)', await b.evaluate(`(() => { const li = document.querySelector('#tickets li'); const s = li.querySelector('ticket-state').getBoundingClientRect(); const m = li.querySelector('ticket-meta').getBoundingClientRect(); return Math.abs(s.top - m.top) < 12; })()`));await viewport(b, 390, 844);await b.goto(BASE + '/');await b.waitForSelector('#tickets li');await b.click('#newticket summary');await b.waitFor('document.querySelector("#newticket").open', { label: 'form open' });check('layout phone: the open new-ticket form has no horizontal overflow', await noOverflow(b));await shot(b, 'phone-newticket');check('layout phone: a ticket row stacks (meta below the state)', await b.evaluate(`(() => { const li = document.querySelector('#tickets li'); const s = li.querySelector('ticket-state').getBoundingClientRect(); const m = li.querySelector('ticket-meta').getBoundingClientRect(); return m.top > s.bottom - 1; })()`));await viewport(b, 1280, 900);// ---- logout on tickets resets the selector (no reload); the session is logged out ----------await a.goto(BASE + IDENT1);await a.waitForSelector('#commentform');await connected(a, 'A before logout');await a.evaluate('window.__logoutMarker = 1');await a.click('#logout');await a.waitFor('!!document.querySelector("#loginhint") && !document.querySelector("#commentform") && !!document.querySelector("#loginbutton")', { label: 'A logged out' });check('logout: no reload, forms gone, selector reset ("choose ident", no logged-in)', (await a.evaluate('window.__logoutMarker')) === 1 && await a.evaluate('!document.querySelector("#selector").hasAttribute("logged-in") && document.querySelector("#selector").loggedIn === false') && /choose/.test(await a.evaluate(sh('r.querySelector("#choose").textContent'))));const afterLogout = await temit('commentOn', [tid, 'after logout'], aliceCookie);check('logout: the session cannot write any more', afterLogout.value && /log in with ident/.test(afterLogout.value.error), afterLogout.raw);// ---- ticket #9: logged in → Log out → the selector → ANOTHER identity (the creator's path) --// hl:webex re-creates the header (and its <script>s) on every login/logout, so login.js RUNS// AGAIN; before mission 012 each run added an `ident-login` listener → after a logout one// choice sent the one-time code twice: the 2nd exchange failed and set the red banner// "ident refused the login (400: unknown or already used code)" although the 1st logged in.const add2 = await (await fetch(ident.base + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie: alice.cookie }, body: J({ t: 'emit', i: 1, event: 'addIdentity', payload: [{ identityName: 'alice two' }] }) })).text();check('#9: alice gets a second identity in ident ("alice two")', /"ok":true/.test(add2) && /alice two/.test(add2), add2.slice(0, 300));const ALICE2_ID = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE, 'alice two'));let ex0 = exchanges.length;await shClick(a, '#choose');await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 1`), { label: 'selector lists two identities' });check('#9: after the logout the selector lists both of alice\'s identities', J((await a.evaluate(sh(`[...r.querySelectorAll('[part~="identity"]')].map(b => b.textContent)`))).sort()) === J(['Default', 'alice two']));await shClick(a, '[part~="identity"]', 'alice two');await a.waitForSelector('#nameform', { timeout: 10000 });await sleep(1500); // a second exchange (the bug) answers well within thislet exNow = exchanges.slice(ex0);check('#9 (ticket page): A → Log out → other identity: no error banner, exactly ONE exchange (200), no reload', !(await a.evaluate('!!document.querySelector("#loginerror")')) && exNow.length === 1 && exNow[0].status === 200 && (await a.evaluate('window.__logoutMarker')) === 1, J(exNow) + ' banner: ' + await a.evaluate('(document.querySelector("#loginerror") || {}).textContent || ""'));await a.type('#displayname', 'alice two');await a.click('#namesave');await a.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#whoami") && document.querySelector("#whoami").textContent === "alice two"', { label: 'A as alice two' });const aCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];let you9 = await (await fetch(BASE + '/you', { headers: { cookie: aCookie } })).text();check('#9: logged in as the OTHER identity (top right "alice two"; /you shows its per-app id, not alice\'s)', you9.includes(ALICE2_ID) && !you9.includes(ALICE_ID) && /logged in with/.test(await a.evaluate(sh('r.querySelector("#status").textContent'))));// the same on the LIST, reached by an in-app navigation, back to the first identityawait a.click('#logout');await a.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#selector").hasAttribute("logged-in")', { label: 'A logged out again' });await clickNav(a, '#navall', 'location.pathname === "/" && !!document.querySelector("#tickets li")', 'A to the list');ex0 = exchanges.length;await shClick(a, '#choose');await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 1`), { label: 'selector list on /' });await shClick(a, '[part~="identity"]', 'Default');await a.waitFor('!!document.querySelector("#whoami") && document.querySelector("#whoami").textContent === "alice"', { label: 'A back as alice', timeout: 10000 });await sleep(1500);exNow = exchanges.slice(ex0);you9 = await (await fetch(BASE + '/you', { headers: { cookie: aCookie } })).text();check('#9 (list, after an in-app navigation): Log out → back to alice: no banner, exactly ONE exchange, logged in as alice, no reload', !(await a.evaluate('!!document.querySelector("#loginerror")')) && exNow.length === 1 && exNow[0].status === 200 && you9.includes(ALICE_ID) && (await a.evaluate('window.__logoutMarker')) === 1, J(exNow));check('#9: login.js installed once in this document although the header was re-created', await a.evaluate('!!window.__ticketsLogin && document.querySelectorAll("ident-selector").length === 1'));await a.click('#logout');await a.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#selector").hasAttribute("logged-in")', { label: 'A logged out (end of #9)' });await a.goto(BASE + IDENT1);await a.waitForSelector('#events li');check('logout: after a reload still logged out', !!(await a.evaluate('!!document.querySelector("#loginhint") && !document.querySelector("#selector").hasAttribute("logged-in")')));// ---- ticket #10: the LOGIN BUTTON returns to the page the login started from ---------------// bob logs out on the list, goes to a ticket by an in-app click (the URL changed by// pushState, not by a load), presses "Log in with ident", chooses on ident → back on THAT pageawait b.goto(BASE + '/');await b.waitForSelector('#logout');await connected(b, 'B before #10');await b.click('#logout');await b.waitForSelector('#loginbutton');await clickNav(b, `#tickets li a.subject[href="${IDENT1}"]`, `location.pathname === ${J(IDENT1)} && !!document.querySelector('#events li')`, 'B to ident #1');await b.click('#loginbutton');await b.waitForSelector('#chooselist', { timeout: 10000 });const signinUrl = await b.evaluate('location.href');check('#10: from the ticket page the button went to ident (/signin/<rid>)', signinUrl.startsWith(ident.base + '/signin/'), signinUrl);await connectedIdent(b);await b.click('#chooselist li:nth-child(1) .choose');await b.waitFor(`location.pathname === ${J(IDENT1)} && !!document.querySelector('#whoami')`, { timeout: 10000, label: 'B back on the ticket' });check('#10: after the button login bob is back on the ticket page he started from, logged in', (await b.evaluate('location.href')) === BASE + IDENT1 && (await b.text('#whoami')) === 'bob' && !(await b.evaluate('!!document.querySelector("#loginerror")')), await b.evaluate('location.href'));// from the list with a query-less filter route tooawait connected(b, 'B on the ticket after #10');await b.click('#logout');await b.waitForSelector('#loginbutton');await b.goto(BASE + '/state/open');await b.waitForSelector('#loginbutton');await b.click('#loginbutton');await b.waitForSelector('#chooselist', { timeout: 10000 });await connectedIdent(b);await b.click('#chooselist li:nth-child(1) .choose');await b.waitFor(`location.pathname === '/state/open' && !!document.querySelector('#whoami')`, { timeout: 10000, label: 'B back on /state/open' });check('#10: from a filtered list (/state/open) back on that list', (await b.evaluate('location.href')) === BASE + '/state/open');// ---- ticket #12 (mission 024): the Markdown editor <md-editor> in every Markdown field -----------// B = bob (logged in). REAL clicks and keys into the editor's shadow DOM; the value is checked in// the textarea (what webex reads) and in the stored ticket; screenshots gate-*-mdeditor.png.const mdR = (id, expr) => `(() => { const t = document.getElementById(${J(id)}); const h = t && t.closest('md-editor'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;const mdClick = async (page, id, sel, end) => {const bx = await page.evaluate(mdR(id, `(() => { const el = r.querySelector(${J(sel)}); if (!el) return null; el.scrollIntoView({ block: 'center' }); const q = el.getBoundingClientRect(); return { x: q.left, y: q.top, w: q.width, h: q.height }; })()`));if (!bx || !bx.w) throw new Error('mdClick: ' + id + ' ' + sel + ' is not laid out');const x = Math.round(end ? bx.x + bx.w - 4 : bx.x + bx.w / 2), y = Math.round(end ? bx.y + bx.h - 8 : bx.y + bx.h / 2);for (const [type, buttons] of [['mouseMoved', 0], ['mousePressed', 1], ['mouseReleased', 0]]) await page.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1, buttons });await sleep(80);};const MDKEY = { Enter: [13, 'Enter', '\r'], End: [35, 'End'], Home: [36, 'Home'], ArrowLeft: [37, 'ArrowLeft'] };const mdPress = async (page, key, mods = 0) => {const [vk, code, text] = MDKEY[key];const t = text && !(mods & 2) ? text : undefined;await page.send('Input.dispatchKeyEvent', { type: t ? 'keyDown' : 'rawKeyDown', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, ...(t ? { text: t, unmodifiedText: t } : {}) });await page.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk });await sleep(30);};const mdType = async (page, text) => {for (const ch of text) {await page.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch });await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });}await sleep(60);};const taVal = (page, id) => page.evaluate(`document.getElementById(${J(id)}).value`);const CTRL = 2;await b.goto(BASE + BT);await b.waitForSelector('#commenttext');await connected(b, 'B on bob\'s ticket (#12)');await sleep(300);const bCookie12 = (await b.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];const ssr12 = await (await fetch(BASE + BT, { headers: { cookie: bCookie12 } })).text();const js12 = await fetch(BASE + '/md-editor.js');check('#12 SSR: comment and state note are plain <textarea>s inside <md-editor> (without JS the textarea shows, as before); /md-editor.js is served',/<md-editor[^>]*>\s*<textarea[^>]*id="commenttext"/.test(ssr12) && /<md-editor[^>]*>\s*<textarea[^>]*id="statenote"/.test(ssr12) && js12.status === 200 && (await js12.text()).includes("customElements.define('md-editor'"), J(ssr12.match(/<md-editor.{0,100}/g)));check('#12: both editors upgraded (toolbar of 10, contenteditable); the textareas stay in their forms, out of sight',await b.evaluate(`['commenttext', 'statenote'].every((id) => { const t = document.getElementById(id); const r = t.closest('md-editor').shadowRoot; return !!r && r.querySelectorAll('[part~=toolbar] button').length === 10 && r.querySelector('[part~=editor]').isContentEditable && !!t.form && getComputedStyle(t.assignedSlot.parentElement).opacity === '0'; })`));const nEv12 = (await api('GET', PT('gamma', 4))).json.events.length;await mdClick(b, 'commenttext', '[part~=editor]');await mdType(b, 'From the **editor** with `code` and a list:');await mdPress(b, 'Enter');await mdType(b, '- one');await mdPress(b, 'Enter');await mdType(b, 'two');const typed12 = await taVal(b, 'commenttext');check('#12 comment: real typing — **x** / `x` format while typing, "- " starts a list; the textarea holds plain Markdown',typed12 === 'From the **editor** with `code` and a list:\n\n- one\n- two' && await b.evaluate(mdR('commenttext', `r.querySelector('[part~=editor] strong').textContent === 'editor' && r.querySelector('[part~=editor] code').textContent === 'code' && r.querySelectorAll('[part~=editor] li').length === 2 && !r.querySelector('[part~=editor]').textContent.includes('**')`)), J(typed12));await mdPress(b, 'Enter', CTRL);await b.waitFor(`document.querySelectorAll('#events > li').length === ${nEv12 + 1}`, { label: 'B comment from the editor' });await sleep(200);const ev12 = (await api('GET', PT('gamma', 4))).json.events.pop();check('#12 comment: Ctrl+Enter sends it — stored as that Markdown, rendered (strong, code, list); the editor is empty again',ev12.text === typed12 && ev12.author === 'bob' && await b.evaluate(`(() => { const t = document.querySelector('#events > li:last-child event-text'); return t.querySelector('strong').textContent === 'editor' && t.querySelector('code').textContent === 'code' && t.querySelectorAll('ul li').length === 2; })()`)&& (await taVal(b, 'commenttext')) === '' && await b.evaluate(mdR('commenttext', `r.querySelector('[part~=editor]').textContent === ''`)), J(ev12));await mdClick(b, 'statenote', '[part~=editor]');await mdType(b, 'Tested on ');await mdClick(b, 'statenote', '[data-tool=bold]');await mdType(b, 'phone');const note12 = await taVal(b, 'statenote');check('#12 state note: toolbar "B" with nothing selected, then typing → **phone**', note12 === 'Tested on **phone**', J(note12));await choose(b, '#newstate', 'awaiting creator');await b.click('#statesend');await b.waitFor(`document.querySelectorAll('#events > li').length === ${nEv12 + 2}`, { label: 'B state note from the editor' });await sleep(200);check('#12 state note: stored as Markdown and rendered bold; the note editor is empty again', (await api('GET', PT('gamma', 4))).json.events.pop().text === 'Tested on **phone**' && (await b.text('#events > li:last-child event-text strong')) === 'phone' && (await taVal(b, 'statenote')) === '');// a paste of hostile rich text into the commentawait mdClick(b, 'commenttext', '[part~=editor]');await b.evaluate(mdR('commenttext', `(() => { const dt = new DataTransfer(); dt.setData('text/html', '<p>Pasted <b>bold</b> <img src=x onerror="window.__xss=12"><script>window.__xss=13</script><a href="javascript:window.__xss=14">bad</a> <a href="https://example.org/ok" onclick="window.__xss=15">ok</a></p>'); dt.setData('text/plain', 'plain'); r.querySelector('[part~=editor]').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true, composed: true })); })()`));await sleep(200);const pasted12 = await taVal(b, 'commenttext');check('#12 paste: rich HTML is reduced to the subset (bold, the https link); image, script, handlers and the javascript: link are gone, nothing ran',pasted12 === 'Pasted **bold** bad [ok](https://example.org/ok)' && await b.evaluate(mdR('commenttext', `!r.querySelector('[part~=editor] img, [part~=editor] script, [part~=editor] [onclick]') && window.__xss === undefined`)), J(pasted12));await mdPress(b, 'Enter', CTRL);await b.waitFor(`document.querySelectorAll('#events > li').length === ${nEv12 + 3}`, { label: 'B pasted comment' });check('#12 paste: the sent comment renders safely (one https link, no img/script, nothing ran)', await b.evaluate(`(() => { const t = document.querySelector('#events > li:last-child event-text'); return t.querySelector('strong').textContent === 'bold' && [...t.querySelectorAll('a')].map((a) => a.getAttribute('href')).join() === 'https://example.org/ok' && !document.querySelector('#events img, #events script') && window.__xss === undefined; })()`));// the author's edit form: the stored summary opens formatted and comes back unchangedconst cur12 = (await api('GET', PT('gamma', 4))).json.ticket.summary;await b.click('#editbutton');await b.waitForSelector('#editform md-editor');await b.waitFor(mdR('editsummary', `!!r.querySelector('[part~=editor] h2')`), { label: 'the edit editor shows the summary' });check('#12 edit: the summary opens formatted (heading, list, em) and the textarea holds the stored Markdown byte for byte',(await taVal(b, 'editsummary')) === cur12 && await b.evaluate(mdR('editsummary', `r.querySelector('[part~=editor] h2').textContent === 'New plan' && r.querySelectorAll('[part~=editor] li').length === 2 && r.querySelector('[part~=editor] li em').textContent === 'two'`)), J([cur12, await taVal(b, 'editsummary')]));for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {await viewport(b, w, h);await b.evaluate(`document.querySelector('#editform').scrollIntoView({ block: 'start' })`);const lay = await b.evaluate(mdR('editsummary', `(() => { const bar = r.querySelector('[part~=toolbar]').getBoundingClientRect(); const bs = [...r.querySelectorAll('[part~=toolbar] button')].map((x) => x.getBoundingClientRect()); return { inside: bar.left >= 0 && bar.right <= window.innerWidth, tap: Math.min(...bs.map((x) => Math.min(x.width, x.height))) }; })()`));check(`#12 layout ${name} ${w}px: the edit form with the editor has no horizontal overflow, toolbar on screen, tap targets ≥ 32px`, (await noOverflow(b)) && lay.inside && lay.tap >= 32, J(lay));await shot(b, `${name}-mdeditor`);}await viewport(b, 1280, 900);await mdClick(b, 'editsummary', '[part~=editor] p:last-child', true);await mdPress(b, 'End', CTRL);await mdType(b, ' Really.');check('#12 edit: typing at the end changes only the last paragraph (the rest stays byte-identical)', (await taVal(b, 'editsummary')) === cur12 + ' Really.', J(await taVal(b, 'editsummary')));await b.click('#editsave');await b.waitFor(`!document.querySelector('#editform')`, { label: 'B saved the edit (#12)' });await sleep(200);check('#12 edit: saved — the stored summary is that Markdown', (await api('GET', PT('gamma', 4))).json.ticket.summary === cur12 + ' Really.');// the new-ticket form on a phone: the toolbar by tapawait viewport(b, 390, 844);await b.goto(BASE + '/');await b.waitForSelector('#newticket');await connected(b, 'B on the list (#12)');await sleep(300);await b.click('#newticket summary');await b.waitFor(mdR('newsummary', `!!r.querySelector('[part~=editor]')`), { label: 'new-ticket editor' });await type(b, '#newproject', 'gamma');await type(b, '#newsubject', 'Opened with the editor on a phone');await mdClick(b, 'newsummary', '[part~=editor]');await mdType(b, 'Needs a fix in ');await mdClick(b, 'newsummary', '[data-tool=code]');await mdType(b, 'store.hl');await mdClick(b, 'newsummary', '[data-tool=code]');await mdType(b, ' soon');const ns12 = await taVal(b, 'newsummary');check('#12 new ticket (390px): toolbar taps → inline code around what was typed in between', ns12 === 'Needs a fix in `store.hl` soon', J(ns12));check('#12 new ticket (390px): no horizontal overflow', await noOverflow(b));await shot(b, 'phone-newticket-mdeditor');await b.click('#newsubmit');await b.waitFor('!!document.querySelector("#notice") && !!document.querySelector("#noticelink")', { label: 'B opened a ticket with the editor' });const nt12 = await api('GET', '/api' + (await b.evaluate('document.querySelector("#noticelink").getAttribute("href")')).replace(/^\/projects\/([^/]+)\/(\d+)$/, '/projects/$1/tickets/$2'));check('#12 new ticket: stored with that Markdown summary', nt12.json && nt12.json.ticket.summary === ns12, J(nt12.json && nt12.json.ticket));await viewport(b, 1280, 900);
Branches
- mainmain branch
Latest commits
- f12fa1bcState of 2026-09-27, before the move to gitoriamre