// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser. // A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide` // in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording // what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is // packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker // through its reader session. Refresh timers fire after 150 ms. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../../bus/src/store.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { views } from '../../bus/src/views.mjs'; import { grantDecision } from '../../bus/tests/host-fixtures.mjs'; import { startServer } from '../src/serve.mjs'; import { browser } from './browser.mjs'; import { fixture, close } from './fixture.mjs'; const HOSTILE = ''; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } }; // Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect` // what fetch gives for the sign-in proxy's redirect under redirect: 'manual'. const STUB = `(() => { const real = window.fetch.bind(window); window.__posts = []; window.__answers = []; const timeout = window.setTimeout.bind(window); window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest); const q = new URLSearchParams(location.search); const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); window.fetch = async (url, init = {}) => { const u = String(url); if (u === '/api/config') { const c = await (await real(url, init)).json(); return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) }); } if (window.__delayRead?.url === u) { const delay = window.__delayRead; window.__delayRead = null; const response = await real(url, init); window.__readHeld = true; await new Promise(resolve => { window.__releaseRead = resolve; }); if (delay.reject) throw new TypeError('Delayed read failed'); return response; } if (u === '/api/bus/inbox' && window.__changedDecision) { const body = await (await real(url, init)).json(); body.rows = body.rows.map(row => row.id === window.__changedDecision ? { ...row, console: { ...row.console, digest: 'e'.repeat(64) } } : row); return answer(200, body); } if (!u.startsWith('/api/decisions/')) return real(url, init); window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect }); const a = window.__answers.shift(); if (!a) throw new TypeError('no answer queued'); if (a.hold) await new Promise(r => { window.__release = r; }); if (a.reject) throw new TypeError('Failed to fetch'); if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } }; return answer(a.status, a.body); }; })();`; // The open grant request, as host-fixtures' grantDecision writes it but with the hostile question. // decisions is append-only (migration 3b), so the question goes in with the row, not by an UPDATE. function hostileGrant(store, grant) { const id = randomUUID(), at = '2026-10-21T00:00:00.000Z'; store.run( 'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', id, at, 'demo', 'chief', 'chief-run', 'gated', 'credential.mint', 'human', `Enroll web1? ${HOSTILE}`, JSON.stringify([{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]), 'yes', 0, ); store.run( "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)", randomUUID(), at, 'demo', 'chief', 'chief-run', id, JSON.stringify({ decision: id, action: 'credential.mint', operation: 'decision.raise', approvalChoice: 'yes', grant }), ); return id; } async function setup({ ordinary = false, paired = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-')); const store = new Store(root), b0 = new Broker({ store, businesses }); const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] }; const open = hostileGrant(store, grant); const deploy = grantDecision(store, { action: 'deploy', grant, close: null }); const bare = grantDecision(store, { close: null }); const chief = b0.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' }); b0.request(chief, { verb: 'role.claim' }); const normal = ordinary ? b0.request(chief, { verb: 'decision.raise', args: { action: 'prd.approve', question: `Choose a project ${HOSTILE}`, blocking: false, options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: `Add to existing project ${HOSTILE}` }], recommendation: 'new/autonomous', approvalChoice: 'new/autonomous', } }).id : null; const other = paired ? b0.request(chief, { verb: 'decision.raise', args: { action: 'prd.approve', question: 'Decision B: choose a project', blocking: false, options: [{ key: 'B-0', text: 'B autonomous' }, { key: 'B-1', text: 'B guided' }, { key: 'B-2', text: 'B existing project' }], recommendation: 'B-0', approvalChoice: 'B-0', } }).id : null; const reader = b0.bindReader({ business: 'demo' }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const f = await fixture(), b = await browser(); const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.viewport(1280); const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`); const posts = () => b.evaluate('window.__posts'); const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`); const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`); const page = async (query, id) => { // The same URL again is a same-document hash change, not a load, so load about:blank first (as editor-browser does). await b.navigate('about:blank'); await b.navigate(`${base}?${query}#/inbox/${id}`); await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")'); // The config read redraws once it lands; a refresh tick after it settles the page. await new Promise(r => setTimeout(r, 400)); }; const settle = () => new Promise(r => setTimeout(r, 600)); const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; return { b, store, base, open, deploy, bare, normal, other, wait, text, has, posts, queue, click, page, settle, done }; } const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } }); test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => { const s = await setup(); try { // Without SSO: no panel, no button, and the reason names both flags. await s.page('x', s.open); assert.equal(await s.has('#decide-section'), false); assert.equal(await s.has('[data-decide]'), false); assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/); assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2); // SSO without --send: read-only, still the terminal lines. await s.page('sso', s.open); assert.equal(await s.has('#decide-section'), false); assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/); // send alone, without decide, still draws nothing. await s.page('sso&send', s.open); assert.equal(await s.has('#decide-section'), false); // A tester Console never answers, whatever the config says. await s.page('sso&decide&tester', s.open); assert.equal(await s.has('#decide-section'), false); // Eligible: the panel, both buttons, the escaped question and the terminal lines too. await s.page('sso&decide', s.open); assert.equal(await s.text('#decide-section h2'), 'Answer in Console'); assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]); assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./); assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2); assert.equal(await s.b.evaluate('window.injected ?? null'), null); assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE)); // A grant raise with another action, and a credential.mint with no grant, keep the terminal only. for (const id of [s.deploy, s.bare]) { await s.page('sso&decide', id); assert.equal(await s.has('#decide-section'), false); assert.equal(await s.has('[data-decide]'), false); } // The Inbox says so, and marks only the eligible row. await s.b.navigate(`${s.base}?sso&decide#/inbox`); await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3'); await s.settle(); assert.match(await s.text('#bus-view'), /Open an eligible decision to answer any of its options here, after a confirmation\./); assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1); assert.deepEqual(await s.posts(), []); } finally { await s.done(); } }); test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => { const s = await setup(); const enc = encodeURIComponent; try { await s.page('sso&decide', s.open); // Approve asks for a confirmation and answers nothing. The button holds while the request is out. await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true }); await s.click('approve'); await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"'); assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true); await s.click('approve'); await s.b.evaluate('window.__release(), true'); await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); let sent = await s.posts(); assert.equal(sent.length, 1); assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' }); assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve'); assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./); assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm'); assert.equal(await s.b.evaluate('window.injected ?? null'), null); // A refresh keeps the confirmation open. await s.settle(); assert.equal(await s.has('#decide-section [data-decide=confirm]'), true); // Cancel drops it without a request. await s.click('cancel'); await s.wait('document.querySelector("#decide-section [data-decide=approve]")'); assert.equal((await s.posts()).length, 1); // A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable. await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } }); await s.click('deny'); await s.wait('document.querySelector("#decide-status")'); assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).'); await s.queue({ status: 408, body: { error: 'x', message: 'y' } }); await s.click('deny'); await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"'); await s.queue({ reject: true }); await s.click('deny'); await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2'); assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable'); assert.equal((await s.posts()).length, 4); // A reply that doesn't match what was asked is not a confirmation. await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } }); await s.click('deny'); await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2'); assert.equal(await s.has('#decide-section [data-decide=confirm]'), false); // An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again. await s.queue(CONFIRM(s, 'deny', 'no', 'No')); await s.click('deny'); await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./); await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } }); await s.click('confirm'); await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"'); assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/); sent = await s.posts(); assert.equal(sent.length, 7); assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' }); assert.equal(await s.has('#decide-section [data-decide=confirm]'), false); await s.settle(); await s.settle(); assert.equal((await s.posts()).length, 7, 'the page sent again by itself'); // A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes. await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes')); await s.click('approve'); await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } }); await s.click('confirm'); await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")'); assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.'); await s.settle(); assert.equal(await s.has('[data-decide]'), false); assert.equal((await s.posts()).length, 9); // Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance. s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console'); await s.page('sso&decide', s.open); assert.equal(await s.has('#decide-section'), false); assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/); assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0); } finally { await s.done(); } }); test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => { const s = await setup(); try { await s.page('sso&decide', s.open); await s.queue({ redirect: true }); await s.click('approve'); await s.wait('document.body.classList.contains("signed-out")'); assert.equal((await s.posts()).length, 1); await s.page('sso&decide', s.open); await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes')); await s.click('approve'); await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); await s.queue({ redirect: true }); await s.click('confirm'); await s.wait('document.body.classList.contains("signed-out")'); assert.equal((await s.posts()).length, 2); } finally { await s.done(); } }); test('ordinary choices render every key/text, cancel and navigation discard confirmations, and the third option records an accurate receipt', { timeout: 120000 }, async () => { const s = await setup({ ordinary: true }); const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: `Choose a project ${HOSTILE}`, choice: 'existing:project', text: `Add to existing project ${HOSTILE}`, confirmation: 'd'.repeat(64) } }; try { await s.page('sso&decide', s.normal); assert.equal(await s.has('#decide-section'), true); assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => e.textContent)'), ['New project — autonomous', 'New project — guided', `Add to existing project ${HOSTILE}`]); assert.equal(await s.b.evaluate('window.injected ?? null'), null); assert.doesNotMatch(await s.text('#decide-section'), /Grant:|Approve|Deny/); await s.queue({ ...confirmation, hold: true }); await s.click('option-2'); await s.click('option-2'); await s.b.evaluate('window.__release(), true'); await s.wait('document.querySelector("[data-decide=confirm]")'); assert.equal((await s.posts()).length, 1); assert.match(await s.text('.s1-confirm'), /Confirm answer/); assert.doesNotMatch(await s.text('.s1-confirm'), /Grant|Approving|Denying/); await s.settle(); assert.equal(await s.has('[data-decide=confirm]'), true); await s.click('cancel'); assert.equal(await s.has('[data-decide=confirm]'), false); assert.equal((await s.posts()).length, 1); await s.queue(confirmation); await s.click('option-2'); await s.wait('document.querySelector("[data-decide=confirm]")'); await s.b.navigate(`${s.base}?sso&decide#/inbox`); await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")'); await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`); await s.wait('document.querySelector("#decide-section")'); assert.equal(await s.has('[data-decide=confirm]'), false, 'leaving the decision must discard its confirmation'); assert.equal((await s.posts()).length, 2); // Navigation while the preview is in flight must also discard the late answer. await s.queue({ ...confirmation, hold: true }); await s.click('option-2'); await s.b.navigate(`${s.base}?sso&decide#/inbox`); await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")'); await s.b.evaluate('window.__release(), true'); await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`); await s.wait('document.querySelector("#decide-section")'); assert.equal(await s.has('[data-decide=confirm]'), false); await s.queue(confirmation); await s.click('option-2'); await s.wait('document.querySelector("[data-decide=confirm]")'); await s.queue({ status: 200, hold: true, body: { id: s.normal, op: 'resolved', choice: 'existing:project' } }); await s.click('confirm'); await s.click('confirm'); await s.b.evaluate('window.__release(), true'); await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); assert.match(await s.text('#decide-status'), /Recorded: you chose “existing:project”, Add to existing project/); assert.doesNotMatch(await s.text('#decide-status'), /grant|denial|Approved|Denied|Nothing starts/); await s.settle(); const posts = await s.posts(); assert.equal(posts.filter(p => p.url.endsWith('/resolve')).length, 1); assert.equal(await s.has('[data-decide]'), false); } finally { await s.done(); } }); test('ordinary confirmation is dropped on closure, reload, session expiry and uncertain outcome without automatic replay', { timeout: 120000 }, async () => { const s = await setup({ ordinary: true }); const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/guided', text: 'New project — guided', confirmation: 'd'.repeat(64) } }; try { await s.page('sso&decide', s.normal); assert.equal(await s.has('#decide-section'), true); await s.queue(confirmation); await s.click('option-1'); await s.wait('document.querySelector("[data-decide=confirm]")'); await s.page('sso&decide', s.normal); assert.equal(await s.has('[data-decide=confirm]'), false); assert.equal((await s.posts()).length, 0); await s.queue(confirmation); await s.click('option-1'); await s.wait('document.querySelector("[data-decide=confirm]")'); await s.queue({ reject: true }); await s.click('confirm'); await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"'); await s.settle(); assert.equal((await s.posts()).length, 2); assert.equal(await s.has('[data-decide=confirm]'), false); await s.queue(confirmation); await s.click('option-1'); await s.wait('document.querySelector("[data-decide=confirm]")'); await s.queue({ status: 401, body: { error: 'sign-in-required', message: 'Sign in again before answering.' } }); await s.click('confirm'); await s.wait('document.querySelector("#decide-status code")?.textContent === "sign-in-required" || document.body.classList.contains("signed-out")'); assert.equal(await s.has('[data-decide=confirm]'), false); assert.equal((await s.posts()).length, 4); await s.page('sso&decide', s.normal); await s.queue(confirmation); await s.click('option-1'); await s.wait('document.querySelector("[data-decide=confirm]")'); s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.normal, '2026-11-03T00:00:00.000Z', 'resolved', 'jason', 'new/autonomous', 'cli'); await s.wait('!document.querySelector("#decide-section")'); assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0); } finally { await s.done(); } }); test('a changed authoritative context drops an open ordinary confirmation before another answer can be submitted', { timeout: 120000 }, async () => { const s = await setup({ ordinary: true }); try { await s.page('sso&decide', s.normal); await s.queue({ status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/autonomous', text: 'New project — autonomous', confirmation: 'c'.repeat(64) } }); await s.click('option-0'); await s.wait('document.querySelector("[data-decide=confirm]")'); await s.b.evaluate(`window.__changedDecision = ${JSON.stringify(s.normal)}`); await s.wait('!document.querySelector("[data-decide=confirm]") && document.querySelector("[data-decide=option-0]")'); assert.equal((await s.posts()).length, 1); assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0); } finally { await s.done(); } }); for (const [read, reject] of [['inbox', false], ['trail', false], ['trail', true]]) { test(`I1: obsolete A ${read} read${reject ? ' failure with cached fallback' : ' completion'} cannot redirect B answer controls`, { timeout: 120000 }, async () => { const s = await setup({ ordinary: true, paired: true }); try { // Warm A's cache so the failed read also exercises the kept-data continuation. await s.page('sso&decide', s.normal); await s.b.evaluate('document.querySelector("#pause").click(), true'); assert.equal(await s.b.evaluate('document.querySelector("#pause").getAttribute("aria-pressed")'), 'true'); const url = read === 'inbox' ? '/api/bus/inbox' : `/api/bus/trail?subject=${encodeURIComponent(s.normal)}`; await s.b.evaluate(`window.__delayRead = ${JSON.stringify({ url, reject })}; document.querySelector('#refresh').click(); true`); await s.wait('window.__readHeld === true'); await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.other}`); await s.wait('document.querySelector("#bus-view .s1-q")?.textContent === "Decision B: choose a project" && document.querySelector("[data-decide=option-2]")?.textContent === "B existing project"'); await s.b.evaluate('window.__releaseRead(), true'); await s.settle(); assert.equal(await s.text('#bus-view .s1-q'), 'Decision B: choose a project'); assert.equal(await s.text('[data-decide=option-2]'), 'B existing project'); await s.queue({ status: 200, body: { id: s.other, kind: 'decision', action: 'answer', question: 'Decision B: choose a project', choice: 'B-2', text: 'B existing project', confirmation: 'b'.repeat(64) } }); await s.click('option-2'); const [preview] = await s.posts(); assert.equal(preview.url, `/api/decisions/${encodeURIComponent(s.other)}/confirmation`, 'B controls must request confirmation for B after the obsolete A continuation'); assert.deepEqual(JSON.parse(preview.body), { choice: 'B-2' }); await s.wait('document.querySelector("[data-decide=confirm]")'); assert.equal(await s.text('#bus-view .s1-q'), 'Decision B: choose a project'); assert.match(await s.text('.s1-confirm'), /B-2.*B existing project/); await s.queue({ status: 200, body: { id: s.other, op: 'resolved', choice: 'B-2' } }); await s.click('confirm'); await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); const posts = await s.posts(); assert.equal(posts.length, 2); assert.equal(posts[1].url, `/api/decisions/${encodeURIComponent(s.other)}/resolve`); assert.deepEqual(JSON.parse(posts[1].body), { choice: 'B-2', confirmation: 'b'.repeat(64) }); } finally { await s.done(); } }); }