Isolate nested dismissal ownership across browsers and builds
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
parent
3eecd74b0d
commit
85ae4f62af
12 changed files with 7090 additions and 0 deletions
|
|
@ -61,3 +61,28 @@ or full accessibility conformance. X03 handles a response error explicitly; it d
|
|||
not test unhandled promises, timeouts, mutation idempotency or remote rollback.
|
||||
Nested dismissal remains unresolved. Dependency/version compatibility and fixture
|
||||
lifecycle effects must be separated before attributing a library defect.
|
||||
|
||||
## Follow-up: nested ownership isolation
|
||||
|
||||
[Review](../../reviews/NestedDismissalIsolation.md) and [48-trial receipt](isolation-results.json)
|
||||
compare development/production, Chromium/Firefox and four explicit variants. The
|
||||
original experiment and receipts above remain unchanged. Follow-up reports 43/48
|
||||
obligations met, including failures; it is a research runner, not a green CI gate.
|
||||
|
||||
```bash
|
||||
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 node isolation.mjs
|
||||
```
|
||||
|
||||
Runs three trials per browser/build/variant, without automatic retries. Default
|
||||
output is `/tmp/fep-isolation.json`; `FEP_RESULT_PATH` overrides it. A separate
|
||||
`/tmp/fep-isolation-checkpoint.json` preserves completed trials if a later launch
|
||||
fails. Launch failures abort; completed matrices retain assertion failures in JSON.
|
||||
A sandbox prevented Firefox accessing its temporary profile here; the recorded
|
||||
completed matrix ran outside that sandbox. Firefox build and limitations are in
|
||||
[isolation-provenance.json](isolation-provenance.json).
|
||||
|
||||
Instrumentation and an experimental guard are applied in memory during bundling;
|
||||
installed vendor files are not modified. The readiness variant observes private
|
||||
listener state and is not a proposed public readiness API. The guard can lose input
|
||||
and is not a validated fix. Do not replace raw failures with readiness passes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"date": "2026-09-26",
|
||||
"firefox": {
|
||||
"playwright_build": "1543",
|
||||
"executable_sha256": "062272ac4d851eade5b75db2dc7c14569d96cb3ca38f949337cdc1b74367e35b",
|
||||
"version": "155.0"
|
||||
},
|
||||
"chromium": "Same executable as provenance.json; explicit override",
|
||||
"instrumentation": "In-memory esbuild transformation; installed package files unmodified",
|
||||
"initial_attempt": "Sandboxed run reached Firefox launch, which failed to access its temporary profile; no completed matrix receipt. Re-executed outside sandbox.",
|
||||
"limitations": [
|
||||
"Fixed condition order, three trials each, no population failure-rate inference",
|
||||
"Tracing and before-event snapshot can change scheduling; raw is an unmodified-library control",
|
||||
"ready uses private listener instrumentation, not a public product readiness API",
|
||||
"guarded is an experimental mutation, not vendor behavior or a validated fix"
|
||||
]
|
||||
}
|
||||
6807
research/experiments/boundary-recovery/isolation-results.json
Normal file
6807
research/experiments/boundary-recovery/isolation-results.json
Normal file
File diff suppressed because it is too large
Load diff
91
research/experiments/boundary-recovery/isolation.mjs
Normal file
91
research/experiments/boundary-recovery/isolation.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Follow-up only: original fixture, runner and result receipts remain unchanged.
|
||||
import {build} from 'esbuild';
|
||||
import {chromium, firefox} from 'playwright';
|
||||
import {createServer} from 'node:http';
|
||||
import {once} from 'node:events';
|
||||
import {readFile, writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const dir = fileURLToPath(new URL('.', import.meta.url));
|
||||
const hash = data => createHash('sha256').update(data).digest('hex');
|
||||
const repetitions = 3;
|
||||
const layerPath = dir + 'node_modules/@radix-ui/react-dismissable-layer/dist/index.mjs';
|
||||
const layerSource = await readFile(layerPath, 'utf8');
|
||||
const expectedLayerHash = JSON.parse(await readFile(dir + 'provenance.json')).inspected_files['@radix-ui/react-dismissable-layer/dist/index.mjs'].sha256;
|
||||
if (hash(layerSource) !== expectedLayerHash) throw new Error('Inspected dependency changed');
|
||||
const bundles = new Map();
|
||||
function instrument(source, guard) {
|
||||
const replace = (from, to) => {
|
||||
if (source.split(from).length !== 2) throw new Error('Ambiguous instrumentation locator: ' + from);
|
||||
source = source.replace(from, to);
|
||||
};
|
||||
replace('const isHighestLayer = node ? index === layers.length - 1 : false;', `const isHighestLayer = node ? index === layers.length - 1 : false;
|
||||
const trace = (kind) => window.layerTrace.push({kind, t: performance.now(), node: node?.getAttribute('aria-labelledby'), active: document.activeElement?.id, layers: [...context.layers].map(n => n.getAttribute('aria-labelledby'))});`);
|
||||
replace(' onEscapeKeyDown?.(event);', ` trace('escape');
|
||||
${guard ? "if ([...context.layers].at(-1) !== node) { trace('guard-rejected'); return; }" : ''}
|
||||
onEscapeKeyDown?.(event);`);
|
||||
replace(' ownerDocument.addEventListener("keydown", handleKeyDown, { capture: true });', ' trace("listen");\n ownerDocument.addEventListener("keydown", handleKeyDown, { capture: true });');
|
||||
replace(' return () => ownerDocument.removeEventListener("keydown", handleKeyDown, { capture: true });', ' return () => { trace("unlisten"); ownerDocument.removeEventListener("keydown", handleKeyDown, { capture: true }); };');
|
||||
replace(' context.layers.add(node);', ' context.layers.add(node); trace("layer-add");');
|
||||
replace(' context.layers.delete(node);', ' trace("layer-remove"); context.layers.delete(node);');
|
||||
return source;
|
||||
}
|
||||
for (const mode of ['development', 'production']) {
|
||||
for (const variant of ['raw', 'traced', 'ready', 'guarded']) {
|
||||
const b = await build({entryPoints: [dir + 'fixture.jsx'], bundle: true, write: false,
|
||||
define: {'process.env.NODE_ENV': JSON.stringify(mode)}, format: 'iife',
|
||||
plugins: variant === 'raw' ? [] : [{name: 'layer-observation', setup(build) {
|
||||
build.onLoad({filter: /@radix-ui\/react-dismissable-layer\/dist\/index\.mjs$/}, () => ({contents: instrument(layerSource, variant === 'guarded'), loader: 'js', resolveDir: dir + 'node_modules/@radix-ui/react-dismissable-layer/dist'}));
|
||||
}}]});
|
||||
bundles.set(mode + '-' + variant, b.outputFiles[0].contents);
|
||||
}
|
||||
}
|
||||
const server = createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://localhost');
|
||||
if (u.pathname === '/bundle.js') {res.setHeader('Content-Type','application/javascript'); res.end(bundles.get(u.searchParams.get('key')));}
|
||||
else {res.setHeader('Content-Type', 'text/html');res.end(`<!doctype html><html lang="en"><title>Nested isolation</title><body><div id="root"></div><script>window.layerTrace=[];</script><script src="/bundle.js?key=${u.searchParams.get('key')}"></script></body></html>`);}
|
||||
});
|
||||
server.listen(0, '127.0.0.1');await once(server, 'listening');
|
||||
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 {
|
||||
for (const mode of ['development', 'production']) for (const variant of ['raw', 'traced', 'ready', 'guarded']) for (let trial = 1; trial <= repetitions; trial++) {
|
||||
const page = await browser.newPage();page.setDefaultTimeout(2000);
|
||||
const r = {engine, browser: browser.version(), mode, variant, trial, status: 'pass'};
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/?mode=focus&key=${mode}-${variant}`);
|
||||
await page.locator('#invoker').focus();await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'remove');
|
||||
await page.keyboard.press('Tab');await page.waitForFunction(() => document.activeElement?.id === 'nested-invoker');
|
||||
await page.keyboard.press('Enter');await page.waitForFunction(() => document.activeElement?.id === 'nested-close');
|
||||
if (variant === 'ready') await page.waitForFunction(() => {
|
||||
const inner = document.getElementById('nested-close').closest('[role=dialog]').getAttribute('aria-labelledby');
|
||||
const listeners = new Set();for (const e of window.layerTrace) {if (e.kind === 'listen') listeners.add(e.node);if(e.kind === 'unlisten') listeners.delete(e.node);}
|
||||
return listeners.size === 1 && listeners.has(inner);
|
||||
});
|
||||
r.before = await page.evaluate(() => ({active: document.activeElement.id, dialogs: [...document.querySelectorAll('[role=dialog]')].map(n => ({id: n.getAttribute('aria-labelledby'), title: document.getElementById(n.getAttribute('aria-labelledby'))?.textContent}))}));
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'nested-invoker' && document.querySelectorAll('[role=dialog]').length === 1, null, {timeout: 1500});
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'invoker', null, {timeout: 1500});
|
||||
} catch (e) {r.status = 'fail';r.error = e.message;}
|
||||
r.after = await page.evaluate(() => ({active: document.activeElement?.id, dialogs: document.querySelectorAll('[role=dialog]').length, transitions: window.observed.dialogTransitions, trace: window.layerTrace}));
|
||||
results.push(r); await page.close();
|
||||
await writeFile('/tmp/fep-isolation-checkpoint.json', JSON.stringify({status: 'incomplete', results}, null, 2) + '\n');
|
||||
}
|
||||
} finally {await browser.close();}
|
||||
}
|
||||
} finally {await new Promise(resolve => server.close(resolve));}
|
||||
const files = {};
|
||||
for (const name of ['isolation.mjs','fixture.jsx','package.json','package-lock.json']) files[name] = hash(await readFile(dir + name));
|
||||
const summary = [];
|
||||
for (const engine of ['chromium','firefox']) for (const mode of ['development','production']) for (const variant of ['raw','traced','ready','guarded']) {
|
||||
const group=results.filter(r => r.engine===engine && r.mode===mode && r.variant===variant);
|
||||
summary.push({engine,mode,variant,passed:group.filter(r=>r.status==='pass').length,total:group.length});
|
||||
}
|
||||
const output = process.env.FEP_RESULT_PATH || '/tmp/fep-isolation.json';
|
||||
await writeFile(output, JSON.stringify({date:new Date().toISOString(), node:process.version, repetitions, files, layerSourceSha256:hash(layerSource), bundleHashes:Object.fromEntries([...bundles].map(([k,v])=>[k,hash(v)])), summary,results},null,2)+'\n');
|
||||
console.log(JSON.stringify({output,summary},null,2));
|
||||
// Findings are retained, including failed obligations; not a CI acceptance gate.
|
||||
Loading…
Add table
Add a link
Reference in a new issue