Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts priv combobox-reference driver.mjs
Raw

priv/combobox-reference/driver.mjs

// Combobox reference conformance driver. Tests the SHIPPED <.combobox> component +
// hook + the SHIPPED Channel guard: versioned query/result basic path, stale drop by
// construction (guard on), the NEGATIVE control (guard off => stale reaches the DOM
// and only client telemetry flags it), cursor collapse + logical-id rebind across
// result replacements, synthetic IME gating, popover shell survival, server-owned
// selection, and zero-JS anchored positioning. Deterministic ordering via the
// fixture's query/release gate — no timing races.
//
// Version numbers are asserted RELATIVELY (rendered == seen, deltas), never as
// absolute constants: engines differ in how many input events one fill() dispatches
// (Firefox fires two), so absolute version numbers are not portable.
// Self-asserts. Output: combobox-reference-results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4135';
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 inp = document.getElementById('cb-input');
const list = document.getElementById('cb');
let open = null; try { open = list.matches(':popover-open'); } catch {}
const options = Array.from(list.querySelectorAll('[role="option"]')).map((it) => ({
id: it.getAttribute('data-item'),
domId: it.id,
active: it.getAttribute('data-highlighted'),
}));
const dbg = document.getElementById('dbg').textContent;
const num = (label) => parseInt((dbg.match(new RegExp(label + ' (\\d+)')) || [])[1] ?? '-1', 10);
return {
open,
popoverAttr: list.getAttribute('popover'),
version: parseInt(list.getAttribute('data-version') || '0', 10),
ariaExpanded: inp.getAttribute('aria-expanded'),
activeDescendant: inp.getAttribute('aria-activedescendant'),
activeItem: options.find((o) => o.active === 'true')?.id ?? null,
dataStale: inp.getAttribute('data-stale'),
options,
seen: num('seen'), rendered: num('rendered'), drops: num('drops'),
query: document.getElementById('dbg-query').textContent.trim(),
changeLog: document.getElementById('change-log').textContent.trim(),
focusOnInput: document.activeElement === inp,
};
});
async function fresh(p) { await p.goto(BASE + '/'); await waitConnected(p); await settle(p); }
async function arrow(p, key) { await p.keyboard.press(key); await settle(p); }
// Auto-release path: fill, then wait until the server has rendered the LATEST seen
// version for exactly this query (relative — no absolute version constants).
async function fillAndSettle(p, text) {
await p.fill('#cb-input', text);
await p.waitForFunction((text) => {
const dbg = document.getElementById('dbg').textContent;
const num = (label) => parseInt((dbg.match(new RegExp(label + ' (\\d+)')) || [])[1] ?? '-1', 10);
return num('rendered') === num('seen') &&
document.getElementById('dbg-query').textContent.trim() === text;
}, text, { timeout: 6000 });
await settle(p);
}
async function runSuite(p) {
const R = {};
await fresh(p);
// 1. basic query/result: rendered catches up to seen; listbox open; aria wired
await fillAndSettle(p, 'ap');
R.basic = await snap(p);
// 2. guard ON (construction): with auto off, emit a newer query, release it, then
// re-release the OLDER previously-rendered version -> the guard drops it
const staleV = R.basic.rendered; // the version we will replay as stale
await jsClick(p, 'toggle-auto');
await p.waitForFunction(() => document.getElementById('dbg').textContent.includes('auto false'), null, { timeout: 6000 });
await p.fill('#cb-input', 'apr'); // emits (>=1) newer versions; all pending
await p.waitForFunction((staleV) => {
const dbg = document.getElementById('dbg').textContent;
const seen = parseInt((dbg.match(/seen (\d+)/) || [])[1] ?? '-1', 10);
return seen > staleV && !!document.getElementById('release-' + seen);
}, staleV, { timeout: 6000 });
const newest = (await snap(p)).seen;
await jsClick(p, 'release-' + newest);
await p.waitForFunction((v) => {
const dbg = document.getElementById('dbg').textContent;
return parseInt((dbg.match(/rendered (\d+)/) || [])[1] ?? '-1', 10) === v;
}, newest, { timeout: 6000 });
const dropsBefore = (await snap(p)).drops;
await jsClick(p, 'release-' + staleV); // superseded -> Channel.accept must drop
await p.waitForFunction((d) => {
const dbg = document.getElementById('dbg').textContent;
return parseInt((dbg.match(/drops (\d+)/) || [])[1] ?? '-1', 10) === d + 1;
}, dropsBefore, { timeout: 6000 });
await settle(p);
const g = await snap(p);
R.guardOn = {
renderedStaysNewest: g.rendered === newest && g.version === newest,
dropsDelta: g.drops - dropsBefore,
options: g.options.map((o) => o.id).join(','),
dataStale: g.dataStale,
};
// 3. guard OFF (negative control): same stale release now REACHES the DOM;
// only the client telemetry flags it
await jsClick(p, 'toggle-guard');
await p.waitForFunction(() => document.getElementById('dbg').textContent.includes('guard false'), null, { timeout: 6000 });
await jsClick(p, 'release-' + staleV);
await p.waitForFunction((v) => parseInt(document.getElementById('cb').getAttribute('data-version') || '0', 10) === v, staleV, { timeout: 6000 });
await settle(p);
const g2 = await snap(p);
R.guardOff = {
staleRendered: g2.version === staleV,
dataStale: g2.dataStale,
options: g2.options.map((o) => o.id).join(','),
};
await jsClick(p, 'toggle-guard');
await p.waitForFunction(() => document.getElementById('dbg').textContent.includes('guard true'), null, { timeout: 6000 });
await jsClick(p, 'toggle-auto');
await p.waitForFunction(() => document.getElementById('dbg').textContent.includes('auto true'), null, { timeout: 6000 });
// 4. cursor collapse: active option removed by the next result set
await fillAndSettle(p, 'ap'); // apple, apricot
await arrow(p, 'ArrowDown'); // apple active
const beforeCollapse = await snap(p);
await fillAndSettle(p, 'apr'); // apricot only -> apple gone
const afterCollapse = await snap(p);
R.collapse = {
activeBefore: beforeCollapse.activeItem,
activeAfter: afterCollapse.activeItem,
ariaAfter: afterCollapse.activeDescendant,
stillOpen: afterCollapse.open,
};
// 5. cursor rebind: active survives a wholesale result replacement (logical id)
await fillAndSettle(p, 'ap'); // apple, apricot
await arrow(p, 'ArrowDown'); // apple active
await fillAndSettle(p, 'a'); // apple, apricot (superset query)
const afterRebind = await snap(p);
R.rebind = {
activeAfter: afterRebind.activeItem,
ariaAfter: afterRebind.activeDescendant,
pointsAtLiveNode: await p.evaluate(() => {
const id = document.getElementById('cb-input').getAttribute('aria-activedescendant');
return !!(id && document.getElementById(id));
}),
};
// 6. IME (synthetic): no emission during composition; exactly one at compositionend
{
const seenBefore = (await snap(p)).seen;
await p.evaluate(() => {
const inp = document.getElementById('cb-input');
inp.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
inp.value = 'ba';
inp.dispatchEvent(new InputEvent('input', { bubbles: true }));
});
await settle(p);
const seenDuring = (await snap(p)).seen;
await p.evaluate(() => {
const inp = document.getElementById('cb-input');
inp.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
});
await p.waitForFunction((s) => {
const dbg = document.getElementById('dbg').textContent;
return parseInt((dbg.match(/seen (\d+)/) || [])[1] ?? '-1', 10) === s + 1;
}, seenBefore, { timeout: 6000 }).catch(() => {});
await settle(p);
const seenAfter = (await snap(p)).seen;
R.ime = { emitsDuring: seenDuring - seenBefore, emitsAtEnd: seenAfter - seenDuring };
}
// 7. popover shell: static server-rendered attribute survived every replacement;
// still open; and an unrelated sibling patch does not disturb it
await jsClick(p, 'btn-sib');
await p.waitForFunction(() => document.getElementById('sib').textContent.includes('sib 1'), null, { timeout: 6000 });
await settle(p);
R.shell = await snap(p);
// 8. selection: Enter on active -> lossless on_select; server records; closes
await arrow(p, 'ArrowDown'); // banana active ("ba" results)
await p.keyboard.press('Enter');
await p.waitForFunction(() => document.getElementById('change-log').textContent.trim() === '1:banana', null, { timeout: 6000 });
await settle(p);
R.select = await snap(p);
// 9. anchored positioning (position="bottom") — zero JS
{
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('cb');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
return { open, btn: r(document.getElementById('cb-input')), 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 fillAndSettle(p, 'ap');
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('cb-input').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('cb-input').getBoundingClientRect().bottom > 600, null, { timeout: 6000 });
await settle(p);
const g3 = await geo();
await jsClick(p, 'btn-reset');
await p.waitForFunction(() => document.getElementById('cb-input').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('combobox-reference-results.json', JSON.stringify(all, null, 2));
console.log('written combobox-reference-results.json');
// Fuse: the shipped combobox + Channel guard must pass every point on every engine.
const score = (x) => [
x.basic?.open === true && x.basic?.rendered === x.basic?.seen && x.basic?.version === x.basic?.rendered
&& x.basic?.ariaExpanded === 'true' && x.basic?.query === 'ap'
&& x.basic?.options?.map((o) => o.id).join(',') === 'apple,apricot'
&& x.basic?.focusOnInput === true && x.basic?.dataStale === 'false',
x.guardOn?.renderedStaysNewest === true && x.guardOn?.dropsDelta === 1
&& x.guardOn?.dataStale === 'false' && x.guardOn?.options === 'apricot',
x.guardOff?.staleRendered === true && x.guardOff?.dataStale === 'true'
&& x.guardOff?.options === 'apple,apricot',
x.collapse?.activeBefore === 'apple' && x.collapse?.activeAfter === null
&& x.collapse?.ariaAfter === '' && x.collapse?.stillOpen === true,
x.rebind?.activeAfter === 'apple' && x.rebind?.ariaAfter === 'cb-opt-apple'
&& x.rebind?.pointsAtLiveNode === true,
x.ime?.emitsDuring === 0 && x.ime?.emitsAtEnd === 1,
x.shell?.popoverAttr === 'manual' && x.shell?.open === true,
x.select?.changeLog === '1:banana' && x.select?.open === false,
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(`COMBOBOX FUSE TRIPPED ${name}: ${passed}/9 — ${JSON.stringify(score(run))}`); ok = false; }
}
}
if (!ok) process.exit(1);
console.log('\n✓ combobox reference fuse OK — 9/9 on all engines (versioned query, stale dropped by construction, guard-off negative control, cursor collapse + rebind, IME gating, popover shell, server-owned selection, zero-JS anchored positioning)');