import test from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp,rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {runTask, purposeFor, localEndpoint} from '../runtime/browser.mjs'; import {Workflow} from '../runtime/workflow.mjs'; const task={name:'runtime-test',url:'http://127.0.0.1:9000',allowedOrigins:['http://127.0.0.1:9000'],controls:[{id:'save',op:'click',label:'Save',selector:'#save',purpose:'submit'}],success:{kind:'text',selector:'#status',value:'Saved'}}; const action={...task.controls[0],current:'wrong',expected:'assigned',viewport:'inside',enabled:true,blocked:false}; const response=choice=>({checkpoint:{id:'NanoJev-Web/browser-head-v5'},execution:{input_tokens:10,output_tokens:0},states:[{answers:{next:{choice,probabilities:{[choice]:1}}}}]}); function fake(){return {calls:0,ms:0,saved:false,observe:async function(){return {signature:this.saved?'saved':'empty',state:'test state',s:{success:this.saved,busy:false},actions:this.saved?[]:[action],dom:{success:this.saved,controls:[{...action,count:1}]}}},act:async function(){this.saved=true;}};} async function run(t,options){const outDir=await mkdtemp(join(tmpdir(),'nanojev-runtime-'));try{return await runTask(t,{outDir,setup:false,screenshot:false,maxSteps:8,...options});}finally{await rm(outDir,{recursive:true,force:true});}} test('legacy reveal is normalized only for a real control',()=>{assert.equal(purposeFor({op:'click',purpose:'reveal'}),'interaction');assert.equal(purposeFor({op:'fill',purpose:'reveal'}),'input');assert.equal(purposeFor({op:'click',purpose:'submit'}),'submit');}); test('remote endpoints and credentials are not accepted',()=>{assert.equal(localEndpoint('http://127.0.0.1:8774'),'http://127.0.0.1:8774');for(const url of ['https://example.com','http://user:pass@localhost:8774','http://localhost:8774/other'])assert.throws(()=>localEndpoint(url));}); test('three BLOCKED decisions have two 500ms retries and fresh observations',async()=>{const browser=fake();let observations=0;const observe=browser.observe;browser.observe=async function(){observations++;return observe.call(this)};browser.act=async()=>assert.fail('must not act');const began=performance.now();const {report,summary}=await run(task,{browser,decide:async()=>response('BLOCKED')});assert.equal(report.status,'model_blocked');assert.equal(report.trace.length,3);assert.equal(summary.metrics.blockedRetries,2);assert.ok(performance.now()-began>=1000);assert.ok(observations>=7);assert.ok(report.trace.every(r=>r.request&&r.answer&&r.afterDecision));}); test('a transient refusal can recover without replaying a browser action',async()=>{let decisions=0,actions=0;const browser=fake(),act=browser.act;browser.act=async function(){actions++;return act.call(this)};const {report}=await run(task,{browser,retryDelayMs:0,decide:async()=>response(++decisions<=2?'BLOCKED':browser.saved?'DONE':'a0')});assert.equal(report.status,'completed');assert.equal(actions,1);assert.equal(decisions,4);}); test('an uncertain click is never automatically retried',async()=>{let actions=0;const browser=fake();browser.act=async()=>{actions++;throw Error('connection lost after click')};const {report}=await run(task,{browser,decide:async()=>response('a0')});assert.equal(report.status,'action_uncertain');assert.equal(actions,1);}); test('changed observations invalidate the model action',async()=>{const browser=fake();let seq=0;const observe=browser.observe;browser.observe=async function(){return {...await observe.call(this),signature:String(++seq)}};browser.act=async()=>assert.fail('stale action');const {report}=await run(task,{browser,maxSteps:3,decide:async()=>response('a0')});assert.equal(report.status,'step_limit');assert.ok(report.trace.every(r=>!r.executed&&r.skipped==='stale_observation'));}); test('a preexisting Saved banner cannot skip workflow steps',async()=>{const browser=fake();browser.saved=true;const workflow={...task,controls:undefined,steps:[{click:'Edit'},{fill:{Name:'Example'}},{click:'Save'}],expect:{visibleText:'Saved'}};const {report}=await run(workflow,{browser,decide:async()=>response('DONE')});assert.equal(report.status,'premature_done');assert.equal(report.workflow.completedSteps,0);}); test('semantic workflow stages preserve values and advance after verified fields',()=>{const w=new Workflow({steps:[{click:'Next'},{fill:{Name:'Example',Address:'Test street'}},{select:{Plan:'Basic'}},{check:{Consent:true}},{click:'Save'}]});assert.equal(w.controls()[0].purpose,'interaction');w.acted({op:'scroll'});assert.equal(w.index,0);w.acted({op:'click'});assert.equal(w.controls()[1].value,'Test street');assert.equal(w.advance({dom:{controls:[{count:1,current:'match'},{count:1,current:'wrong'}]}}),false);assert.equal(w.advance({dom:{controls:[{count:1,current:'match'},{count:1,current:'match'}]}}),true);assert.equal(w.controls()[0].selectByLabel,true);}); test('a submitted form is not submitted again while success is missing',async()=>{let actions=0;const browser=fake();browser.act=async()=>{actions++};const {report}=await run(task,{browser,retryDelayMs:0,decide:async request=>response(Object.hasOwn(request.states[0].questions.next.criteria,'a0')?'a0':'BLOCKED')});assert.equal(actions,1);assert.equal(report.status,'model_blocked');assert.equal(report.submissionAttempted,true);}); test('field targets can be disambiguated with an explicit scope',()=>{const w=new Workflow({steps:[{fill:[{target:{name:'Name',within:'#editor'},value:'Example'}]}]});assert.equal(w.controls()[0].target.within,'#editor');}); test('hybrid is the default and model-only remains an explicit override',async()=>{for(const mode of [undefined,'model']){let filled=false,decisions=0;const input={...action,id:'name',op:'fill',purpose:'input',label:'Name',selector:'#name',value:'Example'};const browser={calls:0,ms:0,observe:async()=>({signature:filled?'filled':'empty',state:'test',s:{success:filled,busy:false},actions:filled?[]:[input],dom:{success:filled,controls:[{...input,count:1}]}}),act:async()=>{filled=true}};const opts={browser,decide:async()=>{decisions++;return response(filled?'DONE':'a0')}};if(mode)opts.mode=mode;const {report,summary}=await run(task,opts);assert.equal(report.status,'completed');assert.equal(report.mode,mode??'hybrid');assert.equal(summary.metrics.deterministicActions,mode?0:1);assert.equal(decisions,mode?2:1);}});