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 popover-reference driver.mjs
Raw

priv/popover-reference/driver.mjs

// Popover reference conformance driver. Tests the SHIPPED <.popover> component +
// hook: native open via trigger, patch-safety, aria-expanded tracking + survival,
// lossy toggle observation, light-dismiss. Self-asserts. Output: popover-reference-results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4129';
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 pop = document.getElementById('pop');
const trg = document.getElementById('pop-trigger');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
return {
open,
ariaExpanded: trg.getAttribute('aria-expanded'),
ariaControls: trg.getAttribute('aria-controls'),
body: document.getElementById('pop-body')?.textContent.trim(),
triggerLabel: trg.textContent.trim(),
observed: JSON.parse(document.getElementById('observed-json').textContent),
popMark: pop.__mark === 'POP',
};
});
async function fresh(p) { await p.goto(BASE + '/'); await waitConnected(p); await settle(p); }
async function openViaTrigger(p) {
await p.click('#pop-trigger');
await p.waitForFunction(() => { try { return document.getElementById('pop').matches(':popover-open'); } catch { return false; } }, null, { timeout: 4000 }).catch(() => {});
await settle(p);
}
async function runSuite(p) {
const R = {};
// 1. open via native trigger -> open, aria-expanded true, toggle observed
await fresh(p);
await p.evaluate(() => { document.getElementById('pop').__mark = 'POP'; });
await openViaTrigger(p);
R.opened = await snap(p);
// 2-4. content / attr / sibling patch -> stays open, content updates, aria stays
for (const [name, btn, done] of [
['contentPatch', 'btn-content', () => document.getElementById('pop-body').textContent.includes('item 1')],
['attrPatch', 'btn-attr', () => document.getElementById('pop').getAttribute('data-rev') === '1'],
['siblingPatch', 'btn-sib', () => document.getElementById('sib').textContent.includes('sib 1')],
]) {
await fresh(p);
await p.evaluate(() => { document.getElementById('pop').__mark = 'POP'; });
await openViaTrigger(p);
await jsClick(p, btn);
await p.waitForFunction(done, null, { timeout: 6000 }).catch(() => {});
await settle(p);
R[name] = await snap(p);
}
// 5. THE decisive anatomy test: patch that RE-RENDERS the trigger button.
// Does hook-managed aria-expanded survive (protected by JS.ignore_attributes)?
{
await fresh(p);
await openViaTrigger(p);
const beforeAria = (await snap(p)).ariaExpanded;
await jsClick(p, 'btn-trig'); // re-renders the trigger label -> touches the button
await p.waitForFunction(() => document.getElementById('pop-trigger').textContent.includes('(1)'), null, { timeout: 6000 }).catch(() => {});
await settle(p);
const after = await snap(p);
R.triggerRerender = { beforeAria, afterAria: after.ariaExpanded, triggerLabel: after.triggerLabel, stillOpen: after.open, ariaSurvived: after.ariaExpanded === 'true' && after.open === true };
}
// 6. light-dismiss (auto popover): Escape closes, aria false, toggle observed
{
await fresh(p);
await openViaTrigger(p);
const openObserved = (await snap(p)).observed;
await p.keyboard.press('Escape');
await settle(p);
const after = await snap(p);
R.lightDismiss = { openObserved, afterOpen: after.open, afterAria: after.ariaExpanded, observed: after.observed };
}
// 7. anchored positioning (position="bottom"/"top") — zero JS, per
// reports/POSITIONING_SPIKE_REPORT.md. Geometry helpers:
{
const TOL = 1.5;
const near = (a, b) => Math.abs(a - b) <= TOL;
const geo = (popId, trgId) =>
p.evaluate(([popId, trgId]) => {
const r = (el) => { const b = el.getBoundingClientRect(); return { top: b.top, left: b.left, bottom: b.bottom, right: b.right }; };
const pop = document.getElementById(popId);
let open = null; try { open = pop.matches(':popover-open'); } catch {}
return { open, btn: r(document.getElementById(trgId)), pop: r(pop) };
}, [popId, trgId]);
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('pop', 'pop-trigger');
// patch while anchored-open: geometry byte-stable
await jsClick(p, 'btn-content');
await p.waitForFunction(() => document.getElementById('pop-body').textContent.includes('item 1'), null, { timeout: 6000 });
await settle(p);
const g1 = await geo('pop', 'pop-trigger');
// a patch MOVES the anchor -> popover follows with zero JS
await jsClick(p, 'btn-move');
await p.waitForFunction((prev) => document.getElementById('pop-trigger').getBoundingClientRect().top > prev + 100, g0.btn.top, { timeout: 6000 });
await settle(p);
const g2 = await geo('pop', 'pop-trigger');
// anchor near the viewport bottom -> flip-block places it above
await jsClick(p, 'btn-bottom');
await p.waitForFunction(() => document.getElementById('pop-trigger').getBoundingClientRect().bottom > 600, null, { timeout: 6000 });
await settle(p);
const g3 = await geo('pop', 'pop-trigger');
// scroll: popover stays glued to the anchor
await jsClick(p, 'btn-reset');
await p.waitForFunction(() => document.getElementById('pop-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('pop', 'pop-trigger');
// second instance, position="top" (distinct anchor names coexist)
await p.evaluate(() => window.scrollTo(0, 0));
await p.click('#pop2-trigger');
await p.waitForFunction(() => { try { return document.getElementById('pop2').matches(':popover-open'); } catch { return false; } }, null, { timeout: 4000 }).catch(() => {});
await settle(p);
const g5 = await geo('pop2', 'pop2-trigger');
R.anchored = {
open: g0, afterPatch: g1, afterMove: g2, flip: g3, afterScroll: g4, topPreset: g5,
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) &&
gluedAbove(g5),
};
}
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('popover-reference-results.json', JSON.stringify(all, null, 2));
console.log('written popover-reference-results.json');
// Fuse: the shipped popover component must pass all conformance points on every engine.
const score = (x) => [
x.opened?.open === true && x.opened?.ariaExpanded === 'true' && x.opened?.ariaControls === 'pop' && JSON.stringify(x.opened?.observed) === JSON.stringify([true]),
x.contentPatch?.open === true && x.contentPatch?.body === 'item 1' && x.contentPatch?.ariaExpanded === 'true',
x.attrPatch?.open === true,
x.siblingPatch?.open === true,
x.triggerRerender?.ariaSurvived === true && x.triggerRerender?.triggerLabel?.includes('(1)'),
x.lightDismiss?.afterOpen === false && x.lightDismiss?.afterAria === 'false' && JSON.stringify(x.lightDismiss?.observed) === JSON.stringify([true, 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 !== 7) { console.error(`POPOVER FUSE TRIPPED ${name}: ${passed}/7 — ${JSON.stringify(score(run))}`); ok = false; }
}
}
if (!ok) process.exit(1);
console.log('\n✓ popover reference fuse OK — 7/7 on all engines (open, patch-safe, aria survives trigger re-render, light-dismiss, lossy observe, zero-JS anchored positioning incl. flip + follow-on-patch)');