Implement bounded browser service, consumer evidence and managed package

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13590-a16f-7f60-be60-fd71627bc99d
This commit is contained in:
tegwick 2026-10-13 19:23:29 +02:00
parent 386b0c0f04
commit ccf46d8c59
81 changed files with 3455 additions and 73 deletions

66
src/protocol.mjs Normal file
View file

@ -0,0 +1,66 @@
import { createHash } from 'node:crypto';
export const API_VERSION = '1';
export const hash = value => createHash('sha256').update(value).digest('hex');
export class Problem extends Error {
constructor(code, status = 400) { super(code); this.code = code; this.status = status; }
}
export const now = () => new Date().toISOString();
export const terminal = state => ['passed', 'failed', 'cancelled', 'interrupted'].includes(state);
function string(value, max = 2048) { return typeof value === 'string' && value.length > 0 && value.length <= max; }
function integer(value, min, max) { return Number.isInteger(value) && value >= min && value <= max; }
export function origin(value) {
try {
const u = new URL(value);
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw new Error();
return u.origin;
} catch { throw new Problem('invalid_target'); }
}
export function validateJob(input, allowedOrigins, fixtureURL) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Problem('invalid_request');
const known = new Set(['api_version', 'target', 'viewports', 'steps', 'timeout_ms', 'label']);
if (Object.keys(input).some(k => !known.has(k))) throw new Problem('unknown_field');
if (input.api_version !== API_VERSION) throw new Problem('api_version_mismatch', 409);
const target = input.target === 'fixture' ? fixtureURL : input.target;
if (!string(target) || !allowedOrigins.includes(origin(target))) throw new Problem('target_not_allowed', 403);
// Queries can carry credentials. V1 targets are public/fixture pages, without queries.
if (new URL(target).search || new URL(target).hash) throw new Problem('target_query_not_supported');
const viewports = input.viewports ?? [{name:'desktop',width:1280,height:800},{name:'narrow',width:390,height:844}];
if (!Array.isArray(viewports) || viewports.length < 1 || viewports.length > 4) throw new Problem('invalid_viewports');
const names = new Set();
for (const v of viewports) {
if (!v || Object.keys(v).some(k=>!['name','width','height'].includes(k)) || !/^[a-z][a-z0-9-]{0,31}$/.test(v.name) ||
names.has(v.name) || !integer(v.width, 240, 1920) || !integer(v.height, 240, 1600)) throw new Problem('invalid_viewports');
names.add(v.name);
}
const steps = input.steps;
if (!Array.isArray(steps) || steps.length < 1 || steps.length > 40) throw new Problem('invalid_steps');
for (const step of steps) {
if (!step || typeof step !== 'object') throw new Problem('invalid_step');
const fields = {click:['selector'],focus:['selector'],press:['key'],fill:['selector','value'],
visible:['selector'],text:['selector','value'],focused:['selector'],url:['value'],screenshot:['name']};
if (!fields[step.action] || Object.keys(step).some(k=>!['action',...fields[step.action]].includes(k))) throw new Problem('invalid_step');
for (const key of fields[step.action]) if (!string(step[key], key === 'value' ? 4096 : 512)) throw new Problem('invalid_step');
if (step.action === 'press' && !/^(?:(?:Shift|Control|Alt|Meta)\+)*(?:Tab|Enter|Space|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|Backspace|[a-z0-9])$/.test(step.key)) throw new Problem('invalid_key');
if (step.action === 'screenshot' && !/^[a-z][a-z0-9-]{0,31}$/.test(step.name)) throw new Problem('invalid_artifact_name');
if (step.action === 'url' && !allowedOrigins.includes(origin(step.value))) throw new Problem('target_not_allowed',403);
}
if (!steps.some(s=>['visible','text','focused','url'].includes(s.action))) throw new Problem('assertion_required');
const timeout_ms = input.timeout_ms ?? 60000;
if (!integer(timeout_ms, 1000, 120000)) throw new Problem('invalid_timeout');
if (input.label !== undefined && !/^[a-zA-Z0-9 ._-]{1,80}$/.test(input.label)) throw new Problem('invalid_label');
return {api_version:API_VERSION,target,viewports,steps,timeout_ms,label:input.label??'ui-test'};
}
export const trial = {
api_version: API_VERSION, target: 'fixture', label: 'independent-browser-trial',
steps: [
{action:'visible',selector:'h1'},
{action:'text',selector:'#count',value:'0'},
{action:'press',key:'Tab'},
{action:'focused',selector:'#increment'},
{action:'press',key:'Enter'},
{action:'text',selector:'#count',value:'1'},
{action:'screenshot',name:'keyboard-result'},
],
};

