# 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
595 lines
46 KiB
JavaScript
595 lines
46 KiB
JavaScript
// #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[31mevil';
|
||
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(), []);
|
||
});
|