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:
1 parent
22e3d21889
commit
5df42478b6
3 files changed
+60
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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')});
|
||||
|
||||
Reference in new issue
Block a user