// #797 / #1839 Task 3: the intake panel on an owner's chat thread, driven in the installed Chromium // against a stubbed fetch (the pattern of chat-browser.test.mjs). The panel is requests.js. The // server side is request-http.test.mjs and the bus side packages/bus/tests/request-console.test.mjs. import test from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { fixture, close } from './fixture.mjs'; import { browser } from './browser.mjs'; const A = '20000000-0000-4000-8000-000000000002'; const M = '40000000-0000-4000-8000-000000000004'; const R = '50000000-0000-4000-8000-000000000005'; const D = '60000000-0000-4000-8000-000000000006'; const HOSTILE = '\u001b[31m‮evil'; const state = (o = {}) => ({ version: 1, request: R, business: 'demo', revision: 4, source_message: M, thread: A, session: null, owner: 'jason', pm: 'pm', phase: 'selection', project: null, chat_project: null, mode: null, mode_revision: null, plan_digest: null, spec_commit: null, snapshot: null, tasks: [], delivery: null, question: null, candidate: null, selection: null, route: null, wait: null, ...o, }); const wait = (kind, code, subject, o = {}) => ({ kind, code, subject, resume_phase: 'selection', observed_revision: 4, ...o }); const CHOOSING = state({ selection: { decision: D, bindings: [] }, wait: wait('decision', 'request-mode', D) }); const PICKING = state({ route: 'existing', wait: wait('setup', 'request-existing-project', R) }); const QUESTION = { id: 'q-1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'Command line' }], answer: null }; const ASKING = state({ revision: 7, phase: 'discovery', project: 'factory', chat_project: 'c1', mode: 'guided', mode_revision: 1, question: QUESTION, wait: wait('question', 'question', 'q-1', { resume_phase: 'discovery', observed_revision: 7 }) }); const CANDIDATE = { version: 1, request: R, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.2.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-TIMER-1', title: 'Timer', path: 'requirements/REQ-TIMER-1.json', blob: 'd'.repeat(40) }] }; const DRAFT = { candidate: CANDIDATE, prd: '# Timer 1.2\n' + HOSTILE, requirements: [{ recordVersion: 1, id: 'REQ-TIMER-1', title: 'Timer', text: 'Counts down\n' + HOSTILE, status: 'draft' }], snapshot: { id: null, revision: 2 } }; const REVIEWING = state({ ...ASKING, revision: 11, phase: 'prd-review', question: null, candidate: CANDIDATE, wait: wait('decision', 'prd-approval', D, { resume_phase: 'prd-review', observed_revision: 11 }) }); const APPROVED = { ...REVIEWING, revision: 14, phase: 'planning', snapshot: 'prd-approved', wait: wait('setup', 'request-planning-unavailable', R) }; const ACTIVE_DRAFT = { ...DRAFT, snapshot: { id: 'prd-approved', revision: 3 } }; const PRD_INBOX = [{ id: D, action: 'prd.approve', question: 'Approve product PRD 1.2 at ' + CANDIDATE.records_commit + '?', console: { kind: 'decision', digest: 'e'.repeat(64), options: [{ key: 'yes', text: 'Approve this exact product PRD version' }, { key: 'no', text: 'Request changes' }] } }]; const ROW = { id: M, body: 'Build me a countdown timer', from_role: 'human', at: '2026-11-03T00:00:00Z', request: R }; const INBOX = [{ id: D, question: 'Confirm the resource plan.', options: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }], console: { kind: 'decision', digest: 'd'.repeat(64), options: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }] } }]; const stubFor = boot => `(() => { try { if (!sessionStorage.getItem('__boot')) sessionStorage.setItem('__boot', ${JSON.stringify(JSON.stringify(boot))}); } catch {} const boot = JSON.parse(sessionStorage.getItem('__boot') || '{}'); const real = fetch.bind(window); window.__rreads = []; window.__rwrites = []; window.__rAnswers = []; window.__drafts = boot.drafts || {}; window.__draftStatus = {}; window.__states = boot.states || {}; window.__projects = boot.projects || []; window.__inbox = boot.inbox || []; window.__pages = boot.pages || []; window.__readStatus = {}; window.__stubReady = true; const flags = { requests: !location.search.includes('norequests'), requestWrites: !location.search.includes('readonly') }; window.fetch = async (url, init = {}) => { const path = String(url), method = init.method || 'GET'; const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } }); if (path === '/api/config') return json({ send: true, ...(flags.requests ? { requests: true, requestWrites: flags.requestWrites } : {}) }); if (path === '/api/bus/inbox') return json({ rows: window.__inbox, unread: 0, at: new Date().toISOString() }); if (path.startsWith('/api/bus/')) return json({ rows: [], unread: 0, at: new Date().toISOString() }); if (path === '/api/business') return json({ projects: ['stack'] }); if (path.startsWith('/api/requests/') || path.startsWith('/api/decisions/')) { if (method === 'POST') { window.__rwrites.push({ path, body: JSON.parse(init.body), redirect: init.redirect }); const a = window.__rAnswers.shift(); if (!a) throw new Error('No write answer'); if (a.hold) await new Promise(r => window.__releaseR = r); if (a.reject) throw new TypeError('network'); if (a.set) window.__states[a.set.request] = a.set; return json(a.body ?? a.set, a.status || 200); } window.__rreads.push(path); const m = /^\\/api\\/requests\\/([^/]+?)(?:\\/(projects|draft))?$/.exec(path); if (!m) return json({ error: 'request-not-found' }, 404); if (window.__readStatus[m[1]]) return json({ error: 'sign-in-required', message: 'Sign in.' }, window.__readStatus[m[1]]); if (m[2] === 'draft') { if (window.__draftHold) await new Promise(r => window.__releaseDraft = r); return window.__draftStatus[m[1]] ? json(window.__draftStatus[m[1]].body, window.__draftStatus[m[1]].status) : json(window.__drafts[m[1]]); } if (m[2] === 'projects') return window.__projectsFail ? json({ error: 'unavailable', message: 'x' }, 503) : json(window.__projects); const s = window.__states[m[1]]; return s ? json(s) : json({ error: 'request-not-found', message: 'No such request.' }, 404); } if (!path.startsWith('/api/chat')) return real(url, init); const data = value => json({ ...value, at: new Date().toISOString() }); if (method === 'POST') return json({ id: 'sent' }); if (path === '/api/chat/instances') return data({ rows: [{ instance: 'assistant', name: 'assistant' }] }); if (path === '/api/chat/projects' || path === '/api/chat/projects/archived') return data({ rows: [] }); if (path.endsWith('/sessions')) { const t = path.split('/')[4]; return data({ rows: [{ id: t, name: 'default', default: true }] }); } if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return data({ rows: [{ id: '${A}', project: '${A}', name: 'alpha', instance: 'assistant' }] }); if (window.__pages.length) return data(window.__pages.length > 1 && !window.__stay ? window.__pages.shift() : window.__pages[0]); return data({ rows: [], after: null, hasMore: false }); }; })();`; // Review r1 M1: one bound for every wait on the page, sized for the full suite where Chromium shares the host with // the other files. It is far above a quiet run and below the 25 s CDP limit, so a page that never gets there fails // here, by name, instead of passing a silent loop. const WAIT_MS = 15000; async function setup(t, { boot = {}, suffix = '' } = {}) { const f = await fixture(); t.after(() => f.close()); const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server)); const b = await browser(); t.after(() => b.close()); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor({ pages: [{ rows: [ROW, { id: D, body: 'assistant reply', from_role: 'assistant', at: '2026-11-03T00:00:01Z' }], after: D, hasMore: false }], states: { [R]: ASKING }, ...boot }) }); const url = `http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`; await b.navigate(url); const w = (expression, ms = WAIT_MS) => b.evaluate(`(async()=>{const end=performance.now()+${ms};for(;;){if(${expression})return;if(performance.now()>end)throw new Error('timed out after ${ms} ms waiting for: '+${JSON.stringify(expression)});await new Promise(r=>setTimeout(r,30));}})()`); const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`); const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click(),true`); const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`); const count = selector => b.evaluate(`document.querySelectorAll(${JSON.stringify(selector)}).length`); const writes = () => b.evaluate('window.__rwrites'); const answer = (...a) => b.evaluate(`window.__rAnswers.push(...${JSON.stringify(a)}),true`); const sleep = ms => new Promise(r => setTimeout(r, ms)); // The explicit signal is the new document's own: the old window carries __old, and only a fresh document has the stub's // __stubReady with the load complete. Then the chat has mounted. If that never happens, this throws. const reload = async () => { await b.evaluate('window.__old=true,true'); await b.call('Page.reload', { ignoreCache: true }); const end = Date.now() + WAIT_MS; for (;;) { try { if (await b.evaluate('!window.__old && window.__stubReady === true && document.readyState==="complete"')) break; } catch { /* the old page is going away */ } if (Date.now() > end) throw new Error(`the reloaded page was not ready after ${WAIT_MS} ms`); await sleep(50); } await w('document.querySelector(".chat-conversation")'); }; await w('window.__stubReady === true && document.readyState === "complete" && document.querySelector(".chat-conversation")'); return { b, w, set, click, text, count, writes, answer, reload, sleep }; } const P = `.request-panel[data-request="${R}"]`; test('request panel: mounts under the owner message that opened the request, reads only, and renders hostile data inert', async t => { const hostile = state({ revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, question: { id: 'q-1', text: HOSTILE, options: [{ key: 'web', text: HOSTILE }], answer: null }, wait: wait('question', 'question', HOSTILE, { resume_phase: 'discovery' }) }); const { b, w, count, text, writes } = await setup(t, { boot: { states: { [R]: hostile } } }); await w(`document.querySelector('${P} .request-question')`); assert.equal(await count('.request-mount'), 1, 'one panel, for the one human row that carries a request'); assert.equal(await b.evaluate(`!!document.querySelector('[data-message="${M}"] .request-mount')`), true, 'inside the message that opened it'); assert.equal(await b.evaluate(`!!document.querySelector('[data-message="${D}"] .request-mount')`), false, 'no panel on the assistant reply'); assert.deepEqual(await writes(), [], 'mounting writes nothing'); assert.equal(await b.evaluate('window.__rreads.filter(p => p.startsWith("/api/requests/")).length >= 1'), true); assert.equal(await b.evaluate('window.injected'), undefined); assert.equal(await b.evaluate(`!!document.querySelector('${P} img')`), false); const panel = await text(P); assert.match(panel, /\\u202e/); assert.doesNotMatch(panel, /[‪-‮⁦-⁩]/u); assert.match(panel, /\\u001b/); assert.match(await text(`${P} .request-phase`), /discovery/); }); test('request panel: only the latest three owner messages get a panel, and a request the bus does not know shows nothing', async t => { const ids = [1, 2, 3, 4, 5].map(i => `5000000${i}-0000-4000-8000-000000000005`); const rows = ids.map((request, i) => ({ id: `4000000${i}-0000-4000-8000-000000000004`, body: `m${i}`, from_role: 'human', at: '2026-11-03T00:00:00Z', request })); const states = Object.fromEntries(ids.slice(1).map(id => [id, { ...ASKING, request: id }])); const { b, w, count } = await setup(t, { boot: { pages: [{ rows, after: rows.at(-1).id, hasMore: false }], states } }); await w('document.querySelectorAll(".request-panel").length === 3'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".request-mount")].map(e => e.dataset.request)'), ids.slice(2)); assert.deepEqual(await b.evaluate('[...new Set(window.__rreads.filter(p => /^\\/api\\/requests\\/[^/]+$/.test(p)).map(p => p.split("/").at(-1)))].sort()'), ids.slice(2).sort(), 'the panel, Files, and summary only read the three latest request IDs'); // The third newest is unknown to the bus: no panel, no error banner. const gone = await setup(t, { boot: { pages: [{ rows: [ROW], after: M, hasMore: false }], states: {} } }); await gone.w(`window.__rreads.some(p => p === "/api/requests/${R}")`); await gone.sleep(150); assert.equal(await gone.count('.request-panel'), 0); assert.equal(await gone.count('.chat-error'), 0); assert.ok(await count('.request-panel') >= 3); }); test('request panel: a Console without request support shows nothing, and one that cannot write shows the state without controls', async t => { const off = await setup(t, { suffix: '?norequests' }); await off.sleep(300); assert.equal(await off.count('.request-mount .request-panel'), 0); assert.equal(await off.b.evaluate('window.__rreads.some(p => p.startsWith("/api/requests/"))'), false, 'no request read when the Console says it has none'); const ro = await setup(t, { suffix: '?readonly', boot: { states: { [R]: ASKING } } }); await ro.w(`document.querySelector('${P} .request-question')`); assert.equal(await ro.count(`${P} form, ${P} textarea, ${P} [data-request-mode], ${P} [data-request-cancel], ${P} [data-request-project], ${P} [data-request-choice]`), 0); assert.match(await ro.text(`${P} .request-readonly`), /read-only|cannot answer/i); assert.deepEqual(await ro.writes(), []); }); test('request panel: the owner picks the mode with buttons through the decision confirmation, nothing is answered by the first click', async t => { const { b, w, click, count, text, writes, answer } = await setup(t, { boot: { states: { [R]: CHOOSING }, inbox: INBOX } }); await w(`document.querySelector('${P} [data-request-choice]')`); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${P} [data-request-choice]')].map(e => e.dataset.requestChoice)`), ['autonomous', 'guided', 'existing']); assert.match(await text(`${P} [data-request-choice="guided"]`), /New project — guided/); await answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'guided', text: 'New project — guided', question: 'Confirm the resource plan.', confirmation: 'tok-1' } }); await click(`${P} [data-request-choice="guided"]`); await w(`document.querySelector('${P} [data-request-confirm]')`); assert.deepEqual(await writes(), [{ path: `/api/decisions/${D}/confirmation`, body: { choice: 'guided' }, redirect: 'manual' }], 'the first click only previews'); assert.match(await text(`${P} .request-confirm`), /New project — guided/); // Cancelling the confirmation answers nothing and brings the choices back. await click(`${P} [data-request-unconfirm]`); await w(`document.querySelector('${P} [data-request-choice]')`); assert.equal((await writes()).length, 1); await answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'autonomous', text: 'New project — autonomous', question: 'q', confirmation: 'tok-2' } }); await click(`${P} [data-request-choice="autonomous"]`); await w(`document.querySelector('${P} [data-request-confirm]')`); const next = state({ revision: 6, phase: 'bootstrap', project: 'timer', mode: 'autonomous', mode_revision: 1, plan_digest: 'a'.repeat(64) }); await b.evaluate(`window.__states['${R}']=${JSON.stringify(next)},true`); await answer({ body: { id: D, op: 'resolved', choice: 'autonomous' } }); await click(`${P} [data-request-confirm]`); await w(`/bootstrap/.test(document.querySelector('${P} .request-phase')?.textContent)`); assert.deepEqual((await writes()).map(x => [x.path, x.body]), [ [`/api/decisions/${D}/confirmation`, { choice: 'guided' }], [`/api/decisions/${D}/confirmation`, { choice: 'autonomous' }], [`/api/decisions/${D}/resolve`, { choice: 'autonomous', confirmation: 'tok-2' }], ]); assert.equal(await count(`${P} [data-request-choice]`), 0, 'a decided selection offers no choices'); }); test('request panel: a refused or lost confirmation says so and is never retried', async t => { const { b, w, click, text, writes, answer } = await setup(t, { boot: { states: { [R]: CHOOSING }, inbox: INBOX } }); await w(`document.querySelector('${P} [data-request-choice]')`); await answer({ status: 409, body: { error: 'decision-stale', message: 'The decision changed.' } }); await click(`${P} [data-request-choice="guided"]`); await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('decision-stale')`); assert.match(await text(`${P} .request-receipt`), /Not answered/); assert.equal((await writes()).length, 1); await answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'guided', text: 'g', question: 'q', confirmation: 'tok-3' } }, { reject: true }); await click(`${P} [data-request-choice="guided"]`); await w(`document.querySelector('${P} [data-request-confirm]')`); await click(`${P} [data-request-confirm]`); await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('unknown')`); assert.equal((await writes()).length, 3, 'the lost resolve is not sent again'); assert.equal(await b.evaluate(`!!document.querySelector('${P} [data-request-confirm]')`), false, 'a confirmation works once'); // A granted-eligibility mismatch: an inbox row that is not a plain decision gets no buttons. const odd = await setup(t, { boot: { states: { [R]: CHOOSING }, inbox: [{ ...INBOX[0], console: { kind: 'grant', digest: 'd'.repeat(64) } }] } }); await odd.w(`document.querySelector('${P} .request-wait')`); assert.equal(await odd.count(`${P} [data-request-choice]`), 0); }); test('request panel: attach shows the authorized projects, picks one with a button, and lets the owner change the pick', async t => { const projects = [{ project: 'old', name: HOSTILE }, { project: 'older', name: 'Older' }]; const { b, w, click, text, count, writes, answer } = await setup(t, { boot: { states: { [R]: PICKING }, projects } }); await w(`document.querySelector('${P} [data-request-project="old"]')`); assert.equal(await count(`${P} [data-request-project]`), 2); assert.equal(await b.evaluate(`!!document.querySelector('${P} img')`), false); assert.equal(await b.evaluate('window.injected'), undefined); assert.match(await text(`${P} [data-request-project="old"]`), /\\u202e/); const picked = state({ revision: 5, route: 'existing', wait: wait('setup', 'request-attachment-selected', 'old', { observed_revision: 5 }) }); await answer({ set: picked }); await click(`${P} [data-request-project="old"]`); await w(`/old/.test(document.querySelector('${P} .request-wait')?.textContent) && /project manager/i.test(document.querySelector('${P} .request-wait')?.textContent)`); const [w1] = await writes(); assert.equal(w1.path, `/api/requests/${R}/attach`); assert.deepEqual({ ...w1.body, op: undefined }, { revision: 4, project: 'old', op: undefined }); assert.match(w1.body.op, /^attach-[0-9a-f]{24}$/); assert.equal(w1.redirect, 'manual'); assert.equal(await count(`${P} [data-request-project]`), 2, 'the owner can still change the pick before the PM proposes'); // Changing the pick is a new action with a new op and the new revision. await answer({ set: { ...picked, revision: 6, wait: wait('setup', 'request-attachment-selected', 'older', { observed_revision: 6 }) } }); await click(`${P} [data-request-project="older"]`); await w('window.__rwrites.length === 2'); const second = (await writes())[1]; assert.deepEqual([second.body.revision, second.body.project], [5, 'older']); assert.notEqual(second.body.op, w1.body.op); // The project list failing says so and draws no buttons. const down = await setup(t, { boot: { states: { [R]: PICKING }, projects } }); await down.b.evaluate('window.__projectsFail=true,document.querySelector("#refresh")?.click(),true'); await down.sleep(100); assert.ok(await down.count(`${P} [data-request-project]`) >= 0); }); test('request panel: a guided answer is free text sent with its question, op and revision, and is never an approval', async t => { const { b, w, set, click, text, count, writes, answer } = await setup(t, { boot: { states: { [R]: ASKING } } }); await w(`document.querySelector('${P} form.request-answer textarea')`); assert.match(await text(`${P} .request-question`), /Which platform\?/); assert.match(await text(P), /not an approval/i); await click(`${P} [data-request-option="cli"]`); assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'Command line', 'an option fills the text, it does not send'); assert.deepEqual(await writes(), []); const body = 'Web, but offline.\nSecond line x'; await set(`${P} textarea`, body); const done = state({ ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-1' }, wait: null }); await answer({ set: done }); await click(`${P} form.request-answer button[type=submit]`); await w(`!document.querySelector('${P} form.request-answer')`); const [sent] = await writes(); assert.equal(sent.path, `/api/requests/${R}/answer`); assert.deepEqual({ ...sent.body, op: undefined }, { revision: 7, question: 'q-1', text: body, op: undefined }); assert.match(sent.body.op, /^answer-[0-9a-f]{24}$/); assert.match(await text(`${P} .request-receipt`), /answer.*(recorded|sent)/i); assert.equal(await b.evaluate(`[...document.querySelectorAll('${P} b')].some(e => e.textContent === 'x')`), false, 'typed markup is never parsed'); assert.equal(await count(`${P} [data-request-choice]`), 0, 'a text answer opens no decision'); assert.equal(await b.evaluate('window.__rwrites.some(x => x.path.startsWith("/api/decisions/"))'), false); assert.equal(await b.evaluate('sessionStorage.length === 1 || [...Array(sessionStorage.length)].every((_, i) => !sessionStorage.key(i).startsWith("mosaic-request-answer"))'), true, 'a recorded answer leaves no pending record'); }); test('request panel: empty text, a long answer and a stale revision never send blind', async t => { const { b, w, set, click, text, writes, answer } = await setup(t, { boot: { states: { [R]: ASKING } } }); await w(`document.querySelector('${P} form.request-answer textarea')`); await click(`${P} form.request-answer button[type=submit]`); assert.deepEqual(await writes(), []); assert.match(await text(`${P} .request-receipt`), /Write an answer/i); await set(`${P} textarea`, 'x'.repeat(8001)); await click(`${P} form.request-answer button[type=submit]`); assert.deepEqual(await writes(), []); assert.match(await text(`${P} .request-receipt`), /too long/i); await set(`${P} textarea`, 'keep me'); const moved = state({ ...ASKING, revision: 9 }); await b.evaluate(`window.__states['${R}']=${JSON.stringify(moved)},true`); const reads = await b.evaluate('window.__rreads.length'); await answer({ status: 409, body: { error: 'request-revision-conflict', message: 'The request changed.' } }); await click(`${P} form.request-answer button[type=submit]`); await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('changed')`); assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'keep me', 'the draft survives a stale revision'); await w(`window.__rreads.length > ${reads}`); await w(`window.__states['${R}'].revision === 9 && document.querySelector('${P} form.request-answer')`); assert.equal((await writes()).length, 1, 'the stale write is not retried'); await answer({ set: { ...moved, revision: 10, question: { ...QUESTION, answer: 'ev-2' }, wait: null } }); await click(`${P} form.request-answer button[type=submit]`); await w('window.__rwrites.length === 2'); assert.equal((await writes())[1].body.revision, 9, 'the retry is a deliberate click at the re-read revision'); assert.notEqual((await writes())[1].body.op, (await writes())[0].body.op, 'a refused write consumed no op'); }); test('request panel: a lost answer response is read first after a reload, and only a click sends again with the same op', async t => { const { b, w, set, click, text, count, writes, answer, reload } = await setup(t, { boot: { states: { [R]: ASKING } } }); await w(`document.querySelector('${P} form.request-answer textarea')`); await set(`${P} textarea`, 'Web please'); await answer({ reject: true }); await click(`${P} form.request-answer button[type=submit]`); await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('unknown')`); const first = (await writes())[0]; assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'Web please', 'the draft is kept'); assert.equal((await writes()).length, 1, 'nothing is sent again by itself'); // The page reloads. The state still shows the question open: the page reads it, offers the stored text, and sends nothing. await reload(); await w(`document.querySelector('${P} form.request-answer textarea')`); assert.equal(await b.evaluate(`document.querySelector('${P} textarea').value`), 'Web please'); assert.match(await text(P), /may not have arrived|may have reached/i); assert.deepEqual(await writes(), [], 'no blind repost after a reload'); assert.equal(await b.evaluate(`window.__rreads.some(p => p === "/api/requests/${R}")`), true, 'the state was read first'); await answer({ set: { ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-3' }, wait: null } }); await click(`${P} [data-request-resend]`); await w('window.__rwrites.length === 1'); const again = (await writes())[0]; assert.equal(again.body.op, first.body.op, 'the same op, so the bus cannot record it twice'); assert.deepEqual([again.body.question, again.body.text, again.body.revision], ['q-1', 'Web please', 7]); await w(`!document.querySelector('${P} form.request-answer')`); assert.equal(await count(`${P} [data-request-resend]`), 0); }); test('request panel: a lost answer that did land is recognised on reload, with no send and no pending record', async t => { const { b, w, set, click, text, writes, answer, reload } = await setup(t, { boot: { states: { [R]: ASKING } } }); await w(`document.querySelector('${P} form.request-answer textarea')`); await set(`${P} textarea`, 'It landed'); await answer({ reject: true }); await click(`${P} form.request-answer button[type=submit]`); await w(`document.querySelector('${P} .request-receipt')?.textContent.includes('unknown')`); assert.equal(await b.evaluate('[...Array(sessionStorage.length)].some((_, i) => sessionStorage.key(i).startsWith("mosaic-request-answer"))'), true, 'the pending answer is stored before the send'); await b.evaluate(`sessionStorage.setItem('__boot', JSON.stringify({ pages: [{ rows: [${JSON.stringify(ROW)}], after: '${M}', hasMore: false }], states: { '${R}': ${JSON.stringify({ ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-4' }, wait: null })} } })),true`); await reload(); await w(`document.querySelector('${P} .request-phase')`); assert.equal(await b.evaluate(`!!document.querySelector('${P} form.request-answer')`), false); assert.match(await text(P), /answered/i); assert.deepEqual(await writes(), []); assert.equal(await b.evaluate('[...Array(sessionStorage.length)].some((_, i) => sessionStorage.key(i).startsWith("mosaic-request-answer"))'), false); }); test('request panel: a mode change names the expected revisions, cancel asks first and keeps the project', async t => { const { b, w, click, text, count, writes, answer } = await setup(t, { boot: { states: { [R]: ASKING } } }); await w(`document.querySelector('${P} [data-request-mode]')`); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${P} [data-request-mode]')].map(e => e.dataset.requestMode)`), ['autonomous'], 'only the other mode is offered'); await answer({ set: { ...ASKING, revision: 8, mode: 'autonomous', mode_revision: 2 } }); await click(`${P} [data-request-mode="autonomous"]`); await w(`document.querySelector('${P} [data-request-mode="guided"]')`); const [m] = await writes(); assert.equal(m.path, `/api/requests/${R}/mode`); assert.deepEqual({ ...m.body, op: undefined }, { revision: 7, mode_revision: 1, mode: 'autonomous', op: undefined }); assert.match(m.body.op, /^mode-[0-9a-f]{24}$/); assert.match(await text(`${P} .request-mode`), /autonomous/); // Cancel needs a second, explicit click and says what it keeps. await click(`${P} [data-request-cancel]`); assert.equal((await writes()).length, 1); assert.match(await text(`${P} .request-cancel`), /project.*(kept|stays|remains)/i); await click(`${P} [data-request-cancel-keep]`); assert.equal(await b.evaluate(`!!document.querySelector('${P} [data-request-cancel-confirm]')`), false); assert.equal((await writes()).length, 1); await click(`${P} [data-request-cancel]`); await answer({ set: { ...ASKING, revision: 9, mode: 'autonomous', mode_revision: 2, phase: 'cancelled', question: null, wait: null } }); await click(`${P} [data-request-cancel-confirm]`); await w(`/cancelled/.test(document.querySelector('${P} .request-phase')?.textContent)`); const c = (await writes())[1]; assert.equal(c.path, `/api/requests/${R}/cancel`); assert.deepEqual({ ...c.body, op: undefined }, { revision: 8, op: undefined }); assert.equal(await count(`${P} form, ${P} button:not([data-request-reload])`), 0, 'a cancelled request has no controls'); assert.match(await text(`${P} .request-project`), /factory/); }); test('request panel: sign-out clears the panel and a late write response changes nothing', async t => { const { b, w, set, click, answer, writes } = await setup(t, { boot: { states: { [R]: ASKING } } }); await w(`document.querySelector('${P} form.request-answer textarea')`); await set(`${P} textarea`, 'private words'); await answer({ hold: true, set: { ...ASKING, revision: 8, question: { ...QUESTION, answer: 'ev-5' }, wait: null } }); await click(`${P} form.request-answer button[type=submit]`); await w('typeof window.__releaseR === "function"'); await b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseR();true'); await w('document.body.classList.contains("signed-out")'); const reads = await b.evaluate('window.__rreads.length'); await b.evaluate('location.hash="#/chat";true'); await new Promise(r => setTimeout(r, 200)); assert.equal(await b.evaluate('window.__rreads.length'), reads, 'no read after sign-out'); assert.doesNotMatch(await b.evaluate('document.body.innerText'), /private words/); assert.equal((await writes()).length, 1); // A 401 from the Console on a request read signs the page out the same way. const out = await setup(t, { boot: { states: { [R]: ASKING } } }); await out.w(`document.querySelector('${P} form.request-answer')`); await out.b.evaluate(`window.__readStatus['${R}']=401;document.querySelector('${P} [data-request-reload]').click();true`); await out.w('document.body.classList.contains("signed-out")'); }); test('PRD panel: exact candidate, full requirements and version are inert, visible before approval and read-only without writes', async t => { const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); await e.w(`document.querySelector('${P} .request-prd-text')`); assert.equal(await e.text(`${P} .request-prd-text`), '# Timer 1.2\n' + HOSTILE.replace('\u001b', '\\u001b').replace('\u202e', '\\u202e')); assert.match(await e.text(`${P} .request-prd`), /1\.2/); for (const value of [CANDIDATE.records_commit, CANDIDATE.prd_path, CANDIDATE.prd_blob, CANDIDATE.spec_commit, 'REQ-TIMER-1', CANDIDATE.requirements[0].blob]) assert.ok((await e.text(`${P} .request-prd`)).includes(value), value); assert.ok((await e.text(`${P} .request-requirements`)).includes('Counts down')); assert.equal(await e.count(`${P} img, ${P} script`), 0); assert.equal(await e.b.evaluate('window.injected'), undefined); assert.deepEqual(await e.writes(), []); assert.equal(await e.count(`${P} [data-request-choice]`), 2); const ro = await setup(t, { suffix: '?readonly', boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT }, inbox: PRD_INBOX } }); await ro.w(`document.querySelector('${P} .request-prd-text')`); assert.equal(await ro.count(`${P} [data-request-choice], ${P} [data-request-revoke]`), 0); assert.match(await ro.text(`${P} .request-snapshot`), /prd-approved/); assert.deepEqual(await ro.writes(), []); }); test('PRD panel: guided approval names the displayed exact version through the existing one-use confirmation', async t => { const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); await e.w(`document.querySelector('${P} [data-request-choice="yes"]')`); await e.answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'yes', text: 'Approve this exact product PRD version', question: PRD_INBOX[0].question, confirmation: 'prd-token' } }); await e.click(`${P} [data-request-choice="yes"]`); await e.w(`document.querySelector('${P} [data-request-confirm]')`); assert.match(await e.text(`${P} .request-confirm`), /1\.2/); assert.ok((await e.text(`${P} .request-confirm`)).includes(CANDIDATE.records_commit)); assert.equal((await e.writes()).length, 1, 'preview never resolves'); await e.b.evaluate(`window.__states['${R}']=${JSON.stringify(APPROVED)};window.__drafts['${R}']=${JSON.stringify(ACTIVE_DRAFT)};true`); await e.answer({ body: { id: D, op: 'resolved', choice: 'yes' } }); await e.click(`${P} [data-request-confirm]`); await e.w(`document.querySelector('${P} .request-snapshot')?.textContent.includes('prd-approved')`); assert.deepEqual((await e.writes()).map(x => [x.path, x.body]), [ [`/api/decisions/${D}/confirmation`, { choice: 'yes' }], [`/api/decisions/${D}/resolve`, { choice: 'yes', confirmation: 'prd-token' }], ]); assert.match(await e.text(`${P} .request-receipt`), /Recorded/); }); test('PRD panel: autonomous approval stays with its arbiter and an unavailable, stale or mismatched draft offers no approval', async t => { const e = await setup(t, { boot: { states: { [R]: { ...REVIEWING, mode: 'autonomous' } }, drafts: { [R]: DRAFT }, inbox: [] } }); await e.w(`document.querySelector('${P} .request-prd-text')`); assert.equal(await e.count(`${P} [data-request-choice]`), 0); for (const [status, error] of [[403, 'human-required'], [409, 'prd-candidate-stale'], [409, 'request-revision-conflict'], [503, 'unavailable']]) { await e.b.evaluate(`window.__draftStatus['${R}']={status:${status},body:{error:'${error}',message:'Draft refused.'}};true`); await e.click(`${P} [data-request-reload]`); await e.w(`document.querySelector('${P} .request-prd-error')?.textContent.includes('${error}')`); assert.equal(await e.count(`${P} .request-prd-text, ${P} [data-request-choice], ${P} [data-request-revoke]`), 0); } await e.b.evaluate(`delete window.__draftStatus['${R}'];window.__drafts['${R}']=${JSON.stringify({ ...DRAFT, candidate: { ...CANDIDATE, records_commit: 'f'.repeat(40) } })};window.__inbox=${JSON.stringify(PRD_INBOX)};true`); await e.click(`${P} [data-request-reload]`); await e.w(`document.querySelector('${P} .request-prd-error')`); assert.equal(await e.count(`${P} [data-request-choice]`), 0, 'a different candidate cannot be approved as the request candidate'); assert.deepEqual(await e.writes(), []); }); test('PRD panel: refreshing a changed candidate drops an open confirmation', async t => { const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); await e.w(`document.querySelector('${P} [data-request-choice="yes"]')`); await e.answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'yes', text: 'Approve', question: PRD_INBOX[0].question, confirmation: 'stale-token' } }); await e.click(`${P} [data-request-choice="yes"]`); await e.w(`document.querySelector('${P} [data-request-confirm]')`); await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...ASKING, revision: 12 })};true`); await e.click(`${P} [data-request-reload]`); await e.w(`!document.querySelector('${P} [data-request-confirm]')`); assert.equal((await e.writes()).length, 1); }); test('PRD revoke: asks for a reason and confirmation, sends the snapshot counter and reads the resulting empty draft', async t => { const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); await e.w(`document.querySelector('${P} [data-request-revoke]')`); await e.click(`${P} [data-request-revoke]`); assert.deepEqual(await e.writes(), []); await e.click(`${P} [data-request-revoke-confirm]`); assert.deepEqual(await e.writes(), []); assert.match(await e.text(`${P} .request-receipt`), /reason/i); await e.set(`${P} .request-revoke textarea`, 'x'.repeat(1025)); await e.click(`${P} [data-request-revoke-confirm]`); assert.deepEqual(await e.writes(), []); await e.set(`${P} .request-revoke textarea`, 'Replace the requirements'); await e.b.evaluate(`window.__drafts['${R}']=${JSON.stringify({ candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 4 } })};true`); await e.answer({ set: { ...ASKING, revision: 15, question: null, candidate: null, snapshot: null }, body: { snapshot: 'prd-approved', revision: 4, kind: 'revoked', cause: 'host' } }); await e.click(`${P} [data-request-revoke-confirm]`); await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('revoked') && !document.querySelector('${P} [data-request-revoke]')`); const [sent] = await e.writes(); assert.equal(sent.path, `/api/requests/${R}/revoke`); assert.deepEqual({ ...sent.body, op: undefined }, { project: 'factory', snapshot: 'prd-approved', revision: 3, reason: 'Replace the requirements', op: undefined }); assert.notEqual(sent.body.revision, APPROVED.revision); assert.match(sent.body.op, /^revoke-[0-9a-f]{24}$/); assert.equal(sent.redirect, 'manual'); await e.w(`document.querySelector('${P} .request-prd-empty')`); }); test('PRD revoke: stale, refused, malformed and lost outcomes never show success or retry', async t => { const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); await e.w(`document.querySelector('${P} [data-request-revoke]')`); for (const [reply, receipt] of [ [{ status: 409, body: { error: 'request-revision-conflict', message: 'Snapshot changed.' } }, 'request-revision-conflict'], [{ status: 403, body: { error: 'requirement-snapshot-refused', message: 'Snapshot refused.' } }, 'requirement-snapshot-refused'], [{ status: 503, body: { error: 'unavailable', message: 'Revoke refused.' } }, 'unavailable'], [{ status: 503, body: { error: 'outcome-unknown', message: 'The revoke may have reached the bus.' } }, 'unknown'], [{ body: { snapshot: 'wrong', revision: 4, kind: 'revoked' } }, 'unknown'], [{ reject: true }, 'unknown'], ]) { if (await e.count(`${P} [data-request-revoke]`)) await e.click(`${P} [data-request-revoke]`); await e.set(`${P} .request-revoke textarea`, 'Replace it'); await e.answer(reply); const n = (await e.writes()).length; await e.click(`${P} [data-request-revoke-confirm]`); await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('${receipt}')`); assert.doesNotMatch(await e.text(`${P} .request-receipt`), /^Approved snapshot revoked/); assert.match(await e.text(`${P} .request-receipt`), receipt === 'unknown' ? /^Outcome unknown/ : /^Not revoked/); await e.w(`!document.querySelector('${P} [data-request-reload]')?.disabled`); assert.equal((await e.writes()).length, n + 1); } }); test('PRD revoke: a stale refusal rereads the project counter before the next deliberate click', async t => { const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); await e.w(`document.querySelector('${P} [data-request-revoke]')`); await e.click(`${P} [data-request-revoke]`); await e.set(`${P} .request-revoke textarea`, 'Replace it'); await e.b.evaluate(`window.__drafts['${R}'].snapshot.revision=5;true`); await e.answer({ status: 409, body: { error: 'request-revision-conflict', message: 'Read the snapshot again.' } }); await e.click(`${P} [data-request-revoke-confirm]`); await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('request-revision-conflict') && !document.querySelector('${P} [data-request-reload]')?.disabled`); assert.equal((await e.writes()).length, 1); await e.answer({ status: 403, body: { error: 'requirement-snapshot-refused', message: 'Refused.' } }); await e.click(`${P} [data-request-revoke-confirm]`); await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('requirement-snapshot-refused')`); const writes = await e.writes(); assert.deepEqual(writes.map(x => x.body.revision), [3, 5]); assert.notEqual(writes[1].body.op, writes[0].body.op); }); test('PRD panel: sign-out clears candidate material and ignores a late draft read', async t => { const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } }); await e.w(`document.querySelector('${P} .request-prd-text')`); await e.b.evaluate('window.__draftHold=true;true'); await e.click(`${P} [data-request-reload]`); await e.w('typeof window.__releaseDraft === "function"'); await e.b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseDraft();true'); await e.w('document.body.classList.contains("signed-out")'); assert.equal(await e.count('.request-prd-text, [data-request-revoke], [data-request-choice]'), 0); assert.doesNotMatch(await e.b.evaluate('document.body.innerText'), /Counts down|Timer 1\.2/); assert.deepEqual(await e.writes(), []); }); test('PRD panel: a successful mode change immediately clears the invalidated draft and revoke control', async t => { const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } }); await e.w(`document.querySelector('${P} [data-request-revoke]')`); await e.answer({ set: { ...ASKING, revision: 15, question: null, mode: 'autonomous', mode_revision: 2 } }); await e.click(`${P} [data-request-mode="autonomous"]`); await e.w(`document.querySelector('${P} .request-mode')?.textContent.includes('autonomous')`); assert.equal(await e.count(`${P} .request-prd-text, ${P} .request-snapshot, ${P} [data-request-revoke]`), 0); assert.equal((await e.writes()).length, 1); }); test('legacy complete requests without delivery evidence are labeled without a verified claim or error', async t => { const old = state({ ...APPROVED, phase: 'complete', delivery: null }); const { w, text } = await setup(t, { boot: { states: { [R]: old }, drafts: { [R]: ACTIVE_DRAFT } } }); await w(`document.querySelector('${P}') && document.querySelector('${P}').textContent.toLowerCase().includes('completed before delivery evidence')`); const content = await text(P); assert.match(content, /completed before delivery evidence/i); assert.doesNotMatch(content, /verified delivery|delivery error/i); }); test('reviewed delivery shows a download and bound evidence separately from deployment', async t => { const evidence = { version: 1, id: 'delivery-1', request: R, business: 'demo', project: 'factory', snapshot: 'prd-approved', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { path: 'timer.html', blob: 'b'.repeat(40), sha256: 'c'.repeat(64), bytes: 100, mime: 'text/html' }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) }, message: 'notice-1' }; const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence }); const { w, text, b } = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } }); await w(`document.querySelector('${P} .request-delivery')`); const panel = await text(`${P} .request-delivery`); assert.match(panel, /Reviewed delivery/); assert.match(panel, /not landed or deployed/); for (const value of [evidence.snapshot, evidence.task_ref, evidence.commit, evidence.review_request, evidence.verdict, evidence.verification.record_commit, evidence.artifact.sha256]) assert.ok(panel.includes(value)); assert.equal(await b.evaluate(`document.querySelector('${P} .request-delivery a').getAttribute('href')`), `/api/requests/${R}/artifact/delivery-1`); }); test('reviewed delivery refuses evidence bound to another project or snapshot', async t => { const evidence = { id: 'delivery-1', request: R, project: 'other', snapshot: 'prd-approved', task_ref: 'vikunja:2/41', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } }; const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence }); const e = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } }); await e.w(`document.querySelector('${P} .request-snapshot')`); assert.equal(await e.count(`${P} .request-delivery`), 0); await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...complete, revision: 15, delivery_evidence: { ...evidence, project: 'factory', snapshot: 'other' } })};true`); await e.click(`${P} [data-request-reload]`); await e.w(`window.__rreads.filter(p => p === '/api/requests/${R}').length >= 2 && document.querySelector('${P} [data-request-reload]')`); assert.equal(await e.count(`${P} .request-delivery`), 0); assert.deepEqual(await e.writes(), []); });