import test from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp,readFile,writeFile,readdir,rm,mkdir} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import http from 'node:http'; import {randomUUID} from 'node:crypto'; import {startService} from '../src/server.mjs'; import {trial,hash} from '../src/protocol.mjs'; const pause=ms=>new Promise(r=>setTimeout(r,ms)); async function environment(options={}){ const dir=await mkdtemp(join(tmpdir(),'rapp-playwright-test-')); const service=await startService({dir,port:0,...options}); const api=async(path,method='GET',body,token)=>{ const response=await fetch(service.url+path,{method,headers:{'X-Rapp-Protocol':'1',...(token?{Authorization:'Bearer '+token}:{})},body:body?JSON.stringify(body):undefined}); return {status:response.status,data:response.headers.get('content-type')?.startsWith('image')?Buffer.from(await response.arrayBuffer()):await response.json()}; }; async function submit(spec=trial){const r=await api('/v1/runs','POST',spec);assert.equal(r.status,202,JSON.stringify(r));return r.data;} async function finish(run){ const end=Date.now()+30000; while(Date.now(){await service.close();await rm(dir,{recursive:true,force:true});}}; } test('missing browser fails preflight and prevents admission',async()=>{ const previous=process.env.PLAYWRIGHT_BROWSERS_PATH; process.env.PLAYWRIGHT_BROWSERS_PATH='/nonexistent-rapp-browser'; let e; try{ e=await environment(); const ready=await e.api('/v1/preflight'); assert.equal(ready.status,503);assert.equal(ready.data.error,'browser_launch_failed'); const run=await e.api('/v1/runs','POST',trial); assert.equal(run.status,503);assert.equal(run.data.error,'browser_unavailable'); }finally{ if(e)await e.close(); if(previous===undefined)delete process.env.PLAYWRIGHT_BROWSERS_PATH; else process.env.PLAYWRIGHT_BROWSERS_PATH=previous; } }); test('real browser produces responsive keyboard evidence, fresh state and scoped artifacts',async()=>{ const e=await environment();try{ const ready=await e.api('/v1/preflight');assert.equal(ready.status,200);assert.equal(ready.data.available,true);assert.ok(ready.data.browser_version); const spec={...trial,steps:[{action:'text',selector:'#storage',value:'Fresh browser state'},...trial.steps]}; const run=await e.submit(spec),receipt=await e.finish(run); assert.equal(receipt.status,'passed',JSON.stringify(receipt));assert.equal(receipt.steps.length,16);assert.equal(receipt.artifacts.length,4); assert.equal(receipt.steps.filter(s=>s.action==='focused'&&s.status==='passed').length,2); assert.equal('token_hash' in receipt,false); const other=await e.submit(spec);assert.equal((await e.finish(other)).status,'passed'); assert.equal((await e.api('/v1/runs/'+run.id)).status,404); assert.equal((await e.api('/v1/runs/'+run.id,'GET',null,other.token)).status,404); for(const artifact of receipt.artifacts){ const path='/v1/runs/'+run.id+'/artifacts/'+artifact.name; assert.equal((await e.api(path,'GET',null,other.token)).status,404); const png=await e.api(path,'GET',null,run.token);assert.equal(png.status,200); assert.equal(png.data.readUInt32BE(16),artifact.name.startsWith('desktop')?1280:390); } const disk=await readFile(join(e.dir,run.id,'receipt.json'),'utf8');assert.ok(!disk.includes(run.token)); assert.equal((await fetch(e.service.url+'/v1/preflight')).status,409); }finally{await e.close();} }); test('UI failures, unreachable targets and forbidden redirects remain distinguishable',async()=>{ let leaked=0;const sink=http.createServer((req,res)=>{leaked++;res.end('should not be reached');});await new Promise(r=>sink.listen(0,'127.0.0.1',r)); const redirect=http.createServer((req,res)=>{res.writeHead(302,{Location:`http://127.0.0.1:${sink.address().port}/`});res.end();});await new Promise(r=>redirect.listen(0,'127.0.0.1',r)); const allow=`http://127.0.0.1:${redirect.address().port},http://127.0.0.1:9`; const e=await environment({allowedOrigins:allow,privateOrigins:allow});try{ const wrong=await e.submit({...trial,viewports:[{name:'desktop',width:1280,height:800}],steps:[{action:'text',selector:'#count',value:'sensitive-expected-value'}]}); const failed=await e.finish(wrong);assert.equal(failed.error,'ui_assertion_failed');assert.equal(failed.steps[0].status,'failed');assert.ok(!JSON.stringify(failed).includes('sensitive-expected-value')); const unreachable=await e.submit({...trial,target:'http://127.0.0.1:9/'});assert.equal((await e.finish(unreachable)).error,'target_unreachable'); const denied=await e.submit({...trial,target:`http://127.0.0.1:${redirect.address().port}/`});assert.equal((await e.finish(denied)).error,'target_not_allowed');assert.equal(leaked,0); assert.equal((await e.api('/v1/runs','POST',{...trial,target:'http://169.254.169.254/'})).status,403); }finally{await e.close();await new Promise(r=>redirect.close(r));await new Promise(r=>sink.close(r));} }); test('queue limits, cancellation, timeout, worker crash and subsequent recovery',async()=>{ const e=await environment();try{ const slow={...trial,timeout_ms:15000,steps:[{action:'visible',selector:'#never'}]}; const responses=await Promise.all(Array.from({length:12},()=>e.api('/v1/runs','POST',slow))); assert.ok(responses.some(r=>r.status===429));assert.ok(responses.filter(r=>r.status===202).length<=5); for(const r of responses.filter(r=>r.status===202)){await e.api('/v1/runs/'+r.data.id,'DELETE',null,r.data.token);assert.equal((await e.finish(r.data)).status,'cancelled');} const timed=await e.submit({...slow,timeout_ms:1000});assert.equal((await e.finish(timed)).error,'run_timeout'); const crashed=await e.submit(slow); const deadline=Date.now()+20000; while(!e.service.runs.get(crashed.id).worker?.browserPid&&Date.now()process.kill(-browserPid,0),{code:'ESRCH'}); assert.equal((await e.finish(await e.submit())).status,'passed'); assert.equal((await e.api('/v1/preflight')).data.active,0); }finally{await e.close();} }); test('restart marks abandoned runs interrupted and expiry removes artifacts',async()=>{ const dir=await mkdtemp(join(tmpdir(),'rapp-playwright-restart-')); const id=randomUUID(),token='a'.repeat(64);await mkdir(join(dir,id)); const persisted={id,token_hash:hash(token),status:'running',expires_at:new Date(Date.now()+60000).toISOString(),artifacts:[]}; await writeFile(join(dir,id,'receipt.json'),JSON.stringify(persisted));await writeFile(join(dir,id,'orphan.png'),'orphan'); const s=await startService({dir,port:0});try{ const result=await fetch(s.url+'/v1/runs/'+id,{headers:{'X-Rapp-Protocol':'1',Authorization:'Bearer '+token}}); const receipt=await result.json();assert.equal(receipt.status,'interrupted');assert.equal(receipt.error,'service_restarted'); assert.deepEqual(await readdir(join(dir,id)),['receipt.json']); s.runs.get(id).data.expires_at=new Date(Date.now()-1).toISOString(); await pause(1500);assert.equal((await readdir(dir)).includes(id),false); }finally{await s.close();await rm(dir,{recursive:true,force:true});} });