Files
stack/packages/webui/tests/request-browser.test.mjs
jason.woltje 6a655a5cbb Merge commit '5255e4e74171cb0f640a2fcd83c77806b5b6f3d6' into feat/mvp3-project-archive-r4
# Conflicts:
#	BUILD-LOG.md
#	docs/SESSIONS.md
#	packages/bus/tests/request-continuation.test.mjs
#	packages/webui/src/public/chat.js
#	packages/webui/src/public/index.html
#	packages/webui/src/serve.mjs
#	packages/webui/tests/browser.test.mjs
2026-11-06 03:32:15 -06:00

595 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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 = '<img src=x onerror="window.injected=1">\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 <b>x</b>';
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(), []);
});