diff --git a/packages/cli/live/README.md b/packages/cli/live/README.md index 400412ad0..cde44c01f 100644 --- a/packages/cli/live/README.md +++ b/packages/cli/live/README.md @@ -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 diff --git a/packages/cli/live/ui-browser.mjs b/packages/cli/live/ui-browser.mjs index cb44d5a66..203483d9f 100644 --- a/packages/cli/live/ui-browser.mjs +++ b/packages/cli/live/ui-browser.mjs @@ -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 }); } diff --git a/packages/cli/live/ui-browser.test.mjs b/packages/cli/live/ui-browser.test.mjs index cc346489f..7cceda26b 100644 --- a/packages/cli/live/ui-browser.test.mjs +++ b/packages/cli/live/ui-browser.test.mjs @@ -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,`

Dialog fixture

idle`); + 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,'

safe

Escape'); await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')});