Files
stack/packages/webui/tests/decide-browser.test.mjs

427 lines
27 KiB
JavaScript

// 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 = '<script>window.injected=1</script><img src=x onerror="window.injected=2">';
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(); }
});
}