56
src/proxy.mjs Normal file
View file

@ -0,0 +1,56 @@
// Every browser HTTP/HTTPS connection goes through this per-run origin gate.
// Resolve once and connect to the verified address, avoiding a DNS rebind race.
import http from 'node:http';
import net from 'node:net';
import { lookup } from 'node:dns/promises';
import { Problem } from './protocol.mjs';
export function publicAddress(address) {
const ip=address.toLowerCase();
if (net.isIP(ip) === 4) {
const [a,b]=ip.split('.').map(Number);
return !(a===0 || a===10 || a===127 || a>=224 || (a===169&&b===254) ||
(a===172&&b>=16&&b<=31) || (a===192&&b===168) || (a===100&&b>=64&&b<=127) ||
(a===192&&b===0) || (a===198&&(b===18||b===19)));
}
// Public global unicast only. Denies loopback, link local and IPv4 mapped forms.
return net.isIP(ip) === 6 && /^[23][0-9a-f]{3}:/.test(ip);
}
export async function startProxy(allowed, privateOrigins = [], onBlocked = ()=>{}) {
const sockets=new Set();
async function resolve(url) {
if (!allowed.includes(url.origin) || url.username || url.password) throw new Problem('target_not_allowed');
const addresses=await lookup(url.hostname.replace(/^\[|\]$/g,''),{all:true});
if (!addresses.length || (!privateOrigins.includes(url.origin) && addresses.some(a=>!publicAddress(a.address)))) throw new Problem('private_address_denied');
return addresses[0];
}
function track(socket) { sockets.add(socket);socket.on('close',()=>sockets.delete(socket));socket.on('error',()=>{});return socket; }
const proxy=http.createServer(async(req,res)=>{
let target, address;
try { target=new URL(req.url); if(target.protocol!=='http:')throw new Error();address=await resolve(target); }
catch(e) { if(e instanceof Problem)onBlocked(target);res.writeHead(e instanceof Problem?403:502);res.end();return; }
const headers={...req.headers,host:target.host};delete headers['proxy-authorization'];delete headers['proxy-connection'];
const upstream=http.request({hostname:address.address,family:address.family,port:target.port||80,
path:target.pathname+target.search,method:req.method,headers,timeout:15000},reply=>{
res.writeHead(reply.statusCode,reply.headers);reply.pipe(res);
});
upstream.on('socket',track);upstream.on('timeout',()=>upstream.destroy());
upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();});
req.on('aborted',()=>upstream.destroy());req.pipe(upstream);
});
proxy.on('connect',async(req,client,head)=>{
track(client);
let target,address;
try { target=new URL('https://'+req.url);address=await resolve(target); }
catch(e) { if(e instanceof Problem)onBlocked(target);client.end(`HTTP/1.1 ${e instanceof Problem?'403 Forbidden':'502 Bad Gateway'}\r\n\r\n`);return; }
const upstream=track(net.connect({host:address.address,port:Number(target.port||443),family:address.family}));
upstream.setTimeout(30000,()=>upstream.destroy());
upstream.once('connect',()=>{client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client);});
upstream.once('error',()=>client.destroy());client.once('close',()=>upstream.destroy());
});
proxy.on('connection',track);proxy.on('clientError',(_,socket)=>socket.destroy());
await new Promise(resolve=>proxy.listen(0,'127.0.0.1',resolve));
return {url:`http://127.0.0.1:${proxy.address().port}`,close:async()=>{
for(const socket of sockets)socket.destroy();await new Promise(resolve=>proxy.close(resolve));
}};
}

187
src/server.mjs Normal file
View file

