Exercise feature activation rollback and old-session artifact retention

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
tegwick 2026-09-26 23:14:34 +02:00
parent b8eec87f08
commit e34ec3fafe
22 changed files with 1661 additions and 1 deletions

View file

@ -0,0 +1,50 @@
# Feature publication, activation and rollback experiment
Executed 2026-09-26. Seven scenarios in Chromium 151.0.7922.34 and Firefox 155.0;
14/14 assertions met. [Review](../../reviews/FeatureReleaseExperiment.md),
[results](result.json), [provenance](provenance.json).
This is an authored synthetic host using real browser-native modules/import maps,
not execution of the earlier FSD/single-spa examples or a production release system.
The host API is fixed at 1. Reports R1/R2 support 1, R3 declares 2; Account A1 stays
unchanged. Each new HTML document receives the current map. Published assets and
activation bindings are separate in-memory controls in the test server.
## Reproduce
From this directory:
```bash
npm ci --ignore-scripts --cache /tmp/fep-npm-cache
PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers node node_modules/playwright/cli.js install firefox
PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers FEP_CHROMIUM_PATH=/path/to/chrome npm test
```
Playwright 1.63.0 is locked. Recorded runs used the same browser binaries identified
by hashes in provenance.json, with an explicit Chromium executable override. Firefox
required execution outside the workspace sandbox to access its temporary profile.
No external network is used during cases. Loopback server/browser cleanup is automatic.
Outputs default to `/tmp/fep-release-result.json` and an incomplete checkpoint at
`/tmp/fep-release-checkpoint.json`; `FEP_RESULT_PATH` overrides the final destination.
Any failed assertion produces nonzero exit. Receipts are not overwritten automatically.
## Cases and limits
| Case | Observed result in both browsers |
| --- | --- |
| X01 Publish R2 without changing binding | Fresh page still uses R1; no R2 request |
| X02 Activate compatible R2 | Fresh page R2; old loaded and old lazy pages R1; Account A1 unchanged |
| X03 Activate incompatible R3 | Host rejects mount, Account works; R3 module has already evaluated |
| X04 Point binding to missing artifact | Reports error, Account navigation still usable |
| X05 Roll binding back R2 → R1 | Fresh page R1; running page R2; explicit reload gets R1 |
| X06 Retire R1 after activating R2 | Old loaded page retains R1; old lazy page fails; new page uses R2 |
| X07 Fail shared startup module | Both navigation buttons remain disabled; feature loading never starts |
Negative controls intentionally pass when the specified failure is observed. All
responses disable HTTP caching, so this is not a CDN/cache invalidation test. Existing
pages retain their document maps; dynamic map mutation/hot replacement is not tested.
Only the host's deliberately tiny API-version contract is checked; module evaluation
can already have side effects before that check. No backend writes, authentication,
API migrations, atomic/concurrent map updates, accessibility audit or safe reload of
unsaved work is modeled. The startup outage reflects this fixture's chosen dependency,
not an assertion that every independently released frontend must fail that way.

View file

@ -0,0 +1,4 @@
export const version = 'A1';
export const hostApi = 1;
window.observed.evaluations.push(version);
export function mount(node) { node.textContent = 'Account A1'; }

View file

@ -0,0 +1,51 @@
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;

View file

@ -0,0 +1,29 @@
const HOST_API = 1;
window.observed = {mounts: [], errors: [], evaluations: [], hostApi: HOST_API};
const report = document.getElementById('reports');
const account = document.getElementById('account');
async function load(name, target) {
try {
const feature = name === 'reports' ? await import('reports') : await import('account');
// Compatibility gate, not a security boundary: module evaluation already happened.
if (feature.hostApi !== HOST_API) throw new Error('incompatible host API');
feature.mount(target);
window.observed.mounts.push({name, version: feature.version});
return {ok: true, version: feature.version};
} catch (error) {
target.textContent = `${name} unavailable`;
window.observed.errors.push({name, message: error.message});
return {ok: false, message: error.message};
}
}
window.loadReports = () => load('reports', report);
window.loadAccount = () => load('account', account);
try {
await import('shared');
for (const button of document.querySelectorAll('button')) button.disabled = false;
document.getElementById('status').textContent = 'ready';
} catch {
document.getElementById('status').textContent = 'startup failed';
}
document.getElementById('reports-nav').onclick = window.loadReports;
document.getElementById('account-nav').onclick = window.loadAccount;

View file

@ -0,0 +1,40 @@
{
"name": "fep-feature-release",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fep-feature-release",
"dependencies": {
"playwright": "1.63.0"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

View file

@ -0,0 +1,7 @@
{
"name": "fep-feature-release",
"private": true,
"type": "module",
"scripts": {"test": "node experiment.mjs"},
"dependencies": {"playwright": "1.63.0"}
}

View file

@ -0,0 +1,28 @@
{
"date": "2026-09-26",
"packages": {
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="
}
},
"chromium": {
"version": "151.0.7922.34",
"executable_sha256": "0b20b130e7edd9dd51873be867761295fe0cfad490c2b9a64f95bd3cfc08fa71",
"selection": "preinstalled Chromium build 1234, explicit executable override; not claimed to be Playwright 1.63 bundled revision"
},
"firefox": {
"playwright_build": "1543",
"executable_sha256": "062272ac4d851eade5b75db2dc7c14569d96cb3ca38f949337cdc1b74367e35b",
"version": "155.0"
},
"execution": "Local loopback server, native ES modules and import maps; synthetic host/artifacts; no production or single-spa deployment",
"http_cache": "All responses use Cache-Control: no-store; no service worker or CDN",
"activation": "Runner changes an in-memory binding used only in newly served documents; no client hot-rebinding or concurrent deployment protocol"
}

View file

@ -0,0 +1,4 @@
export const version = 'R1';
export const hostApi = 1;
window.observed.evaluations.push(version);
export function mount(node) { node.textContent = 'Reports R1'; }

View file

@ -0,0 +1,4 @@
export const version = 'R2';
export const hostApi = 1;
window.observed.evaluations.push(version);
export function mount(node) { node.textContent = 'Reports R2'; }

View file

@ -0,0 +1,4 @@
export const version = 'R3';
export const hostApi = 2;
window.observed.evaluations.push(version);
export function mount(node) { node.textContent = 'Reports R3 incompatible'; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export const sharedVersion = 'S1';