fix(cli): answer native dialogs in the tester browser (#797)

A click on the test Console's Resume button opened window.confirm and the
browser daemon hung: the mouse release, screenshots and the frame tree all
block until the dialog is answered, and ui-browser had no way to answer it.

A click that opens a dialog now returns {dialog:{type,message}}. The new
command `dialog accept|dismiss` answers it and records the type, message
and answer in the receipt. While a dialog is open other commands except
stop refuse (journaled), and no screenshot or URL check is attempted.
A real-Chromium confirm test covers accept, dismiss and the refusals.
This commit is contained in:
jason.woltje committed 2026-11-06 00:13:16 -06:00
1 parent 22e3d21889
commit 5df42478b6
3 files changed
+60 -5

No files matched your search

+5
View File
@@ -195,6 +195,11 @@ letters/digits, with Ctrl/Control, Alt, Meta or Shift modifiers (for example
`press Ctrl+A`, `press Backspace` to clear a focused field). Clicks use
real CDP mouse input. No page-function calls or fetch/state mocks are injected.
`wait` is bounded to 120 seconds per invocation. No uncertain write is replayed.
A click that opens a native alert/confirm/prompt returns `{dialog:{type,message}}`;
answer it with `dialog accept` or `dialog dismiss` (for example the Console's
Resume confirmation). While a dialog is open every other command except `stop`
refuses, and no screenshot is taken because the page is blocked; the receipt
records the dialog type, message and answer.
Each accepted action, including failed commands, start and stop, writes
`NNNNN-before.png`, `NNNNN-after.png` and one `actions.jsonl` receipt with result
+28 -5
View File
@@ -167,6 +167,11 @@ export async function daemon(evidence) {
b.on('Browser.downloadWillBegin', e => { pendingDownloads.add(e.guid); });
b.on('Browser.downloadProgress', e => { if (e.state === 'completed') { completed.set(e.guid, true); pendingDownloads.delete(e.guid); } });
b.on('Page.frameNavigated', e => { if (!e.frame.parentId) { generation++; refs.clear(); } });
// A native alert/confirm/prompt blocks the page (and input, screenshots and the AX tree) until it
// is answered, so the tester answers it with `dialog accept|dismiss`; its type and text are recorded.
let dialog = null, dialogOpened = () => {};
b.on('Page.javascriptDialogOpening', e => { dialog = { type:e.type, message:String(e.message).slice(0,500) }; dialogOpened(); });
b.on('Page.javascriptDialogClosed', () => { dialog = null; });
const refused = async id => {
blocked = true;
await b.call('Fetch.failRequest', { requestId:id, errorReason:'BlockedByClient' }).catch(() => {});
@@ -223,7 +228,11 @@ export async function daemon(evidence) {
const x = (q[0]+q[2]+q[4]+q[6])/4, y = (q[1]+q[3]+q[5]+q[7])/4;
await b.call('Input.dispatchMouseEvent', { type:'mouseMoved',x,y });
await b.call('Input.dispatchMouseEvent', { type:'mousePressed',x,y,button:'left',clickCount:1 });
await b.call('Input.dispatchMouseEvent', { type:'mouseReleased',x,y,button:'left',clickCount:1 });
// The release does not complete while a dialog it opened is waiting; return when one opens.
const opened = new Promise(resolve => { dialogOpened = resolve; });
const released = b.call('Input.dispatchMouseEvent', { type:'mouseReleased',x,y,button:'left',clickCount:1 });
released.catch(() => {});
try { await Promise.race([released, opened]); } finally { dialogOpened = () => {}; }
}
async function press(key) {
const parts = key.split('+'), base = parts.pop(); let modifiers = 0;
@@ -264,7 +273,16 @@ export async function daemon(evidence) {
const s = await snapshot(), found = s.elements.filter(e => e.name === args[1] && !e.disabled);
if (found.length !== 1) fail('text must name exactly one enabled interactive element'); ref = found[0].ref;
} else if (args.length === 1) ref = args[0]; else fail('click REF or --text TEXT');
if (/^r[0-9]+-[0-9]+$/.test(ref)) detail.ref = ref; await focus(ref); await delay(150); return snapshot();
if (/^r[0-9]+-[0-9]+$/.test(ref)) detail.ref = ref; await focus(ref); await delay(150);
if (dialog) { detail.dialog = { ...dialog }; return { dialog:{ ...dialog } }; }
return snapshot();
}
case 'dialog': {
if (args.length !== 1 || !['accept','dismiss'].includes(args[0])) fail('dialog accept|dismiss');
if (!dialog) fail('no dialog is open');
detail.dialog = { ...dialog, answer:args[0] };
await b.call('Page.handleJavaScriptDialog', { accept:args[0] === 'accept' });
dialog = null; await delay(150); return snapshot();
}
case 'type': if (args.length !== 2) fail('type REF TEXT'); if (/^r[0-9]+-[0-9]+$/.test(args[0])) detail.ref=args[0]; await focus(args[0]); await b.call('Input.insertText', { text:args[1] }); return snapshot();
case 'press': if (args.length !== 1) fail('press KEY'); await press(args[0]); await delay(100); return snapshot();
@@ -291,12 +309,17 @@ export async function daemon(evidence) {
}
async function action(command,args) {
detail = {};
const n = String(++sequence).padStart(5,'0'), before = await shot(`${n}-before.png`);
// No screenshot can be taken while a dialog blocks the page; other commands are refused and journaled.
const n = String(++sequence).padStart(5,'0'), before = dialog ? null : await shot(`${n}-before.png`);
let result, error, after;
try { if (command !== 'stop') await currentURL(); result = await perform(command,args); if (command !== 'stop') { await currentURL(); if (blocked) fail('non-test network request blocked; stop session'); } }
try {
if (dialog && command !== 'dialog' && command !== 'stop') fail(`a ${dialog.type} dialog is open; dialog accept|dismiss`);
// The frame tree is unavailable while a dialog is open; the page cannot navigate until it closes.
if (command !== 'stop' && !dialog) await currentURL(); result = await perform(command,args);
if (command !== 'stop') { if (!dialog) await currentURL(); if (blocked) fail('non-test network request blocked; stop session'); } }
catch (e) { error = e.message; }
finally {
after = await shot(`${n}-after.png`).catch(() => null);
after = dialog ? null : await shot(`${n}-after.png`).catch(() => null);
// No text input, URLs, auth codes, tokens, cookie/header/body values in the journal.
appendFileSync(join(evidence,'actions.jsonl'), JSON.stringify({ sequence,at:new Date().toISOString(),command,...detail,before,after,ok:!error,error:error ?? null })+'\n', { mode:0o600 });
}
+27
View File
@@ -60,6 +60,33 @@ test('real Chromium keeps a session across CLI processes; mouse/keyboard refs, s
assert.ok(!JSON.stringify(receipts).includes('Pomodoro from keyboard'));
assert.ok(readdirSync(join(f.evidence,'profile')).length>0);
});
test('a click that opens a native confirm returns it; dialog accept|dismiss answers it and other commands refuse meanwhile',async t=>{
const f=await setup(t,`<!doctype html><h1>Dialog fixture</h1><button onclick="document.querySelector('output').textContent=confirm('Resume processing?')?'accepted':'dismissed'">Resume</button><output>idle</output>`);
await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});
await assert.rejects(callBrowser(f.evidence,'dialog',['accept']),/no dialog is open/);
const opened=await callBrowser(f.evidence,'click',['--text','Resume']);
assert.deepEqual(opened,{dialog:{type:'confirm',message:'Resume processing?'}});
await assert.rejects(callBrowser(f.evidence,'snapshot'),/confirm dialog is open/);
await assert.rejects(callBrowser(f.evidence,'dialog',['maybe']),/dialog accept\|dismiss/);
const accepted=await callBrowser(f.evidence,'dialog',['accept']);assert.match(accepted.text,/accepted/);
await callBrowser(f.evidence,'click',['--text','Resume']);
const dismissed=await callBrowser(f.evidence,'dialog',['dismiss']);assert.match(dismissed.text,/dismissed/);
await callBrowser(f.evidence,'stop');
const receipts=readFileSync(join(f.evidence,'actions.jsonl'),'utf8').trim().split('\n').map(JSON.parse);
const rows=receipts.slice(-8).map(r=>[r.command,r.ok,r.dialog?.type??null,r.dialog?.answer??null,!!r.before,!!r.after]);
assert.deepEqual(rows,[
['dialog',false,null,null,true,true],
['click',true,'confirm',null,true,false],
['snapshot',false,null,null,false,false],
['dialog',false,null,null,false,false],
['dialog',true,'confirm','accept',false,true],
['click',true,'confirm',null,true,false],
['dialog',true,'confirm','dismiss',false,true],
['stop',true,null,null,true,true],
]);
assert.equal(receipts.find(r=>r.dialog?.answer==='accept').dialog.message,'Resume processing?');
for(const r of receipts)for(const file of [r.before,r.after])if(file)assert.ok(existsSync(join(f.evidence,file)));
});
test('redirects to the live port are blocked before a request reaches it',async t=>{
const f=await setup(t,'<!doctype html><h1>safe</h1><a href="/redirect">Escape</a>');
await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});