@ -0,0 +1,187 @@
import http from 'node:http';
import { fork } from 'node:child_process';
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, rename, mkdir, readdir, rm, stat } from 'node:fs/promises';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { API_VERSION, Problem, hash, now, terminal, origin, trial, validateJob } from './protocol.mjs';
const packageInfo=JSON.parse(await readFile(new URL('../package.json',import.meta.url)));
const playwrightInfo=JSON.parse(await readFile(new URL('../node_modules/playwright/package.json',import.meta.url)));
if(playwrightInfo.version!==packageInfo.dependencies.playwright)throw new Error('playwright_package_version_mismatch');
const workerPath=fileURLToPath(new URL('./worker.mjs',import.meta.url));
const fixture=await readFile(new URL('../fixtures/trial.html',import.meta.url));
const sha256Pattern=/^[a-f0-9]{64}$/;
export async function startService(options={}) {
const dir=resolve(options.dir??process.env.DATA_DIR??'.local/runs');
const host=options.host??process.env.HOST??'127.0.0.1';
const port=options.port??Number(process.env.PORT??8080);
const concurrency=options.concurrency??Number(process.env.MAX_CONCURRENCY??1);
const retention=options.retention??Number(process.env.RETENTION_MS??3600000);
const maxRuns=options.maxRuns??64;
if(!Number.isInteger(concurrency)||concurrency<1||concurrency>4||!Number.isInteger(retention)||retention<1000||retention>86400000)throw new Error('invalid service limits');
const configured=(options.allowedOrigins??process.env.ALLOWED_ORIGINS??'').split(',').filter(Boolean).map(origin);
const privateConfigured=(options.privateOrigins??process.env.PRIVATE_ORIGINS??'').split(',').filter(Boolean).map(origin);
if(privateConfigured.some(o=>!configured.includes(o)))throw new Error('private origins must be explicitly allowed');
await mkdir(dir,{recursive:true,mode:0o700});
const runs=new Map();const queue=[];let active=0,closed=false,probeActive=false;
let availability={available:false,error:'browser_not_probed',checked_at:null,browser_version:null};
const fixtureServer=http.createServer((req,res)=>{
if(req.method==='GET'&&req.url==='/'){res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});res.end(fixture);}
else {res.writeHead(404);res.end();}
});
await new Promise(r=>fixtureServer.listen(0,'127.0.0.1',r));
const fixtureURL=`http://127.0.0.1:${fixtureServer.address().port}/`;
const allowedOrigins=[...configured,origin(fixtureURL)];
const privateOrigins=[...privateConfigured,origin(fixtureURL)];
for(const id of await readdir(dir)){
if(!/^[a-f0-9-]{36}$/.test(id))continue;
try{
const data=JSON.parse(await readFile(join(dir,id,'receipt.json'),'utf8'));
if(data.id!==id||!sha256Pattern.test(data.token_hash)||!Number.isFinite(Date.parse(data.expires_at)))throw new Error();
if(Date.parse(data.expires_at)<=Date.now()){await rm(join(dir,id),{recursive:true,force:true});continue;}
if(!terminal(data.status)){data.status='interrupted';data.error='service_restarted';data.finished_at=now();data.artifacts=[];
for(const name of await readdir(join(dir,id)))if(name!=='receipt.json')await rm(join(dir,id,name),{recursive:true,force:true});}
const run={data,pending:Promise.resolve()};runs.set(id,run);await save(run);
}catch{throw new Error(`invalid persisted run ${id}`);}
}
function save(run){
const snapshot=JSON.stringify(run.data,null,2)+'\n';
run.pending=run.pending.then(async()=>{
const tmp=join(dir,run.data.id,'receipt.tmp');await writeFile(tmp,snapshot,{mode:0o600});await rename(tmp,join(dir,run.data.id,'receipt.json'));
});return run.pending;
}
function receipt(run){const {token_hash,...rest}=run.data;return rest;}
function checkToken(req,run){
const token=req.headers.authorization?.match(/^Bearer ([a-f0-9]{64})$/)?.[1];
return token&&timingSafeEqual(Buffer.from(hash(token),'hex'),Buffer.from(run.data.token_hash,'hex'));
}
async function reap(){
for(const [id,run] of runs){if(terminal(run.data.status)&&Date.parse(run.data.expires_at)<=Date.now()){
runs.delete(id);await run.pending;await rm(join(dir,id),{recursive:true,force:true});
}}
}
function killGroup(pid){if(pid)try{process.kill(-pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}}
function launch(config,onMessage,onExit){
const temporary=mkdtempSync(join(tmpdir(),'rapp-worker-'));
const child=fork(workerPath,[],{detached:true,stdio:['ignore','ignore','ignore','ipc'],
env:{PATH:process.env.PATH,HOME:temporary,TMPDIR:temporary,PLAYWRIGHT_BROWSERS_PATH:process.env.PLAYWRIGHT_BROWSERS_PATH,
CHROMIUM_SANDBOX:process.env.CHROMIUM_SANDBOX??'false'}});
let browserPid=null,killTimer;
child.on('message',msg=>{if(msg.type==='browser_pid')browserPid=msg.pid;else onMessage(msg);});
child.once('error',()=>{});
child.once('exit',async(code)=>{clearTimeout(killTimer);killGroup(browserPid);await rm(temporary,{recursive:true,force:true});onExit(code);});
child.send({...config,allowedOrigins,privateOrigins});
return {stop:()=>{child.kill('SIGTERM');killTimer=setTimeout(()=>{killGroup(browserPid);killGroup(child.pid);},2000);},pid:child.pid,get browserPid(){return browserPid;}};
}
async function probe(){
if(probeActive||closed||active)return;probeActive=true;
try {
availability=await new Promise(resolve=>{
let result;const worker=launch({probe:true,dir},msg=>{if(msg.type==='result')result=msg.result;},()=>{
clearTimeout(timer);resolve({available:result?.status==='passed',error:result?.status==='passed'?null:'browser_launch_failed',checked_at:now(),browser_version:result?.browser_version??null});
});const timer=setTimeout(()=>worker.stop(),25000);
});
}finally{probeActive=false;drain();}
}
function drain(){
if(closed||probeActive)return;
while(active<concurrency&&queue.length){const run=queue.shift();if(run.data.status!=='queued')continue;execute(run);}
}
async function execute(run){
active++;run.data.status='running';run.data.started_at=now();
try{await save(run);}catch{active--;run.data.status='failed';run.data.error='storage_failed';return;}
let result;
run.worker=launch({job:run.job,dir:join(dir,run.data.id)},msg=>{
if(msg.type==='result')result=msg.result;
if(msg.type==='progress')run.data.completed_steps=msg.steps;
},async()=>{
clearTimeout(run.timer);
if(run.stopReason){run.data.status=run.stopReason==='cancelled'?'cancelled':'failed';run.data.error=run.stopReason;}
else if(result)Object.assign(run.data,result);
else {run.data.status='failed';run.data.error='browser_crashed';}
run.data.finished_at=now();delete run.worker;delete run.job;
await save(run);active--;drain();
});
run.timer=setTimeout(()=>cancel(run,'run_timeout'),run.data.timeout_ms);
if(run.stopReason)run.worker.stop();
}
async function cancel(run,reason='cancelled'){
if(terminal(run.data.status))return;
run.stopReason=reason;
if(run.data.status==='queued'){
run.data.status='cancelled';run.data.error=reason;run.data.finished_at=now();delete run.job;await save(run);
}else run.worker?.stop();
}
function json(res,status,value){res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});res.end(JSON.stringify(value));}
async function body(req){
let size=0,chunks=[];
for await(const chunk of req){size+=chunk.length;if(size>32768)throw new Problem('request_too_large',413);chunks.push(chunk);}
try{return JSON.parse(Buffer.concat(chunks).toString());}catch{throw new Problem('invalid_json');}
}
const server=http.createServer(async(req,res)=>{
try{
if(closed)throw new Problem('service_stopping',503);
const path=new URL(req.url,'http://localhost').pathname;
if(req.method==='GET'&&path==='/healthz')return json(res,200,{status:'ok',version:packageInfo.version});
if(req.headers['x-rapp-protocol']!==API_VERSION)throw new Problem('api_version_mismatch',409);
if(req.method==='GET'&&path==='/v1/preflight')return json(res,availability.available?200:503,{
...availability,api_version:API_VERSION,service_version:packageInfo.version,playwright_version:playwrightInfo.version,
execution_location:process.env.EXECUTION_LOCATION??'local-development',release:process.env.RELEASE_ID??'development',
allowed_origins:configured,fixture:true,active,queued:queue.filter(r=>r.data.status==='queued').length,
limits:{concurrency,max_queue:4,max_timeout_ms:120000,retention_ms:retention,max_runs:maxRuns},
});
if(req.method==='GET'&&path==='/v1/trial')return json(res,200,trial);
if(req.method==='POST'&&path==='/v1/runs'){
if(!availability.available)throw new Problem('browser_unavailable',503);
const input=await body(req);const job=validateJob(input,allowedOrigins,fixtureURL);
await reap();
const bytes=[...runs.values()].reduce((n,r)=>n+(r.data.artifacts??[]).reduce((s,a)=>s+a.bytes,0),0);
const reserved=[...runs.values()].filter(r=>!terminal(r.data.status)).length;
if(runs.size>=maxRuns||bytes+(reserved+1)*20*1024*1024>200*1024*1024)throw new Problem('artifact_capacity',429);
if([...runs.values()].filter(r=>r.data.status==='queued').length>=4)throw new Problem('queue_full',429);
const id=randomUUID(),token=randomBytes(32).toString('hex');
const run={job,pending:Promise.resolve(),data:{id,token_hash:hash(token),api_version:API_VERSION,
status:'queued',error:null,label:job.label,target:input.target,viewports:job.viewports,
timeout_ms:job.timeout_ms,created_at:now(),expires_at:new Date(Date.now()+retention).toISOString(),
service_version:packageInfo.version,playwright_version:playwrightInfo.version,
release:process.env.RELEASE_ID??'development',execution_location:process.env.EXECUTION_LOCATION??'local-development',
browser_version:null,completed_steps:0,steps:[],artifacts:[]}};
runs.set(id,run);
try{await mkdir(join(dir,id),{mode:0o700});await save(run);}catch(e){runs.delete(id);throw e;}
queue.push(run);drain();
return json(res,202,{id,token,status:'queued',expires_at:run.data.expires_at});
}
const match=path.match(/^\/v1\/runs\/([a-f0-9-]{36})(?:\/artifacts\/([a-z0-9-]+\.png))?$/);
if(!match)throw new Problem('not_found',404);
const run=runs.get(match[1]);
if(!run||!checkToken(req,run)||Date.parse(run.data.expires_at)<=Date.now())throw new Problem('not_found',404);
if(match[2]&&req.method==='GET'){
const artifact=run.data.artifacts.find(a=>a.name===match[2]);
if(!terminal(run.data.status)||!artifact)throw new Problem('not_found',404);
const data=await readFile(join(dir,run.data.id,artifact.name));
res.writeHead(200,{'Content-Type':'image/png','Content-Length':data.length,'Cache-Control':'no-store'});return res.end(data);
}
if(!match[2]&&req.method==='GET')return json(res,200,receipt(run));
if(!match[2]&&req.method==='DELETE'){await cancel(run);return json(res,202,receipt(run));}
throw new Problem('method_not_allowed',405);
}catch(e){if(!res.headersSent)json(res,e instanceof Problem?e.status:500,{error:e instanceof Problem?e.code:'internal_error'});else res.end();}
});
server.requestTimeout=15000;server.headersTimeout=10000;server.keepAliveTimeout=2000;
await probe();await new Promise(r=>server.listen(port,host,r));
const maintenance=setInterval(()=>{reap().catch(()=>{});if(!availability.checked_at||Date.now()-Date.parse(availability.checked_at)>60000)probe();},1000);maintenance.unref();
return {server,url:`http://${host}:${server.address().port}`,runs,async close(){
closed=true;clearInterval(maintenance);
await Promise.all([...runs.values()].filter(r=>!terminal(r.data.status)).map(r=>cancel(r,'service_stopped')));
const deadline=Date.now()+5000;while(active&&Date.now()<deadline)await new Promise(r=>setTimeout(r,50));
server.closeAllConnections();await new Promise(r=>server.close(r));await new Promise(r=>fixtureServer.close(r));
}};
}
if(process.argv[1]===fileURLToPath(import.meta.url)){
const service=await startService();console.log(JSON.stringify({event:'listening',url:service.url}));
let closing=false;for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>{if(closing)return;closing=true;service.close().finally(()=>process.exit(0));});
}

