rapp-playwright/tests/integration.mjs
tegwick ccf46d8c59 Implement bounded browser service, consumer evidence and managed package
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13590-a16f-7f60-be60-fd71627bc99d
2026-10-13 19:23:29 +02:00

112 lines
7.7 KiB
JavaScript

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()<end){const r=await api('/v1/runs/'+run.id,'GET',null,run.token);assert.equal(r.status,200,JSON.stringify(r));
if(['passed','failed','cancelled','interrupted'].includes(r.data.status))return r.data;await pause(100);}
throw new Error('run did not terminate');
}
return {dir,service,api,submit,finish,close:async()=>{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()<deadline)await pause(20);
const worker=e.service.runs.get(crashed.id).worker;assert.ok(worker?.browserPid);
const browserPid=worker.browserPid;
process.kill(worker.pid,'SIGKILL');assert.equal((await e.finish(crashed)).error,'browser_crashed');
await pause(500);assert.throws(()=>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});}
});