frontend-patterns/research/experiments/feature-release/experiment.mjs
tegwick e34ec3fafe Exercise feature activation rollback and old-session artifact retention
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
2026-09-26 23:14:34 +02:00

51 lines
7 KiB
JavaScript

import {chromium, firefox} from 'playwright';
import {createServer} from 'node:http';
import {once} from 'node:events';
import {readFile,writeFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {createHash} from 'node:crypto';
import assert from 'node:assert/strict';
const dir=fileURLToPath(new URL('.',import.meta.url));
const fileNames=['host.js','reports-r1.js','reports-r2.js','reports-r3.js','account-a1.js','shared.js'];
const assets=new Map(await Promise.all(fileNames.map(async f=>['/'+f,await readFile(dir+f)])));
let binding, published, sharedDown, requests;
function reset(){binding={reports:'/reports-r1.js',account:'/account-a1.js',shared:'/shared.js'};published=new Set(['/host.js','/reports-r1.js','/account-a1.js','/shared.js']);sharedDown=false;requests=[];}
reset();
const server=createServer((req,res)=>{
const path=new URL(req.url,'http://localhost').pathname;
res.setHeader('Cache-Control','no-store');
if(path==='/'){
requests.push({path,status:200,binding:{...binding}});res.setHeader('Content-Type','text/html');
res.end(`<!doctype html><html lang="en"><title>Feature release probe</title><body><nav><button id="reports-nav" disabled>Reports</button><button id="account-nav" disabled>Account</button></nav><p id="status" role="status">starting</p><section id="reports"></section><section id="account"></section><script type="importmap">${JSON.stringify({imports:binding})}</script><script type="module" src="/host.js"></script></body></html>`);
}else if(path==='/shared.js'&&sharedDown){requests.push({path,status:503});res.writeHead(503,{'Content-Type':'text/plain'});res.end('synthetic startup outage');}
else if(published.has(path)&&assets.has(path)){requests.push({path,status:200});res.setHeader('Content-Type','application/javascript');res.end(assets.get(path));}
else{requests.push({path,status:404});res.writeHead(404,{'Content-Type':'text/plain'});res.end('artifact absent');}
});
server.listen(0,'127.0.0.1');await once(server,'listening');
const url=`http://127.0.0.1:${server.address().port}`;
const results=[];
try{
for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
const browser=await launcher.launch({headless:true,...(engine==='chromium'&&process.env.FEP_CHROMIUM_PATH?{executablePath:process.env.FEP_CHROMIUM_PATH}:{})});
try{
async function scenario(id,run){reset();const pages=[];const r={id,engine,browser:browser.version(),status:'pass'};
async function session(expected='ready'){const p=await browser.newPage();p.setDefaultTimeout(2500);pages.push(p);await p.goto(url);await p.locator('#status').filter({hasText:expected}).waitFor();return p;}
try{r.observation=await run(session);}catch(e){r.status='fail';r.error=e.message;}
r.sessions=await Promise.all(pages.map(p=>p.evaluate(()=>({text:document.body.innerText,observed:window.observed}))));r.requests=[...requests];results.push(r);for(const p of pages)await p.close();
await writeFile('/tmp/fep-release-checkpoint.json',JSON.stringify({status:'incomplete',results},null,2)+'\n');}
const reports=p=>p.evaluate(()=>window.loadReports());const account=p=>p.evaluate(()=>window.loadAccount());
await scenario('X01-publish-without-activation',async open=>{published.add('/reports-r2.js');const p=await open();assert.deepEqual(await reports(p),{ok:true,version:'R1'});assert(!requests.some(x=>x.path==='/reports-r2.js'));return {active:'R1',publishedButUnused:'R2'};});
await scenario('X02-compatible-activation-old-and-lazy',async open=>{
const loaded=await open();assert.equal((await reports(loaded)).version,'R1');const lazy=await open();published.add('/reports-r2.js');binding.reports='/reports-r2.js';const fresh=await open();
assert.equal((await reports(fresh)).version,'R2');assert.equal((await reports(loaded)).version,'R1');assert.equal((await reports(lazy)).version,'R1');for(const p of [loaded,lazy,fresh])assert.equal((await account(p)).version,'A1');
return {fresh:'R2',oldLoaded:'R1',oldLazy:'R1',account:'A1',hostUnchanged:true};});
await scenario('X03-incompatible-module',async open=>{published.add('/reports-r3.js');binding.reports='/reports-r3.js';const p=await open();assert.deepEqual(await reports(p),{ok:false,message:'incompatible host API'});assert.equal((await account(p)).version,'A1');const o=await p.evaluate(()=>window.observed);assert(o.evaluations.includes('R3'));assert(!o.mounts.some(x=>x.name==='reports'));return {incompatibleMountRejected:true,moduleAlreadyEvaluated:true,accountUsable:true};});
await scenario('X04-missing-artifact',async open=>{binding.reports='/missing.js';const p=await open();assert.equal((await reports(p)).ok,false);await p.locator('#account-nav').click();await p.locator('#account').filter({hasText:'Account A1'}).waitFor();assert(requests.some(x=>x.path==='/missing.js'&&x.status===404));return {localLoadFailure:true,navigationUsable:true};});
await scenario('X05-rollback-fresh-versus-running',async open=>{published.add('/reports-r2.js');binding.reports='/reports-r2.js';const old=await open();assert.equal((await reports(old)).version,'R2');binding.reports='/reports-r1.js';const fresh=await open();assert.equal((await reports(fresh)).version,'R1');assert.equal((await reports(old)).version,'R2');await old.reload();await old.locator('#status').filter({hasText:'ready'}).waitFor();assert.equal((await reports(old)).version,'R1');return {freshAfterRollback:'R1',runningBeforeReload:'R2',afterExplicitReload:'R1'};});
await scenario('X06-retired-old-artifact',async open=>{const loaded=await open();assert.equal((await reports(loaded)).version,'R1');const lazy=await open();published.add('/reports-r2.js');binding.reports='/reports-r2.js';published.delete('/reports-r1.js');assert.equal((await reports(loaded)).version,'R1');assert.equal((await reports(lazy)).ok,false);const fresh=await open();assert.equal((await reports(fresh)).version,'R2');return {oldLoadedStillWorks:true,oldLazyFails:true,newSession:'R2'};});
await scenario('X07-shared-startup-outage',async open=>{sharedDown=true;const p=await open('startup failed');assert.equal(await p.locator('#reports-nav').isDisabled(),true);assert.equal(await p.locator('#account-nav').isDisabled(),true);assert(requests.some(x=>x.path==='/shared.js'&&x.status===503));assert(!requests.some(x=>x.path==='/reports-r1.js'));return {bothNavigationActionsBlocked:true,featureLoadingNeverStarted:true};});
}finally{await browser.close();}
}
}finally{await new Promise(resolve=>server.close(resolve));}
const files={};for(const f of [...fileNames,'experiment.mjs','package.json','package-lock.json'])files[f]=createHash('sha256').update(await readFile(dir+f)).digest('hex');
const output=process.env.FEP_RESULT_PATH||'/tmp/fep-release-result.json';const result={date:new Date().toISOString(),node:process.version,kind:'synthetic-native-es-module-browser-experiment',files,results,passed:results.filter(x=>x.status==='pass').length,total:results.length};await writeFile(output,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({output,passed:result.passed,total:result.total,failures:results.filter(x=>x.status==='fail')},null,2));if(result.passed!==result.total)process.exitCode=1;