Current section
Files
Jump to
Current section
Files
priv/select-reference/driver.mjs
// Select reference conformance driver. Tests the SHIPPED <.select> component + hook:
// native open, aria-activedescendant cursor, cursor survival across unrelated patches,
// SERVER-owned selection round-trip (Enter + click) into the hidden form input /
// trigger label / aria-selected, value survival across patches, deterministic
// collapse when the active option is removed, Escape dismiss, APG open-on-selected,
// and zero-JS anchored positioning. Self-asserts. Output: select-reference-results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4134';
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('sel-trigger');
const pop = document.getElementById('sel');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
const options = Array.from(pop.querySelectorAll('[role="option"]')).map((it) => ({
id: it.getAttribute('data-item'),
domId: it.id,
active: it.getAttribute('data-highlighted'),
selected: it.getAttribute('aria-selected'),
label: it.textContent.trim(),
}));
return {
open,
ariaExpanded: trg.getAttribute('aria-expanded'),
ariaControls: trg.getAttribute('aria-controls'),
activeDescendant: trg.getAttribute('aria-activedescendant'),
activeItem: options.find((o) => o.active === 'true')?.id ?? null,
options,
triggerLabel: trg.textContent.trim(),
inputValue: document.getElementById('sel-input')?.value ?? null,
changeLog: document.getElementById('change-log').textContent.trim(),
focusOnTrigger: document.activeElement === trg,
};
});
async function fresh(p) { await p.goto(BASE + '/'); await waitConnected(p); await settle(p); }
async function openViaTrigger(p) {
await p.click('#sel-trigger');
await p.waitForFunction(() => { try { return document.getElementById('sel').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 = {};
// 1. open via native trigger: aria-expanded, listbox, options, focus handoff
await fresh(p);
await openViaTrigger(p);
R.opened = await snap(p);
// 2. cursor: two downs -> beta; up -> alpha; focus stays on trigger
await arrow(p, 'ArrowDown');
await arrow(p, 'ArrowDown');
const afterTwoDown = await snap(p);
await arrow(p, 'ArrowUp');
const afterUp = await snap(p);
R.cursorMove = { afterTwoDown, afterUp };
// 3. unrelated patch (gamma label) while cursor on alpha -> cursor + open survive
await jsClick(p, 'btn-content');
await p.waitForFunction(() => document.getElementById('sel-opt-gamma')?.textContent.includes('Gamma 1'), null, { timeout: 6000 });
await settle(p);
R.unrelatedPatch = await snap(p);
// 4. Enter selects (server round-trip): closes; value/label/input update; reopen
// shows aria-selected AND the cursor starts on the selected option (APG).
await p.keyboard.press('Enter');
await p.waitForFunction(() => document.getElementById('change-log').textContent.trim() === '1:alpha', null, { timeout: 6000 });
await settle(p);
R.afterEnterSelect = await snap(p);
await openViaTrigger(p);
R.reopenedAfterSelect = await snap(p);
// 5. click selects: real click on gamma -> closes; server value updates
await p.click('#sel-opt-gamma');
await p.waitForFunction(() => document.getElementById('change-log').textContent.trim() === '2:gamma', null, { timeout: 6000 });
await settle(p);
R.afterClickSelect = await snap(p);
// 6. value survives an unrelated patch (server-owned by construction)
await jsClick(p, 'btn-sib');
await p.waitForFunction(() => document.getElementById('sib').textContent.includes('sib 1'), null, { timeout: 6000 });
await settle(p);
R.valueAfterPatch = await snap(p);
// 7. active option removed by a patch -> deterministic collapse, no dangling aria
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown');
await arrow(p, 'ArrowDown'); // beta
const beforeRemove = await snap(p);
await jsClick(p, 'btn-remove-beta');
await p.waitForFunction(() => !document.getElementById('sel-opt-beta'), null, { timeout: 6000 });
await settle(p);
const afterRemove = await snap(p);
R.removedActive = {
activeBefore: beforeRemove.activeItem,
betaGoneFromDom: afterRemove.options.every((o) => o.id !== 'beta'),
activeAfterCleared: afterRemove.activeItem === null && afterRemove.activeDescendant === '',
stillOpen: afterRemove.open,
};
// 8. Escape closes; aria-expanded false; cursor cleared
await fresh(p);
await openViaTrigger(p);
await arrow(p, 'ArrowDown');
await p.keyboard.press('Escape');
await settle(p);
const afterEsc = await snap(p);
R.escape = { open: afterEsc.open, ariaExpanded: afterEsc.ariaExpanded, activeDescendant: afterEsc.activeDescendant };
// 9. anchored positioning (position="bottom") — zero JS, per
// reports/POSITIONING_SPIKE_REPORT.md
{
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('sel');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
return { open, btn: r(document.getElementById('sel-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('sel-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('sel-trigger').getBoundingClientRect().bottom > 600, null, { timeout: 6000 });
await settle(p);
const g3 = await geo();
await jsClick(p, 'btn-reset');
await p.waitForFunction(() => document.getElementById('sel-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('select-reference-results.json', JSON.stringify(all, null, 2));
console.log('written select-reference-results.json');
// Fuse: the shipped select component must pass all conformance points on every engine.
const score = (x) => [
x.opened?.open === true && x.opened?.ariaExpanded === 'true' && x.opened?.ariaControls === 'sel'
&& x.opened?.options?.length === 3 && x.opened?.focusOnTrigger === true && x.opened?.activeItem === null,
x.cursorMove?.afterTwoDown?.activeItem === 'beta' && x.cursorMove?.afterTwoDown?.activeDescendant === 'sel-opt-beta'
&& x.cursorMove?.afterUp?.activeItem === 'alpha' && x.cursorMove?.afterUp?.activeDescendant === 'sel-opt-alpha'
&& x.cursorMove?.afterUp?.focusOnTrigger === true,
x.unrelatedPatch?.open === true && x.unrelatedPatch?.activeItem === 'alpha'
&& x.unrelatedPatch?.options?.find((o) => o.id === 'gamma')?.label === 'Gamma 1',
x.afterEnterSelect?.open === false && x.afterEnterSelect?.changeLog === '1:alpha'
&& x.afterEnterSelect?.inputValue === 'alpha' && x.afterEnterSelect?.triggerLabel === 'Alpha'
&& x.reopenedAfterSelect?.options?.find((o) => o.id === 'alpha')?.selected === 'true'
&& x.reopenedAfterSelect?.activeItem === 'alpha',
x.afterClickSelect?.open === false && x.afterClickSelect?.changeLog === '2:gamma'
&& x.afterClickSelect?.inputValue === 'gamma',
x.valueAfterPatch?.inputValue === 'gamma' && x.valueAfterPatch?.triggerLabel?.startsWith('Gamma'),
x.removedActive?.activeBefore === 'beta' && x.removedActive?.betaGoneFromDom === true
&& x.removedActive?.activeAfterCleared === true && x.removedActive?.stillOpen === true,
x.escape?.open === false && x.escape?.ariaExpanded === 'false' && x.escape?.activeDescendant === '',
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 !== 9) { console.error(`SELECT FUSE TRIPPED ${name}: ${passed}/9 — ${JSON.stringify(score(run))}`); ok = false; }
}
}
if (!ok) process.exit(1);
console.log('\n✓ select reference fuse OK — 9/9 on all engines (open + focus handoff, cursor, unrelated-patch survival, server-owned selection via Enter + click, value survives patches, removal collapse, Escape, open-on-selected, zero-JS anchored positioning)');