Current section
Files
Jump to
Current section
Files
priv/menu-reference/driver.mjs
// Menu reference conformance driver. Tests the SHIPPED <.menu> component + hook:
// native open via trigger, ArrowDown/ArrowUp cursor movement, cursor survival across
// an unrelated patch, cursor survival across a patch that re-renders the active item
// itself (the ignore_attributes money test), deterministic collapse when the active
// item is removed, light-dismiss, and item activation as a server event.
// Self-asserts. Output: menu-reference-results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4130';
const settle = (p) => p.waitForTimeout(150);
const waitConnected = (p) =>
p.waitForFunction(() => document.querySelector('[data-phx-main]')?.classList.contains('phx-connected'), null, { timeout: 12000 });
const jsClick = (p, id) => p.evaluate((id) => document.getElementById(id).click(), id);
const snap = (p) =>
p.evaluate(() => {
const trg = document.getElementById('menu-trigger');
const pop = document.getElementById('menu');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
const items = Array.from(pop.querySelectorAll('[role="menuitem"]')).map((it) => ({
id: it.getAttribute('data-item'),
domId: it.id,
active: it.getAttribute('data-highlighted'),
label: it.textContent.trim(),
}));
return {
open,
ariaExpanded: trg.getAttribute('aria-expanded'),
ariaControls: trg.getAttribute('aria-controls'),
activeDescendant: trg.getAttribute('aria-activedescendant'),
items,
selectedLog: document.getElementById('selected-log')?.textContent.trim(),
menuMark: pop.__mark === 'MENU',
};
});
async function fresh(p) { await p.goto(BASE + '/'); await waitConnected(p); await settle(p); }
async function openViaTrigger(p) {
await p.click('#menu-trigger');
await p.waitForFunction(() => { try { return document.getElementById('menu').matches(':popover-open'); } catch { return false; } }, null, { timeout: 4000 }).catch(() => {});
await settle(p);
}
async function arrow(p, key) {
await p.keyboard.press(key);
await settle(p);
}
async function runSuite(p) {
const R = {};
// a. open via native trigger -> open, aria-expanded true, items present.
await fresh(p);
await p.evaluate(() => { document.getElementById('menu').__mark = 'MENU'; });
await openViaTrigger(p);
R.opened = await snap(p);
// b. ArrowDown / ArrowUp move aria-activedescendant to a live, real node.
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown'); // -> alpha
await arrow(p, 'ArrowDown'); // -> beta
const afterTwoDown = await snap(p);
await arrow(p, 'ArrowUp'); // -> alpha
const afterUp = await snap(p);
R.cursorMove = {
afterTwoDown: { activeDescendant: afterTwoDown.activeDescendant, activeItem: afterTwoDown.items.find((i) => i.active === 'true')?.id },
afterUp: { activeDescendant: afterUp.activeDescendant, activeItem: afterUp.items.find((i) => i.active === 'true')?.id },
};
// c. active item (alpha) survives an UNRELATED content patch (touches gamma).
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown'); // -> alpha
const beforeUnrelated = await snap(p);
await jsClick(p, 'btn-content');
await p.waitForFunction(() => document.querySelector('[data-item="gamma"]')?.textContent.includes('Gamma 1'), null, { timeout: 6000 }).catch(() => {});
await settle(p);
const afterUnrelated = await snap(p);
R.unrelatedPatch = {
activeBefore: beforeUnrelated.activeDescendant,
activeAfter: afterUnrelated.activeDescendant,
gammaLabel: afterUnrelated.items.find((i) => i.id === 'gamma')?.label,
alphaStillActive: afterUnrelated.items.find((i) => i.id === 'alpha')?.active,
};
// d. THE decisive anatomy test: a patch that RE-RENDERS the active item
// itself. Does hook-managed aria-activedescendant / data-highlighted survive
// (protected by JS.ignore_attributes), and is the DOM node preserved (not
// destroyed/recreated, verified via a JS expando marker)?
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown'); // -> alpha
await p.evaluate(() => { document.getElementById('menu-item-alpha').__mark = 'ALPHA'; });
const beforeSelf = await snap(p);
await jsClick(p, 'btn-item');
await p.waitForFunction(() => document.getElementById('menu-item-alpha')?.textContent.includes('Alpha 1'), null, { timeout: 6000 }).catch(() => {});
await settle(p);
const afterSelf = await snap(p);
const nodePreserved = await p.evaluate(() => document.getElementById('menu-item-alpha')?.__mark === 'ALPHA');
R.selfRerender = {
beforeActiveDescendant: beforeSelf.activeDescendant,
afterActiveDescendant: afterSelf.activeDescendant,
afterAlphaDataActive: afterSelf.items.find((i) => i.id === 'alpha')?.active,
alphaLabel: afterSelf.items.find((i) => i.id === 'alpha')?.label,
nodePreserved,
survived: afterSelf.activeDescendant === 'menu-item-alpha' && afterSelf.items.find((i) => i.id === 'alpha')?.active === 'true' && nodePreserved === true,
};
// e. active item REMOVED by a patch -> cursor collapses deterministically
// (clears; never left pointing at a dead node).
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown'); // -> alpha
await arrow(p, 'ArrowDown'); // -> beta (the one we remove)
const beforeRemove = await snap(p);
await jsClick(p, 'btn-remove-beta');
await p.waitForFunction(() => !document.getElementById('menu-item-beta'), null, { timeout: 6000 }).catch(() => {});
await p.waitForFunction(() => document.getElementById('menu-trigger').getAttribute('aria-activedescendant') === '', null, { timeout: 6000 }).catch(() => {});
await settle(p);
const afterRemove = await snap(p);
R.removedActive = {
activeBefore: beforeRemove.activeDescendant,
betaGoneFromDom: afterRemove.items.every((i) => i.id !== 'beta'),
activeAfterCleared: afterRemove.activeDescendant === '',
noDanglingActive: afterRemove.items.every((i) => i.active !== 'true'),
};
// f. light-dismiss: a real click outside the menu closes it (native, auto popover).
await fresh(p);
await openViaTrigger(p);
const beforeDismiss = await snap(p);
await p.mouse.click(2, 2);
await settle(p);
const afterDismiss = await snap(p);
R.lightDismiss = { openBefore: beforeDismiss.open, openAfter: afterDismiss.open, ariaAfter: afterDismiss.ariaExpanded };
// g. item activation sends a server event (verified via server-rendered counter),
// both via Enter-on-active-item and via a real click on an item.
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown'); // -> alpha
await p.keyboard.press('Enter');
await p.waitForFunction(() => document.getElementById('selected-log')?.textContent.trim() === '1:alpha', null, { timeout: 6000 }).catch(() => {});
await settle(p);
const afterEnterSelect = await snap(p);
await fresh(p);
await openViaTrigger(p);
await p.click('#menu-item-gamma');
await p.waitForFunction(() => document.getElementById('selected-log')?.textContent.trim() === '1:gamma', null, { timeout: 6000 }).catch(() => {});
await settle(p);
const afterClickSelect = await snap(p);
R.activation = {
enterSelectedLog: afterEnterSelect.selectedLog,
clickSelectedLog: afterClickSelect.selectedLog,
};
// 8. anchored positioning (position="bottom") — zero JS, per
// reports/POSITIONING_SPIKE_REPORT.md: glued on open, geometry stable across a
// patch, follows a patch-moved anchor, flips at the viewport edge, tracks scroll.
{
const TOL = 1.5;
const near = (a, b) => Math.abs(a - b) <= TOL;
const geo = () =>
p.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { top: b.top, left: b.left, bottom: b.bottom, right: b.right }; };
const pop = document.getElementById('menu');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
return { open, btn: r(document.getElementById('menu-trigger')), pop: r(pop) };
});
const gluedBelow = (g) => g.open === true && near(g.pop.top, g.btn.bottom) && near(g.pop.left, g.btn.left);
const gluedAbove = (g) => g.open === true && near(g.pop.bottom, g.btn.top) && near(g.pop.left, g.btn.left);
await fresh(p);
await openViaTrigger(p);
const g0 = await geo();
await jsClick(p, 'btn-sib');
await p.waitForFunction(() => document.getElementById('sib').textContent.includes('sib 1'), null, { timeout: 6000 });
await settle(p);
const g1 = await geo();
await jsClick(p, 'btn-move');
await p.waitForFunction((prev) => document.getElementById('menu-trigger').getBoundingClientRect().top > prev + 100, g0.btn.top, { timeout: 6000 });
await settle(p);
const g2 = await geo();
await jsClick(p, 'btn-bottom');
await p.waitForFunction(() => document.getElementById('menu-trigger').getBoundingClientRect().bottom > 600, null, { timeout: 6000 });
await settle(p);
const g3 = await geo();
await jsClick(p, 'btn-reset');
await p.waitForFunction(() => document.getElementById('menu-trigger').getBoundingClientRect().top < 200, null, { timeout: 6000 });
await p.evaluate(() => window.scrollTo(0, 100));
await p.waitForFunction(() => window.scrollY === 100, null, { timeout: 4000 });
await settle(p);
const g4 = await geo();
R.anchored = {
open: g0, afterPatch: g1, afterMove: g2, flip: g3, afterScroll: g4,
pass:
gluedBelow(g0) &&
gluedBelow(g1) && near(g1.pop.top, g0.pop.top) && near(g1.pop.left, g0.pop.left) &&
g2.btn.top > g0.btn.top + 100 && gluedBelow(g2) &&
gluedAbove(g3) &&
gluedBelow(g4),
};
}
return R;
}
const engines = [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]];
const only = process.env.ONLY_BROWSER;
const list = only ? engines.filter(([n]) => n === only) : engines;
const all = {};
for (const [name, launcher] of list) {
console.log('=== ' + name + ' ===');
const b = await launcher.launch();
all[name] = { version: b.version(), runs: [] };
for (let run = 1; run <= 2; run++) {
const ctx = await b.newContext();
const p = await ctx.newPage();
p.setDefaultTimeout(10000);
try { all[name].runs.push(await runSuite(p)); console.log(`${name} run ${run}: ok`); }
catch (e) { all[name].runs.push({ FATAL: String(e) }); console.log(`${name} run ${run}: FATAL ${e}`); }
await ctx.close();
}
await b.close();
}
writeFileSync('menu-reference-results.json', JSON.stringify(all, null, 2));
console.log('written menu-reference-results.json');
// Fuse: the shipped menu component must pass all 7 conformance points on every engine.
const score = (x) => [
x.opened?.open === true && x.opened?.ariaExpanded === 'true' && x.opened?.ariaControls === 'menu' && x.opened?.items?.length === 3,
x.cursorMove?.afterTwoDown?.activeItem === 'beta' && x.cursorMove?.afterTwoDown?.activeDescendant === 'menu-item-beta'
&& x.cursorMove?.afterUp?.activeItem === 'alpha' && x.cursorMove?.afterUp?.activeDescendant === 'menu-item-alpha',
x.unrelatedPatch?.activeBefore === 'menu-item-alpha' && x.unrelatedPatch?.activeAfter === 'menu-item-alpha'
&& x.unrelatedPatch?.gammaLabel === 'Gamma 1' && x.unrelatedPatch?.alphaStillActive === 'true',
x.selfRerender?.survived === true && x.selfRerender?.alphaLabel === 'Alpha 1',
x.removedActive?.betaGoneFromDom === true && x.removedActive?.activeAfterCleared === true && x.removedActive?.noDanglingActive === true,
x.lightDismiss?.openBefore === true && x.lightDismiss?.openAfter === false && x.lightDismiss?.ariaAfter === 'false',
x.activation?.enterSelectedLog === '1:alpha' && x.activation?.clickSelectedLog === '1:gamma',
x.anchored?.pass === true,
];
let ok = true;
for (const [name, e] of Object.entries(all)) {
for (const run of e.runs) {
if (run.FATAL) { console.error(`FATAL ${name}: ${run.FATAL}`); ok = false; continue; }
const passed = score(run).filter(Boolean).length;
if (passed !== 8) { console.error(`MENU FUSE TRIPPED ${name}: ${passed}/8 — ${JSON.stringify(score(run))}`); ok = false; }
}
}
if (!ok) process.exit(1);
console.log('\n✓ menu reference fuse OK — 8/8 on all engines (open, cursor move, unrelated-patch survival, self-rerender survival, deterministic removal collapse, light-dismiss, activation event, zero-JS anchored positioning)');