gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9bfba36a9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre9bfba36a/tests/browser-pre20-parked.txt

84.1 KB

  1. PARKED at ticket #20 (2026-09-26): the browser checks of the pre-roles gate (creator-only confirm, author-only edit, 'awaiting creator' inbox,
  2. relations by author, tokens page, layouts …). They assume the old permission model and state names; port them to the roles, then move them back
  3. into browser.mjs after the '#20' section.
  4. // A changes state → B's badge and history follow, B's shell inbox count appears
  5. check('shell: no inbox count yet', !(await b.evaluate('!!document.querySelector("#inboxcount")')));
  6. await choose(a, '#newstate', 'awaiting creator');
  7. await type(a, '#statenote', 'please test');
  8. await a.click('#statesend');
  9. await a.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'A state changed' });
  10. 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'));
  11. check('state: the badge takes the state colour (purple for awaiting creator)', await a.evaluate('getComputedStyle(document.querySelector("#state")).color') === 'rgb(197, 134, 192)');
  12. await b.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'B state live' });
  13. check('live: B\'s state badge followed', (await b.evaluate('document.querySelector("#state").className')) === 'awaiting-creator');
  14. check('live: B\'s history has the state event', (await texts(b, '#events li')).length === 3);
  15. await b.waitFor('!!document.querySelector("#inboxcount") && document.querySelector("#inboxcount").textContent === "1"', { label: 'B inbox count' });
  16. check('live: B\'s header inbox count shows 1', true);
  17. check('live: B never reloaded', (await b.evaluate('window.__gateMarker')) === 42);
  18. // an API write reaches the open browsers
  19. const apiComment = await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: 'comment via the API' });
  20. check('api: POST comment answers 201 with the event', apiComment.status === 201 && apiComment.json.event.kind === 'comment', J(apiComment));
  21. await a.waitFor('document.querySelectorAll("#events li").length === 4', { label: 'API comment reached A' });
  22. await b.waitFor('document.querySelectorAll("#events li").length === 4', { label: 'API comment reached B' });
  23. 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');
  24. // B opens the inbox (real click on the header link)
  25. await clickNav(b, '#navinbox', 'location.pathname === "/inbox" && document.querySelectorAll("#tickets li").length === 1', 'B inbox');
  26. 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'));
  27. // A moves a MIGRATED ticket into the inbox via its OLD number, then ident #1 out of it via the web
  28. await api('POST', '/api/tickets/3/state', { state: 'awaiting-creator', text: 'done, verified' });
  29. await b.waitFor('document.querySelectorAll("#tickets li").length === 2', { label: 'inbox grows live' });
  30. 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')));
  31. 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');
  32. check('live: header count is 2', (await b.text('#inboxcount')) === '2');
  33. await choose(a, '#newstate', 'confirmed');
  34. await a.click('#statesend');
  35. await b.waitFor('document.querySelectorAll("#tickets li").length === 1', { label: 'inbox shrinks live' });
  36. check('live: inbox drops ident #1 when A (alice, the creator) confirms it', (await b.text('#tickets li a.subject')).includes('Legacy alpha two'));
  37. check('live: header count back to 1', (await b.text('#inboxcount')) === '1');
  38. check('creator: the confirm event is alice\'s', (await api('GET', PT('ident.worldapi.org', 1))).json.events.pop().author === 'alice');
  39. // ---- bob is not the creator: confirm / reject refused (web and API) -------------------------
  40. await clickNav(b, '#tickets li a.subject', 'location.pathname === "/projects/alpha/2" && !!document.querySelector("#stateform")', 'B opens alpha #2');
  41. const alpha2Before = (await api('GET', PT('alpha', 2))).json.events.length;
  42. for (const st of ['confirmed', 'rejected']) {
  43. await choose(b, '#newstate', st);
  44. await b.click('#statesend');
  45. await b.waitFor(`document.querySelector("#message").textContent.includes(${J('only the creator can set a ticket to ' + st)})`, { label: 'B refused ' + st });
  46. 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));
  47. }
  48. check('not creator: nothing was written', (await api('GET', PT('alpha', 2))).json.events.length === alpha2Before);
  49. await choose(b, '#newstate', 'in progress');
  50. await b.click('#statesend');
  51. await b.waitFor('document.querySelector("#state").textContent === "in progress"', { label: 'B other state' });
  52. check('not creator: bob may set other states (in progress, author bob)', (await b.text('#events li:last-child')).includes('bob changed the state'));
  53. await choose(b, '#newstate', 'awaiting creator');
  54. await b.click('#statesend');
  55. await b.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'B back to awaiting' });
  56. // ---- ticket #19: the creator's comment ANSWERS a ticket waiting for him; another user's does not ----
  57. await type(b, '#commenttext', 'bob asks again');
  58. await b.click('#commentsend');
  59. 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' });
  60. check('#19: a non-creator comment leaves "awaiting creator" as it is', (await b.text('#state')) === 'awaiting creator');
  61. await a.goto(BASE + '/projects/alpha/2');
  62. await a.waitForSelector('#commenttext');
  63. await connected(a, 'A alpha #2');
  64. await type(a, '#commenttext', 'alice answers');
  65. await a.click('#commentsend');
  66. await b.waitFor('document.querySelector("#state").textContent === "answered"', { label: 'B sees answered' });
  67. const ans19 = (await api('GET', PT('alpha', 2))).json;
  68. const last19 = ans19.events.slice(-2);
  69. 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));
  70. await b.waitFor('document.querySelector("#state").textContent === "answered"');
  71. await choose(b, '#newstate', 'awaiting creator');
  72. await b.click('#statesend');
  73. await b.waitFor('document.querySelector("#state").textContent === "awaiting creator"', { label: 'asker sets awaiting again' });
  74. check('#19: the asker can set "awaiting creator" again after an answer', (await b.text('#inboxcount')) === '1');
  75. // ---- bob's API token on /you: shown once → API write as bob → revoke → 401 -----------------
  76. await clickNav(b, '#whoami', 'location.pathname === "/you" && !!document.querySelector("#tokenform")', 'B /you');
  77. const bobId = (await b.text('#youidentity')).trim();
  78. 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")')));
  79. await b.type('#tokenlabel', 'bob laptop');
  80. await b.click('#tokencreate');
  81. await b.waitForSelector('#newtoken');
  82. const BOB_TOKEN = (await b.text('#newtoken')).trim();
  83. 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']));
  84. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  85. await viewport(b, w, h);
  86. check(`layout ${name} ${w}px: /you with the new token has no horizontal overflow`, await noOverflow(b));
  87. await shot(b, `${name}-tokens`);
  88. }
  89. await viewport(b, 1280, 900);
  90. await b.click('#tokendone');
  91. await b.waitFor('!document.querySelector("#newtoken")');
  92. await b.goto(BASE + '/you');
  93. await b.waitForSelector('#tokenlist li');
  94. // mission 017: the Revoke click below needs the hydrated page (a click on the bare SSR button
  95. // did nothing once: "waitFor timed out: selector #notokens", .scratch/m017/gate-dev-4.txt)
  96. await connected(b, 'B /you after the reload');
  97. 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);
  98. const asBob = await api('POST', PT('alpha', 2) + '/comments', { text: 'written with bob\'s token' }, BOB_TOKEN);
  99. check('token: an API write with it acts as bob (201, author bob)', asBob.status === 201 && asBob.json.event.author === 'bob', J(asBob));
  100. const bobConfirm = await api('POST', PT('alpha', 2) + '/state', { state: 'confirmed' }, BOB_TOKEN);
  101. check('token: bob\'s token cannot confirm (403 naming state)', bobConfirm.status === 403 && bobConfirm.json.field === 'state', J(bobConfirm));
  102. await b.click('#tokenlist li .revoke');
  103. await b.waitForSelector('#notokens');
  104. check('token: revoked on the page (the list is empty)', (await texts(b, '#tokenlist li')).length === 0);
  105. const afterRevoke = await api('POST', PT('alpha', 2) + '/comments', { text: 'after the revoke' }, BOB_TOKEN);
  106. 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));
  107. const bobTok2 = await temit('tokenCreate', ['second'], bobCookie);
  108. const aliceRevokesBob = await temit('tokenRevoke', [bobTok2.value.tokens[0].id], aliceCookie);
  109. 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);
  110. // alice's /you: she is the creator; the id is the one ident gave tickets for her identity
  111. await a.goto(BASE + '/you');
  112. await a.waitForSelector('#youidentity');
  113. 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')));
  114. // ---- #31: a forged trailing session argument is never a session -----------------------------
  115. const forged = { id: 'x', user: { id: ALICE_UID }, data: { tag: 'x' } };
  116. const tid = (await api('GET', PT('alpha', 2))).json.ticket.id;
  117. const beforeForge = J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events]));
  118. for (const [ev, args, re] of [
  119. ['openTicket', ['alpha', 'forged', ''], /log in with ident/], ['commentOn', [tid, 'forged'], /log in with ident/],
  120. ['setTicketState', [tid, 'confirmed', ''], /log in with ident/], ['tokenCreate', ['forged'], /log in with ident/],
  121. ['tokenRevoke', ['x'], /log in with ident/], ['saveDisplayName', ['mallory'], /log in with ident/],
  122. ['identLogin', ['ab'.repeat(24)], /no session/], ['logOut', [], /no session/],
  123. ]) {
  124. const r = await temit(ev, [...args, forged], null);
  125. check(`forged session refused (#31): ${ev}`, framework_refused(r.raw) || (r.value && re.test(r.value.error || '')), r.raw);
  126. }
  127. const yd = await temit('youData', [forged], null);
  128. check('forged session refused (#31): youData answers nothing', yd.value === null || yd.value === undefined, yd.raw);
  129. check('forged: nothing was written', J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events])) === beforeForge);
  130. // ---- ticket #8 (mission 014): the AUTHOR edits subject + summary; history keeps the old ----
  131. // ---- ticket #6: Markdown rendered safely; `Accept: text/markdown` read view -----------------
  132. const MD_RICH = [
  133. '# Plan', '',
  134. 'Some *em*, **strong**, `inline <code>` and a [safe link](https://example.org/a?b=1).',
  135. 'Second line of the same paragraph, see https://example.org/x.', '',
  136. '- item one', '- item **two**', '',
  137. '1. first', '2. second', '',
  138. '```', '<script>window.__xss = 1</script>', '```', '',
  139. '<img src=x onerror="window.__xss=2"> <script>window.__xss=3</script>',
  140. // the old hl:webex </script> hole (mission 014; hl:web escapes the seed itself, hybriel#34): this
  141. // line used to END the page's seed <script> and run as HTML
  142. '</script><img src=x onerror=window.__xss=5>',
  143. '[bad](javascript:window.__xss=4) [data](data:text/html,x) [ok](/projects/gamma/1)',
  144. ].join('\n');
  145. const gm = await api('POST', '/api/projects/gamma/tickets', { subject: 'Markdown and edits', summary: MD_RICH });
  146. 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));
  147. const GM = PT('gamma', 3);
  148. const e1 = await api('POST', GM + '/edit', { subject: 'Edited subject' });
  149. check('#8 api: the author edits the subject (201, an "edit" event with the previous values, summary kept)',
  150. 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'
  151. && 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));
  152. const e2 = await api('POST', '/api/tickets/' + gm.json.ticket.id + '/edit', { summary: MD_RICH + '\n\nAdded **later**.' });
  153. 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));
  154. const gmHist = (await api('GET', GM)).json;
  155. 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));
  156. const gmBefore = J((await api('GET', GM)).json);
  157. const aliceTok = (await temit('tokenCreate', ['alice gate'], aliceCookie)).value.token;
  158. for (const [label, body, token, want, field] of [
  159. ['no token', { subject: 'x' }, null, 401, null],
  160. ['bob\'s token (not the author)', { subject: 'bob was here' }, bobTok2.value.token, 403, null],
  161. ['alice\'s token (the creator, but not the author)', { subject: 'alice was here' }, aliceTok, 403, null],
  162. ['an empty body {}', {}, TOKEN, 400, ''],
  163. ['an empty subject', { subject: ' ' }, TOKEN, 400, 'subject'],
  164. ['the same values (nothing changed)', { subject: 'Edited subject' }, TOKEN, 400, 'subject'],
  165. ['an unknown field', { subject: 'y', text: 'x' }, TOKEN, 400, 'text'],
  166. ['a non-string subject', { subject: 5 }, TOKEN, 400, 'subject'],
  167. ['an author field', { subject: 'y', author: 'mallory' }, TOKEN, 400, 'author'],
  168. ]) {
  169. const r = await api('POST', GM + '/edit', body, token);
  170. 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));
  171. }
  172. 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);
  173. check('#8 api: the refused edits wrote nothing', J((await api('GET', GM)).json) === gmBefore);
  174. // a ticket from BEFORE the login (its 'created' event has only the free-text author "creator")
  175. const legBefore = J((await api('GET', PT('alpha', 1))).json);
  176. const legGate = await api('POST', PT('alpha', 1) + '/edit', { subject: 'gate edits legacy' });
  177. const legBob = await api('POST', PT('alpha', 1) + '/edit', { subject: 'bob edits legacy' }, bobTok2.value.token);
  178. 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]));
  179. const legAlice = await api('POST', PT('alpha', 1) + '/edit', { summary: 'Summary added by the creator.' }, aliceTok);
  180. 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));
  181. // JSON is unchanged: an event that is not an edit carries no new keys; Accept: application/json = no Accept
  182. const EVENT_KEYS = J(['author', 'createdMs', 'from', 'hasText', 'id', 'isComment', 'isCreated', 'isState', 'kind', 'label', 'number', 'project', 'seq', 'text', 'ticket', 'to', 'toSlug', 'when']);
  183. const plainJson = await (await fetch(BASE + PT('alpha', 2))).text();
  184. const acceptJson = await fetch(BASE + PT('alpha', 2), { headers: { accept: 'application/json' } });
  185. 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));
  186. // THE MARKDOWN READ VIEW (Accept: text/markdown) — one ticket with its history, the lists
  187. const 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() }; };
  188. const md1 = await mdGet(GM);
  189. 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));
  190. 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);
  191. 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);
  192. check('#6 read view: /api/tickets/<uuid> gives the same document', (await mdGet('/api/tickets/' + gm.json.ticket.id)).text === md1.text);
  193. const mdL = await mdGet('/api/projects/gamma/tickets');
  194. const gammaRows = (await api('GET', '/api/projects/gamma/tickets')).json.tickets;
  195. const mdLines = mdL.text.trim().split('\n');
  196. 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);
  197. const mdAll = await mdGet('/api/tickets?state=open');
  198. 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));
  199. 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]]) {
  200. const r = await mdGet(GM, accept);
  201. 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);
  202. }
  203. 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)));
  204. // the parser alone (markdown.hl via tests/markdown.hl): block kinds + spans
  205. const 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).'];
  206. const mdOut = spawnSync(BIN, ['tests/markdown.hl'], { cwd: APP, env: { ...process.env, MD_INPUT: J(mdCases) }, encoding: 'utf8', timeout: 30000 });
  207. let mdParsed = null; try { mdParsed = JSON.parse(mdOut.stdout.trim().split('\n').pop()); } catch {}
  208. 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('');
  209. 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]));
  210. 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]));
  211. 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)));
  212. 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);
  213. // ---- in the browsers: bob's own ticket (bob = B, alice = A) ----------------------------------
  214. const bt = await api('POST', '/api/projects/gamma/tickets', { subject: 'Bob\'s Markdown ticket', summary: MD_RICH }, bobTok2.value.token);
  215. const BT = '/projects/gamma/' + bt.json.ticket.number;
  216. check('#8: bob opens ' + BT + ' (his token)', bt.status === 201 && bt.json.ticket.number === 4, J(bt));
  217. await b.goto(BASE + BT);
  218. await b.waitForSelector('#summary markdown-text');
  219. await connected(b, 'B on bob\'s ticket');
  220. await a.goto(BASE + BT);
  221. await a.waitForSelector('#summary markdown-text');
  222. await connected(a, 'A on bob\'s ticket');
  223. await sleep(300);
  224. 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")')));
  225. check('#8 page: signed out (no cookie) there is no "Edit"', !(await (await fetch(BASE + BT)).text()).includes('id="editbutton"'));
  226. const rendered = await b.evaluate(`(() => { const s = document.querySelector('#summary'); return {
  227. 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),
  228. code: [...s.querySelectorAll('p code')].map(e => e.textContent), ul: s.querySelectorAll('ul > li').length, ol: s.querySelectorAll('ol > li').length,
  229. pre: (s.querySelector('pre code') || {}).textContent, hrefs: [...s.querySelectorAll('a')].map(a => a.getAttribute('href')),
  230. bad: s.querySelectorAll('script, img, iframe, [onerror]').length, text: s.textContent, xss: window.__xss === undefined ? null : window.__xss }; })()`);
  231. check('#6 page: headings, emphasis, strong, inline code, lists and a code block are rendered',
  232. 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));
  233. 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',
  234. J(rendered.hrefs) === J(['https://example.org/a?b=1', 'https://example.org/x', '/projects/gamma/1']) && rendered.bad === 0 && rendered.xss === null
  235. && 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));
  236. 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'); })()`));
  237. // a Markdown comment through the API → pushed live, rendered in both browsers
  238. await b.evaluate('window.__mdMarker = 1');
  239. await a.evaluate('window.__mdMarker = 1');
  240. 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' });
  241. for (const [p, who] of [[a, 'A'], [b, 'B']]) {
  242. await p.waitFor('document.querySelectorAll("#events > li").length === 2', { label: who + ' got the Markdown comment' });
  243. 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'));
  244. }
  245. // a Markdown state note from bob's form
  246. await choose(b, '#newstate', 'in progress');
  247. await type(b, '#statenote', 'started, **step 1** in `markdown.hl`');
  248. await b.click('#statesend');
  249. await a.waitFor('document.querySelectorAll("#events > li").length === 3', { label: 'A got the state note' });
  250. 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'));
  251. // bob edits in the web form: subject + summary; alice follows live
  252. await shot(b, 'desktop-editbutton');
  253. await b.click('#editbutton');
  254. await b.waitForSelector('#editform');
  255. 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')`));
  256. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  257. await viewport(b, w, h);
  258. check(`layout ${name} ${w}px: the edit form has no horizontal overflow`, await noOverflow(b));
  259. await shot(b, `${name}-edit`);
  260. }
  261. await viewport(b, 1280, 900);
  262. await b.click('#editcancel');
  263. await b.waitFor('!document.querySelector("#editform") && !!document.querySelector("#editbutton")', { label: 'B edit cancelled' });
  264. check('#8 page: Cancel closes the form and writes nothing', (await api('GET', PT('gamma', 4))).json.events.length === 3);
  265. await b.click('#editbutton');
  266. await b.waitForSelector('#editform');
  267. await type(b, '#editsubject', ' ');
  268. await b.click('#editsave');
  269. await b.waitFor('document.querySelector("#editmessage") && document.querySelector("#editmessage").textContent === "subject is required"', { label: 'B empty subject refused' });
  270. check('#8 page: an empty subject is refused on the page, nothing written', (await api('GET', PT('gamma', 4))).json.events.length === 3);
  271. const NEW_SUMMARY = '## New plan\n\n- one\n- *two*\n\nDone.';
  272. await type(b, '#editsubject', 'Bob\'s edited ticket');
  273. await type(b, '#editsummary', NEW_SUMMARY);
  274. await b.click('#editsave');
  275. await b.waitFor('!document.querySelector("#editform") && document.querySelector("#subject").textContent === "Bob\'s edited ticket"', { label: 'B saved the edit' });
  276. await a.waitFor('document.querySelector("#subject").textContent === "Bob\'s edited ticket" && document.querySelectorAll("#events > li").length === 4', { label: 'A got the edit live' });
  277. for (const [p, who] of [[b, 'B'], [a, 'A']]) {
  278. 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 }; })()`);
  279. 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));
  280. }
  281. const btApi = (await api('GET', PT('gamma', 4))).json;
  282. 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]));
  283. await a.evaluate(`document.querySelector('#events > li:last-child details').open = true`);
  284. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  285. await viewport(a, w, h);
  286. check(`layout ${name} ${w}px: the Markdown ticket with an edit has no horizontal overflow`, await noOverflow(a));
  287. await shot(a, `${name}-markdown`);
  288. }
  289. await viewport(a, 1280, 900);
  290. // the faces: alice may not edit bob's ticket; a forged session never
  291. const btBefore = J((await api('GET', PT('gamma', 4))).json);
  292. const aliceEdit = await temit('ticketEdit', [bt.json.ticket.id, 'alice edits', 'x'], aliceCookie);
  293. const forgedEdit = await temit('ticketEdit', [bt.json.ticket.id, 'forged', 'x', forged], null);
  294. const forgedMay = await temit('ticketMayEdit', [bt.json.ticket.id, forged], null);
  295. const bobMay = await temit('ticketMayEdit', [bt.json.ticket.id], bobCookie);
  296. 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]));
  297. // a ticket from before the login: the creator (alice) gets "Edit", bob does not; after a
  298. // logout + selector login (no reload) alice's "Edit" comes back
  299. await a.goto(BASE + '/projects/alpha/1');
  300. await a.waitForSelector('#events > li');
  301. await connected(a, 'A on alpha #1');
  302. await b.goto(BASE + '/projects/alpha/1');
  303. await b.waitForSelector('#events > li');
  304. 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")')));
  305. 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'));
  306. await a.evaluate('window.__editMarker = 1');
  307. await a.click('#logout');
  308. await a.waitFor('!document.querySelector("#editbutton") && !!document.querySelector("#loginbutton")', { label: 'A logged out, no Edit' });
  309. await shClick(a, '#choose');
  310. await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list (edit)' });
  311. await shClick(a, '[part~="identity"]', 'Default');
  312. await a.waitFor('!!document.querySelector("#whoami") && !!document.querySelector("#editbutton")', { label: 'A back with Edit' });
  313. 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');
  314. // ---- ticket #4 (mission 017): parent / child, blocked by — API, Markdown, two browsers --------
  315. // Project rel.example: the gate user opens P (#1) and C1–C3 (#2–#4, like ident #1 with its
  316. // pieces), bob opens X (#5). Who may: the author of either ticket, or the creator (alice).
  317. const RP = (n) => PT('rel.example', n);
  318. const relMk = async (subject, token = TOKEN) => (await api('POST', '/api/projects/rel.example/tickets', { subject }, token)).json.ticket;
  319. const P = await api('POST', '/api/tickets', { project: 'rel.example', subject: 'Parent: rebuild it in pieces' });
  320. const C1 = await relMk('piece 1/3');
  321. const C2 = await relMk('piece 2/3');
  322. const C3 = await relMk('piece 3/3');
  323. const X = await relMk('bob\'s blocker', bobTok2.value.token);
  324. 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
  325. && 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]));
  326. const PID = P.json.ticket.id;
  327. const s1 = await api('POST', RP(2) + '/parent', { parent: 'rel.example#1' });
  328. check('#4 api: POST …/parent {parent:"rel.example#1"} → 201, a "link" event on the child, the row names the parent with its state',
  329. 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'
  330. && 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));
  331. const s2 = await api('POST', '/api/tickets/' + C2.id + '/parent', { parent: PID });
  332. const s3 = await api('POST', RP(4) + '/parent', { parent: ' rel.example # 1 ' });
  333. 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]));
  334. let pg = (await api('GET', RP(1))).json;
  335. check('#4 api: the parent lists its children (oldest link first) with states; allChildrenConfirmed false; its own history untouched',
  336. 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']])
  337. && pg.ticket.allChildrenConfirmed === false && pg.ticket.parent === null && pg.events.length === 1 && pg.ticket.state === 'open', J(pg.ticket));
  338. const relList = (await api('GET', '/api/projects/rel.example/tickets')).json.tickets;
  339. const rowOf = (n) => relList.find(t => t.number === n);
  340. 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])));
  341. 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']);
  342. 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',
  343. 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()));
  344. // refused parent writes: nothing written
  345. const relBefore = J((await api('GET', '/api/projects/rel.example/tickets')).json);
  346. for (const [label, path, body, token, want, field] of [
  347. ['no token', RP(2) + '/parent', { parent: '' }, null, 401, null],
  348. ['bob (author of neither)', RP(2) + '/parent', { parent: '' }, bobTok2.value.token, 403, null],
  349. ['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],
  350. ['itself', RP(1) + '/parent', { parent: 'rel.example#1' }, TOKEN, 400, 'parent'],
  351. ['an unknown ticket', RP(2) + '/parent', { parent: 'rel.example#99' }, TOKEN, 400, 'parent'],
  352. ['no project#number', RP(2) + '/parent', { parent: 'nonsense' }, TOKEN, 400, 'parent'],
  353. ['a loop (P under its own child)', RP(1) + '/parent', { parent: 'rel.example#2' }, TOKEN, 400, 'parent'],
  354. ['the same parent again', RP(2) + '/parent', { parent: 'rel.example#1' }, TOKEN, 400, 'parent'],
  355. ['removing a parent that is not there', RP(1) + '/parent', { parent: '' }, TOKEN, 400, 'parent'],
  356. ['no parent field {}', RP(2) + '/parent', {}, TOKEN, 400, 'parent'],
  357. ['an unknown field', RP(2) + '/parent', { parent: 'rel.example#1', child: 'x' }, TOKEN, 400, 'child'],
  358. ['a non-string', RP(2) + '/parent', { parent: 1 }, TOKEN, 400, 'parent'],
  359. ['an author field', RP(2) + '/parent', { parent: 'rel.example#1', author: 'x' }, TOKEN, 400, 'author'],
  360. ['an unknown ticket in the URL', RP(99) + '/parent', { parent: 'rel.example#1' }, TOKEN, 404, null],
  361. ]) {
  362. const r = await api('POST', path, body, token);
  363. 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));
  364. }
  365. check('#4 api: the refused parent writes wrote nothing', J((await api('GET', '/api/projects/rel.example/tickets')).json) === relBefore);
  366. // who may: bob (author of X) may put X under the gate's P and take it out again; the creator may
  367. // link a ticket from before the login; a legacy ticket + a ticket bob does not own → 403 for bob
  368. const bx1 = await api('POST', RP(5) + '/parent', { parent: 'rel.example#1' }, bobTok2.value.token);
  369. const bx2 = await api('POST', RP(5) + '/parent', { parent: '' }, bobTok2.value.token);
  370. 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]));
  371. const relLegBob = await api('POST', PT('alpha', 3) + '/parent', { parent: 'gamma#1' }, bobTok2.value.token);
  372. const legAliceP = await api('POST', PT('alpha', 3) + '/parent', { parent: 'gamma#1' }, aliceTok);
  373. const legAliceR = await api('POST', PT('alpha', 3) + '/parent', { parent: '' }, aliceTok);
  374. 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]));
  375. const rep1 = await api('POST', RP(4) + '/parent', { parent: 'rel.example#5' });
  376. 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));
  377. await api('POST', RP(4) + '/parent', { parent: 'rel.example#1' });
  378. // grandchild loop: X under C1, then P under X → P would be its own ancestor
  379. await api('POST', RP(5) + '/parent', { parent: 'rel.example#2' }, bobTok2.value.token);
  380. const loop2 = await api('POST', RP(1) + '/parent', { parent: 'rel.example#5' });
  381. check('#4 api: a loop through a grandchild (P → X → C1 → P) is refused', loop2.status === 400 && /loop/.test(loop2.json.error), J(loop2));
  382. await api('POST', RP(5) + '/parent', { parent: '' }, bobTok2.value.token);
  383. // BLOCKED BY
  384. const b1 = await api('POST', RP(2) + '/blocked-by', { add: 'rel.example#5' });
  385. check('#4 api: POST …/blocked-by {add} → 201 "marked it blocked by rel.example #5"; blockedBy on C1, blocks on X, with states',
  386. 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']])
  387. && J((await api('GET', RP(5))).json.ticket.blocks.map(r => [r.key, r.state])) === J([['rel.example#2', 'open']]), J(b1));
  388. const b2 = await api('POST', '/api/tickets/' + C2.id + '/blocked-by', { add: 'rel.example#2' });
  389. check('#4 api: C2 blocked by C1 (UUID route) → 201', b2.status === 201, J(b2));
  390. const bBefore = J((await api('GET', '/api/projects/rel.example/tickets')).json);
  391. for (const [label, path, body, token, want, field] of [
  392. ['no token', RP(2) + '/blocked-by', { add: 'rel.example#4' }, null, 401, null],
  393. ['bob (author of neither)', RP(3) + '/blocked-by', { add: 'rel.example#4' }, bobTok2.value.token, 403, null],
  394. ['itself', RP(2) + '/blocked-by', { add: 'rel.example#2' }, TOKEN, 400, 'add'],
  395. ['twice', RP(2) + '/blocked-by', { add: 'rel.example#5' }, TOKEN, 400, 'add'],
  396. ['a direct loop (X blocked by C1)', RP(5) + '/blocked-by', { add: 'rel.example#2' }, bobTok2.value.token, 400, 'add'],
  397. ['a loop over two (X blocked by C2 → C1 → X)', RP(5) + '/blocked-by', { add: 'rel.example#3' }, bobTok2.value.token, 400, 'add'],
  398. ['add AND remove', RP(2) + '/blocked-by', { add: 'rel.example#4', remove: 'rel.example#5' }, TOKEN, 400, null],
  399. ['neither {}', RP(2) + '/blocked-by', {}, TOKEN, 400, 'add'],
  400. ['an empty add', RP(2) + '/blocked-by', { add: ' ' }, TOKEN, 400, 'add'],
  401. ['an unknown ticket', RP(2) + '/blocked-by', { add: 'nope#1' }, TOKEN, 400, 'add'],
  402. ['removing one that does not block', RP(2) + '/blocked-by', { remove: 'rel.example#4' }, TOKEN, 400, 'remove'],
  403. ['an unknown field', RP(2) + '/blocked-by', { blocker: 'rel.example#4' }, TOKEN, 400, 'blocker'],
  404. ['an unknown ticket in the URL', '/api/tickets/nope/blocked-by', { add: 'rel.example#4' }, TOKEN, 404, null],
  405. ]) {
  406. const r = await api('POST', path, body, token);
  407. 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));
  408. }
  409. check('#4 api: the refused blocked-by writes wrote nothing', J((await api('GET', '/api/projects/rel.example/tickets')).json) === bBefore);
  410. const bRm = await api('POST', RP(3) + '/blocked-by', { remove: 'rel.example#2' });
  411. 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));
  412. 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));
  413. // the Markdown read view
  414. const mdC1 = await mdGet(RP(2));
  415. check('#4 read view: a child says "Parent: …" and "Blocked by:" with states and URLs; its history names the link',
  416. 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`)
  417. && /\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);
  418. const mdP = await mdGet(RP(1));
  419. 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);
  420. const mdX = await mdGet(RP(5));
  421. 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);
  422. const mdRel = (await mdGet('/api/projects/rel.example/tickets')).text;
  423. check('#4 read view: list lines carry " · parent …", " · children 3, 0 confirmed", " · blocked by … [state]", " · blocks …"',
  424. 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);
  425. // ---- in the browsers: B (bob) watches P, A (alice, the creator) works on the children --------
  426. const PP = '/projects/rel.example/1';
  427. await b.goto(BASE + PP);
  428. await b.waitForSelector('#children li');
  429. await connected(b, 'B on P');
  430. await b.evaluate('window.__relMarker = 1');
  431. 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') }))()`);
  432. check('#4 page: the parent lists its three children (state, ref link, subject) and "0 of 3 children confirmed"',
  433. 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));
  434. const anonP = await (await fetch(BASE + PP)).text();
  435. 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"'));
  436. await a.goto(BASE + '/projects/rel.example/3');
  437. await a.waitForSelector('#parent');
  438. await connected(a, 'A on C2');
  439. 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);
  440. // A removes C2's parent with the web button → B's parent page drops it live
  441. await a.click('#parentremove');
  442. await a.waitFor('!document.querySelector("#parent") && !document.querySelector("#parentremove")', { label: 'A parent removed' });
  443. await b.waitFor('document.querySelectorAll("#children li").length === 2', { label: 'B children 2 live' });
  444. 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'));
  445. // A sets it again by typing into the form
  446. await type(a, '#parentkey', 'rel.example#1');
  447. await a.click('#parentsave');
  448. await a.waitFor('!!document.querySelector("#parent")', { label: 'A parent set' });
  449. await b.waitFor('document.querySelectorAll("#children li").length === 3', { label: 'B children 3 live' });
  450. 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);
  451. // refused on the page: an unknown ticket, a loop
  452. await type(a, '#parentkey', 'rel.example#99');
  453. await a.click('#parentsave');
  454. await a.waitFor('document.querySelector("#linkmessage").textContent.includes("no such ticket: rel.example#99")', { label: 'A unknown parent refused' });
  455. 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');
  456. // blocked by from the web: C2 blocked by X; B watches X's page ("Blocks")
  457. await b.goto(BASE + '/projects/rel.example/5');
  458. await b.waitForSelector('#blocks li');
  459. await connected(b, 'B on X');
  460. 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")')));
  461. await type(a, '#blockerkey', 'rel.example#5');
  462. await a.click('#blockeradd');
  463. await a.waitFor('!!document.querySelector("#blockedby li")', { label: 'A blocker added' });
  464. await b.waitFor('document.querySelectorAll("#blocks li").length === 2', { label: 'B blocks 2 live' });
  465. 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']));
  466. // 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#1
  467. const bobC1 = await temit('ticketAddBlocker', [C1.id, 'gamma#1'], bobCookie);
  468. 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);
  469. for (const [ev, args] of [['ticketSetParent', [C1.id, '']], ['ticketAddBlocker', [C1.id, 'rel.example#4']], ['ticketRemoveBlocker', [C1.id, 'rel.example#5']]]) {
  470. const r = await temit(ev, [...args, forged], null);
  471. check(`#4 forged session refused (#31): ${ev}`, framework_refused(r.raw) || (r.value && /log in with ident/.test(r.value.error || '')), r.raw);
  472. }
  473. // the per-row "Remove" button on the blocked page
  474. await a.click('#blockedby li .unblock');
  475. await a.waitFor('!document.querySelector("#blockedby")', { label: 'A blocker removed' });
  476. await b.waitFor('document.querySelectorAll("#blocks li").length === 1', { label: 'B blocks 1 live' });
  477. 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'));
  478. // PARENT STATE: alice (the creator) confirms the three children → P says "all children confirmed", its state stays open
  479. await b.goto(BASE + PP);
  480. await b.waitForSelector('#children li');
  481. await connected(b, 'B on P again');
  482. await b.evaluate('window.__relMarker = 2');
  483. for (const n of [2, 3]) await api('POST', RP(n) + '/state', { state: 'confirmed' }, aliceTok);
  484. await b.waitFor('document.querySelector("#childnote").textContent === "2 of 3 children confirmed"', { label: 'B 2 of 3' });
  485. 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'));
  486. await a.goto(BASE + '/projects/rel.example/4');
  487. await a.waitForSelector('#stateform');
  488. await connected(a, 'A on C3');
  489. await choose(a, '#newstate', 'confirmed');
  490. await a.click('#statesend');
  491. await b.waitFor('document.querySelector("#childnote").classList.contains("done")', { label: 'B all confirmed' });
  492. const pAll = (await api('GET', RP(1))).json;
  493. check('#4 parent state: all children confirmed → the page says "all children confirmed (3)" (green), P itself stays open (no auto-confirm), no reload',
  494. (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,
  495. (await b.text('#childnote')) + ' ' + (await b.evaluate('getComputedStyle(document.querySelector("#childnote")).color')));
  496. 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'));
  497. // screenshots: the parent with its children (B), a blocked ticket (C1, A) at 390 / 1280
  498. await a.goto(BASE + '/projects/rel.example/2');
  499. await a.waitForSelector('#blockedby li');
  500. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  501. await viewport(b, w, h);
  502. check(`layout ${name} ${w}px: the parent page with its children has no horizontal overflow`, await noOverflow(b));
  503. await shot(b, `${name}-parent`);
  504. await viewport(a, w, h);
  505. check(`layout ${name} ${w}px: the blocked child page (parent, blocked by, forms) has no horizontal overflow`, await noOverflow(a));
  506. await shot(a, `${name}-blocked`);
  507. }
  508. await viewport(a, 1280, 900);
  509. await viewport(b, 1280, 900);
  510. // ---- a new ticket from the web form; the other list follows --------------------------
  511. await b.goto(BASE + '/');
  512. await b.waitForSelector('#tickets li');
  513. await connected(b, 'B on list');
  514. await a.goto(BASE + '/');
  515. await a.waitForSelector('#tickets li');
  516. await connected(a, 'A on list');
  517. await sleep(300);
  518. await a.click('#newticket summary');
  519. 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]")'));
  520. await type(a, '#newproject', 'hybriel');
  521. await type(a, '#newsubject', 'A ticket from the web form');
  522. await type(a, '#newsummary', 'made by the gate');
  523. await a.click('#newsubmit');
  524. await a.waitFor('!!document.querySelector("#notice")', { label: 'A notice' });
  525. 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'));
  526. 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'));
  527. const NOW = (await api('GET', '/api/tickets')).json.tickets.length; // TOTAL + the tickets of the #8/#6 part + this one
  528. await b.waitFor(`document.querySelectorAll("#tickets li").length === ${NOW}`, { label: 'B list grows' });
  529. 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'));
  530. 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');
  531. const six = await api('GET', PT('hybriel', 1));
  532. 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));
  533. await clickNav(a, '#noticelink', 'location.pathname === "/projects/hybriel/1" && !!document.querySelector("#subject")', 'notice link');
  534. check('new: the notice link opens the ticket', (await a.text('#subject')) === 'A ticket from the web form');
  535. // ---- import: paragraphs (blank line = new paragraph, CRLF file) --------------------------
  536. const PARA = join(SCRATCH, 'gate-import');
  537. rmSync(PARA, { recursive: true, force: true });
  538. mkdirSync(PARA, { recursive: true });
  539. 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'));
  540. 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 || ''); };
  541. const impP = runImportFrom(PARA);
  542. 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);
  543. const seven = await api('GET', PT('hybriel', 2));
  544. 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));
  545. await a.goto(BASE + '/projects/hybriel/2');
  546. await a.waitForSelector('#summary');
  547. // ticket #6: the summary is Markdown — a blank line = a new <p>, the two sit apart
  548. check('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'));
  549. await viewport(a, 390, 844);
  550. await shot(a, 'phone-paragraphs');
  551. await viewport(a, 1280, 900);
  552. // ---- import: Markdown list lines stay separate lines (ticket #15) ------------------------
  553. const LISTS = join(SCRATCH, 'gate-import-lists');
  554. rmSync(LISTS, { recursive: true, force: true });
  555. mkdirSync(LISTS, { recursive: true });
  556. 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'));
  557. const impL = runImportFrom(LISTS);
  558. 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);
  559. 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.';
  560. const eight = await api('GET', PT('hybriel', 3));
  561. check('import: list lines kept as lines, wrapped lines still joined', eight.json.ticket.summary === LIST_SUMMARY, J(eight.json.ticket.summary));
  562. await a.goto(BASE + '/projects/hybriel/3');
  563. await a.waitForSelector('#summary');
  564. // ticket #6: the stored lines are Markdown — the intro paragraph, a bullet list (2), a numbered
  565. // list (2), the closing paragraph ("3.5 kg" and "-not" are no list items)
  566. 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 }; })()`);
  567. 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));
  568. await viewport(a, 390, 844);
  569. await shot(a, 'phone-lists');
  570. await viewport(a, 1280, 900);
  571. // ---- layout: phone and desktop ------------------------------------------------------
  572. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  573. await viewport(b, w, h);
  574. await b.goto(BASE + '/');
  575. await b.waitForSelector('#tickets li');
  576. check(`layout ${name} ${w}px: list has no horizontal overflow`, await noOverflow(b), await b.evaluate('document.documentElement.scrollWidth'));
  577. await shot(b, `${name}-list`);
  578. await b.goto(BASE + IDENT1);
  579. await b.waitForSelector('#events li');
  580. check(`layout ${name} ${w}px: ticket page has no horizontal overflow`, await noOverflow(b), await b.evaluate('document.documentElement.scrollWidth'));
  581. await shot(b, `${name}-ticket`);
  582. await b.goto(BASE + '/tickets/1');
  583. await b.waitForSelector('#oldref');
  584. 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');
  585. await shot(b, `${name}-legacy`);
  586. await b.goto(BASE + '/inbox');
  587. await b.waitForSelector('#tickets li');
  588. check(`layout ${name} ${w}px: inbox has no horizontal overflow`, await noOverflow(b));
  589. await shot(b, `${name}-inbox`);
  590. }
  591. await viewport(b, 1280, 900);
  592. await b.goto(BASE + '/');
  593. await b.waitForSelector('#tickets li');
  594. 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; })()`));
  595. await viewport(b, 390, 844);
  596. await b.goto(BASE + '/');
  597. await b.waitForSelector('#tickets li');
  598. await b.click('#newticket summary');
  599. await b.waitFor('document.querySelector("#newticket").open', { label: 'form open' });
  600. check('layout phone: the open new-ticket form has no horizontal overflow', await noOverflow(b));
  601. await shot(b, 'phone-newticket');
  602. 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; })()`));
  603. await viewport(b, 1280, 900);
  604. // ---- logout on tickets resets the selector (no reload); the session is logged out ----------
  605. await a.goto(BASE + IDENT1);
  606. await a.waitForSelector('#commentform');
  607. await connected(a, 'A before logout');
  608. await a.evaluate('window.__logoutMarker = 1');
  609. await a.click('#logout');
  610. await a.waitFor('!!document.querySelector("#loginhint") && !document.querySelector("#commentform") && !!document.querySelector("#loginbutton")', { label: 'A logged out' });
  611. 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'))));
  612. const afterLogout = await temit('commentOn', [tid, 'after logout'], aliceCookie);
  613. check('logout: the session cannot write any more', afterLogout.value && /log in with ident/.test(afterLogout.value.error), afterLogout.raw);
  614. // ---- ticket #9: logged in → Log out → the selector → ANOTHER identity (the creator's path) --
  615. // hl:webex re-creates the header (and its <script>s) on every login/logout, so login.js RUNS
  616. // AGAIN; before mission 012 each run added an `ident-login` listener → after a logout one
  617. // choice sent the one-time code twice: the 2nd exchange failed and set the red banner
  618. // "ident refused the login (400: unknown or already used code)" although the 1st logged in.
  619. 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();
  620. check('#9: alice gets a second identity in ident ("alice two")', /"ok":true/.test(add2) && /alice two/.test(add2), add2.slice(0, 300));
  621. const ALICE2_ID = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE, 'alice two'));
  622. let ex0 = exchanges.length;
  623. await shClick(a, '#choose');
  624. await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 1`), { label: 'selector lists two identities' });
  625. 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']));
  626. await shClick(a, '[part~="identity"]', 'alice two');
  627. await a.waitForSelector('#nameform', { timeout: 10000 });
  628. await sleep(1500); // a second exchange (the bug) answers well within this
  629. let exNow = exchanges.slice(ex0);
  630. 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 || ""'));
  631. await a.type('#displayname', 'alice two');
  632. await a.click('#namesave');
  633. await a.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#whoami") && document.querySelector("#whoami").textContent === "alice two"', { label: 'A as alice two' });
  634. const aCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  635. let you9 = await (await fetch(BASE + '/you', { headers: { cookie: aCookie } })).text();
  636. 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'))));
  637. // the same on the LIST, reached by an in-app navigation, back to the first identity
  638. await a.click('#logout');
  639. await a.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#selector").hasAttribute("logged-in")', { label: 'A logged out again' });
  640. await clickNav(a, '#navall', 'location.pathname === "/" && !!document.querySelector("#tickets li")', 'A to the list');
  641. ex0 = exchanges.length;
  642. await shClick(a, '#choose');
  643. await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 1`), { label: 'selector list on /' });
  644. await shClick(a, '[part~="identity"]', 'Default');
  645. await a.waitFor('!!document.querySelector("#whoami") && document.querySelector("#whoami").textContent === "alice"', { label: 'A back as alice', timeout: 10000 });
  646. await sleep(1500);
  647. exNow = exchanges.slice(ex0);
  648. you9 = await (await fetch(BASE + '/you', { headers: { cookie: aCookie } })).text();
  649. 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));
  650. 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'));
  651. await a.click('#logout');
  652. await a.waitFor('!!document.querySelector("#loginbutton") && !document.querySelector("#selector").hasAttribute("logged-in")', { label: 'A logged out (end of #9)' });
  653. await a.goto(BASE + IDENT1);
  654. await a.waitForSelector('#events li');
  655. check('logout: after a reload still logged out', !!(await a.evaluate('!!document.querySelector("#loginhint") && !document.querySelector("#selector").hasAttribute("logged-in")')));
  656. // ---- ticket #10: the LOGIN BUTTON returns to the page the login started from ---------------
  657. // bob logs out on the list, goes to a ticket by an in-app click (the URL changed by
  658. // pushState, not by a load), presses "Log in with ident", chooses on ident → back on THAT page
  659. await b.goto(BASE + '/');
  660. await b.waitForSelector('#logout');
  661. await connected(b, 'B before #10');
  662. await b.click('#logout');
  663. await b.waitForSelector('#loginbutton');
  664. await clickNav(b, `#tickets li a.subject[href="${IDENT1}"]`, `location.pathname === ${J(IDENT1)} && !!document.querySelector('#events li')`, 'B to ident #1');
  665. await b.click('#loginbutton');
  666. await b.waitForSelector('#chooselist', { timeout: 10000 });
  667. const signinUrl = await b.evaluate('location.href');
  668. check('#10: from the ticket page the button went to ident (/signin/<rid>)', signinUrl.startsWith(ident.base + '/signin/'), signinUrl);
  669. await connectedIdent(b);
  670. await b.click('#chooselist li:nth-child(1) .choose');
  671. await b.waitFor(`location.pathname === ${J(IDENT1)} && !!document.querySelector('#whoami')`, { timeout: 10000, label: 'B back on the ticket' });
  672. 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'));
  673. // from the list with a query-less filter route too
  674. await connected(b, 'B on the ticket after #10');
  675. await b.click('#logout');
  676. await b.waitForSelector('#loginbutton');
  677. await b.goto(BASE + '/state/open');
  678. await b.waitForSelector('#loginbutton');
  679. await b.click('#loginbutton');
  680. await b.waitForSelector('#chooselist', { timeout: 10000 });
  681. await connectedIdent(b);
  682. await b.click('#chooselist li:nth-child(1) .choose');
  683. await b.waitFor(`location.pathname === '/state/open' && !!document.querySelector('#whoami')`, { timeout: 10000, label: 'B back on /state/open' });
  684. check('#10: from a filtered list (/state/open) back on that list', (await b.evaluate('location.href')) === BASE + '/state/open');
  685. // ---- ticket #12 (mission 024): the Markdown editor <md-editor> in every Markdown field -----------
  686. // B = bob (logged in). REAL clicks and keys into the editor's shadow DOM; the value is checked in
  687. // the textarea (what webex reads) and in the stored ticket; screenshots gate-*-mdeditor.png.
  688. 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}); })()`;
  689. const mdClick = async (page, id, sel, end) => {
  690. 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 }; })()`));
  691. if (!bx || !bx.w) throw new Error('mdClick: ' + id + ' ' + sel + ' is not laid out');
  692. 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);
  693. for (const [type, buttons] of [['mouseMoved', 0], ['mousePressed', 1], ['mouseReleased', 0]]) await page.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1, buttons });
  694. await sleep(80);
  695. };
  696. const MDKEY = { Enter: [13, 'Enter', '\r'], End: [35, 'End'], Home: [36, 'Home'], ArrowLeft: [37, 'ArrowLeft'] };
  697. const mdPress = async (page, key, mods = 0) => {
  698. const [vk, code, text] = MDKEY[key];
  699. const t = text && !(mods & 2) ? text : undefined;
  700. await page.send('Input.dispatchKeyEvent', { type: t ? 'keyDown' : 'rawKeyDown', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, ...(t ? { text: t, unmodifiedText: t } : {}) });
  701. await page.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: mods, key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk });
  702. await sleep(30);
  703. };
  704. const mdType = async (page, text) => {
  705. for (const ch of text) {
  706. await page.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch });
  707. await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });
  708. }
  709. await sleep(60);
  710. };
  711. const taVal = (page, id) => page.evaluate(`document.getElementById(${J(id)}).value`);
  712. const CTRL = 2;
  713. await b.goto(BASE + BT);
  714. await b.waitForSelector('#commenttext');
  715. await connected(b, 'B on bob\'s ticket (#12)');
  716. await sleep(300);
  717. const bCookie12 = (await b.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  718. const ssr12 = await (await fetch(BASE + BT, { headers: { cookie: bCookie12 } })).text();
  719. const js12 = await fetch(BASE + '/md-editor.js');
  720. 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',
  721. /<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)));
  722. check('#12: both editors upgraded (toolbar of 10, contenteditable); the textareas stay in their forms, out of sight',
  723. 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'; })`));
  724. const nEv12 = (await api('GET', PT('gamma', 4))).json.events.length;
  725. await mdClick(b, 'commenttext', '[part~=editor]');
  726. await mdType(b, 'From the **editor** with `code` and a list:');
  727. await mdPress(b, 'Enter');
  728. await mdType(b, '- one');
  729. await mdPress(b, 'Enter');
  730. await mdType(b, 'two');
  731. const typed12 = await taVal(b, 'commenttext');
  732. check('#12 comment: real typing — **x** / `x` format while typing, "- " starts a list; the textarea holds plain Markdown',
  733. 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));
  734. await mdPress(b, 'Enter', CTRL);
  735. await b.waitFor(`document.querySelectorAll('#events > li').length === ${nEv12 + 1}`, { label: 'B comment from the editor' });
  736. await sleep(200);
  737. const ev12 = (await api('GET', PT('gamma', 4))).json.events.pop();
  738. check('#12 comment: Ctrl+Enter sends it — stored as that Markdown, rendered (strong, code, list); the editor is empty again',
  739. 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; })()`)
  740. && (await taVal(b, 'commenttext')) === '' && await b.evaluate(mdR('commenttext', `r.querySelector('[part~=editor]').textContent === ''`)), J(ev12));
  741. await mdClick(b, 'statenote', '[part~=editor]');
  742. await mdType(b, 'Tested on ');
  743. await mdClick(b, 'statenote', '[data-tool=bold]');
  744. await mdType(b, 'phone');
  745. const note12 = await taVal(b, 'statenote');
  746. check('#12 state note: toolbar "B" with nothing selected, then typing → **phone**', note12 === 'Tested on **phone**', J(note12));
  747. await choose(b, '#newstate', 'awaiting creator');
  748. await b.click('#statesend');
  749. await b.waitFor(`document.querySelectorAll('#events > li').length === ${nEv12 + 2}`, { label: 'B state note from the editor' });
  750. await sleep(200);
  751. 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')) === '');
  752. // a paste of hostile rich text into the comment
  753. await mdClick(b, 'commenttext', '[part~=editor]');
  754. 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 })); })()`));
  755. await sleep(200);
  756. const pasted12 = await taVal(b, 'commenttext');
  757. 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',
  758. 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));
  759. await mdPress(b, 'Enter', CTRL);
  760. await b.waitFor(`document.querySelectorAll('#events > li').length === ${nEv12 + 3}`, { label: 'B pasted comment' });
  761. 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; })()`));
  762. // the author's edit form: the stored summary opens formatted and comes back unchanged
  763. const cur12 = (await api('GET', PT('gamma', 4))).json.ticket.summary;
  764. await b.click('#editbutton');
  765. await b.waitForSelector('#editform md-editor');
  766. await b.waitFor(mdR('editsummary', `!!r.querySelector('[part~=editor] h2')`), { label: 'the edit editor shows the summary' });
  767. check('#12 edit: the summary opens formatted (heading, list, em) and the textarea holds the stored Markdown byte for byte',
  768. (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')]));
  769. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  770. await viewport(b, w, h);
  771. await b.evaluate(`document.querySelector('#editform').scrollIntoView({ block: 'start' })`);
  772. 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))) }; })()`));
  773. 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));
  774. await shot(b, `${name}-mdeditor`);
  775. }
  776. await viewport(b, 1280, 900);
  777. await mdClick(b, 'editsummary', '[part~=editor] p:last-child', true);
  778. await mdPress(b, 'End', CTRL);
  779. await mdType(b, ' Really.');
  780. 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')));
  781. await b.click('#editsave');
  782. await b.waitFor(`!document.querySelector('#editform')`, { label: 'B saved the edit (#12)' });
  783. await sleep(200);
  784. check('#12 edit: saved — the stored summary is that Markdown', (await api('GET', PT('gamma', 4))).json.ticket.summary === cur12 + ' Really.');
  785. // the new-ticket form on a phone: the toolbar by tap
  786. await viewport(b, 390, 844);
  787. await b.goto(BASE + '/');
  788. await b.waitForSelector('#newticket');
  789. await connected(b, 'B on the list (#12)');
  790. await sleep(300);
  791. await b.click('#newticket summary');
  792. await b.waitFor(mdR('newsummary', `!!r.querySelector('[part~=editor]')`), { label: 'new-ticket editor' });
  793. await type(b, '#newproject', 'gamma');
  794. await type(b, '#newsubject', 'Opened with the editor on a phone');
  795. await mdClick(b, 'newsummary', '[part~=editor]');
  796. await mdType(b, 'Needs a fix in ');
  797. await mdClick(b, 'newsummary', '[data-tool=code]');
  798. await mdType(b, 'store.hl');
  799. await mdClick(b, 'newsummary', '[data-tool=code]');
  800. await mdType(b, ' soon');
  801. const ns12 = await taVal(b, 'newsummary');
  802. 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));
  803. check('#12 new ticket (390px): no horizontal overflow', await noOverflow(b));
  804. await shot(b, 'phone-newticket-mdeditor');
  805. await b.click('#newsubmit');
  806. await b.waitFor('!!document.querySelector("#notice") && !!document.querySelector("#noticelink")', { label: 'B opened a ticket with the editor' });
  807. const nt12 = await api('GET', '/api' + (await b.evaluate('document.querySelector("#noticelink").getAttribute("href")')).replace(/^\/projects\/([^/]+)\/(\d+)$/, '/projects/$1/tickets/$2'));
  808. check('#12 new ticket: stored with that Markdown summary', nt12.json && nt12.json.ticket.summary === ns12, J(nt12.json && nt12.json.ticket));
  809. await viewport(b, 1280, 900);

Branches

Latest commits

  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre