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:
parent
b8eec87f08
commit
e34ec3fafe
22 changed files with 1661 additions and 1 deletions
50
research/experiments/feature-release/README.md
Normal file
50
research/experiments/feature-release/README.md
Normal 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.
|
||||
4
research/experiments/feature-release/account-a1.js
Normal file
4
research/experiments/feature-release/account-a1.js
Normal 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'; }
|
||||
51
research/experiments/feature-release/experiment.mjs
Normal file
51
research/experiments/feature-release/experiment.mjs
Normal 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;
|
||||
29
research/experiments/feature-release/host.js
Normal file
29
research/experiments/feature-release/host.js
Normal 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;
|
||||
40
research/experiments/feature-release/package-lock.json
generated
Normal file
40
research/experiments/feature-release/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
research/experiments/feature-release/package.json
Normal file
7
research/experiments/feature-release/package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "fep-feature-release",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {"test": "node experiment.mjs"},
|
||||
"dependencies": {"playwright": "1.63.0"}
|
||||
}
|
||||
28
research/experiments/feature-release/provenance.json
Normal file
28
research/experiments/feature-release/provenance.json
Normal 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"
|
||||
}
|
||||
4
research/experiments/feature-release/reports-r1.js
Normal file
4
research/experiments/feature-release/reports-r1.js
Normal 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'; }
|
||||
4
research/experiments/feature-release/reports-r2.js
Normal file
4
research/experiments/feature-release/reports-r2.js
Normal 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'; }
|
||||
4
research/experiments/feature-release/reports-r3.js
Normal file
4
research/experiments/feature-release/reports-r3.js
Normal 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'; }
|
||||
1292
research/experiments/feature-release/result.json
Normal file
1292
research/experiments/feature-release/result.json
Normal file
File diff suppressed because it is too large
Load diff
1
research/experiments/feature-release/shared.js
Normal file
1
research/experiments/feature-release/shared.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const sharedVersion = 'S1';
|
||||
Loading…
Add table
Add a link
Reference in a new issue