93
src/worker.mjs Normal file
View file

@ -0,0 +1,93 @@
import { chromium } from 'playwright';
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startProxy } from './proxy.mjs';
import { now } from './protocol.mjs';
// The supervisor supplies a validated job via IPC; no consumer JS is evaluated.
process.once('message',async config=>{
const {job,dir,allowedOrigins,privateOrigins}=config;
let browserServer,browser,proxy,browserHome,phase='browser_launch_failed',stopping=false,navigationBlocked=false,navigationOrigin=null;
const result={status:'passed',error:null,steps:[],artifacts:[],browser_version:null,blocked_requests:0};
const send=value=>{if(process.connected)process.send(value);};
const stop=async()=>{if(stopping)return;stopping=true;await browser?.close().catch(()=>{});await browserServer?.close().catch(()=>{});await proxy?.close();if(browserHome)await rm(browserHome,{recursive:true,force:true});};
process.on('SIGTERM',()=>{stop().finally(()=>process.exit(143));});
process.on('disconnect',()=>{stop().finally(()=>process.exit(1));});
try {
proxy=await startProxy(allowedOrigins,privateOrigins,url=>{
result.blocked_requests++;
if(url?.origin===navigationOrigin)navigationBlocked=true;
});
browserHome=await mkdtemp(join(tmpdir(),'rapp-browser-home-'));
browserServer=await chromium.launchServer({headless:true,proxy:{server:proxy.url},
executablePath:fileURLToPath(new URL('../tools/chromium-launcher.py',import.meta.url)),
env:{...process.env,HOME:browserHome,RAPP_CHROMIUM_EXECUTABLE:chromium.executablePath()},
args:['--proxy-bypass-list=<-loopback>','--disable-quic','--disable-background-networking'],
chromiumSandbox:process.env.CHROMIUM_SANDBOX==='true',timeout:20000});
send({type:'browser_pid',pid:browserServer.process().pid});
browser=await chromium.connect(browserServer.wsEndpoint());
result.browser_version=browser.version();
if(config.probe)return;
for(const viewport of job.viewports){
const context=await browser.newContext({viewport:{width:viewport.width,height:viewport.height},
serviceWorkers:'block',acceptDownloads:false,locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
context.on('request',request=>{
if(request.isNavigationRequest()&&request.frame().parentFrame()===null)
navigationOrigin=new URL(request.url()).origin;
});
await context.route('**/*',route=>{
const u=new URL(route.request().url());
const navigation=route.request().isNavigationRequest()&&route.request().frame().parentFrame()===null;
if(navigation)navigationOrigin=u.origin;
if(!['http:','https:'].includes(u.protocol)||!allowedOrigins.includes(u.origin)){
if(navigation)navigationBlocked=true;
result.blocked_requests++;return route.abort('blockedbyclient');
}
return route.continue();
});
await context.routeWebSocket('**/*',ws=>ws.close());
context.on('page',p=>{p.on('dialog',d=>d.dismiss());});
const page=await context.newPage();page.setDefaultTimeout(5000);page.setDefaultNavigationTimeout(15000);
phase='target_unreachable';
const response=await page.goto(job.target,{waitUntil:'domcontentloaded'});
if(!response || response.status()>=400)throw new Error('target_http_error');
phase='ui_assertion_failed';
for(let i=0;i<job.steps.length;i++){
const step=job.steps[i];const receipt={viewport:viewport.name,index:i,action:step.action,status:'passed',at:now()};
// Record selectors/key names but not fill values, page text, headers or console output.
if(step.selector)receipt.selector=step.selector;
if(step.key)receipt.key=step.key;
result.steps.push(receipt);
try{
const locator=step.selector?page.locator(step.selector):null;
switch(step.action){
case 'click':await locator.click();break;
case 'focus':await locator.focus();break;
case 'fill':await locator.fill(step.value);break;
case 'press':await page.keyboard.press(step.key);break;
case 'visible':await locator.waitFor({state:'visible'});break;
case 'text':await page.waitForFunction(({selector,value})=>document.querySelector(selector)?.textContent?.trim()===value,{selector:step.selector,value:step.value},{timeout:5000});break;
case 'focused':await page.waitForFunction(selector=>document.querySelector(selector)===document.activeElement,step.selector,{timeout:5000});break;
case 'url':await page.waitForURL(url=>url.href===step.value,{timeout:5000});break;
case 'screenshot':await screenshot(page,`${viewport.name}-${i}-${step.name}.png`);break;
}
}catch(e){receipt.status='failed';throw e;}
send({type:'progress',steps:result.steps.length});
}
await screenshot(page,`${viewport.name}-final.png`);
await context.close();
}
} catch {
result.status='failed';result.error=navigationBlocked&&phase==='target_unreachable'?'target_not_allowed':phase;
} finally {
await stop();send({type:'result',result});if(process.connected)process.disconnect();
}
async function screenshot(page,name){
const data=await page.screenshot({type:'png',animations:'disabled',timeout:5000});
const size=result.artifacts.reduce((sum,a)=>sum+a.bytes,0)+data.length;
if(size>20*1024*1024) {phase='artifact_limit';throw new Error();}
await writeFile(join(dir,name),data,{mode:0o600});result.artifacts.push({name,bytes:data.length});
}
});