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

priv/tooltip-reference/driver.mjs

// Tooltip reference conformance driver. Tests the SHIPPED <.tooltip> component +
// hook: hover (mouseenter/mouseleave) open/close, focus/blur open/close, patch
// survival (content/attr/sibling), aria-describedby survival across a trigger
// re-render, and node identity of the tooltip element across patches. All state
// reads come from native sources (:popover-open, document.activeElement,
// getAttribute) — never HTML string comparison. Self-asserts.
// Output: tooltip-reference-results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4131';
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 isOpen = (p) =>
p.evaluate(() => { try { return document.getElementById('tip').matches(':popover-open'); } catch { return false; } });
const snap = (p) =>
p.evaluate(() => {
const tip = document.getElementById('tip');
const trg = document.getElementById('tip-trigger');
let open = null; try { open = tip.matches(':popover-open'); } catch {}
return {
open,
ariaDescribedby: trg.getAttribute('aria-describedby'),
body: document.getElementById('tip-body')?.textContent.trim(),
triggerLabel: trg.textContent.trim(),
tipMark: tip.__mark === 'TIP',
activeIsTrigger: document.activeElement === trg,
};
});
async function fresh(p) {
await p.goto(BASE + '/');
await waitConnected(p);
await settle(p);
await p.evaluate(() => { document.getElementById('tip').__mark = 'TIP'; });
}
async function waitOpen(p) {
await p.waitForFunction(() => { try { return document.getElementById('tip').matches(':popover-open'); } catch { return false; } }, null, { timeout: 4000 }).catch(() => {});
await settle(p);
}
async function waitClosed(p) {
await p.waitForFunction(() => { try { return !document.getElementById('tip').matches(':popover-open'); } catch { return true; } }, null, { timeout: 4000 }).catch(() => {});
await settle(p);
}
async function hoverOpen(p) {
// Force an actual pointer move: after a fresh navigation the OS-level
// cursor can already be sitting at the same viewport coordinates as the
// (re-rendered) trigger, and some engines then skip firing mouseenter
// because the pointer "didn't move". Move away first so hover() always
// produces a real mouseenter.
await p.mouse.move(0, 0);
await p.hover('#tip-trigger');
await waitOpen(p);
}
async function hoverAway(p) {
await p.mouse.move(2, 2); // moves off the trigger -> mouseleave
await waitClosed(p);
}
async function focusOpen(p) {
await p.focus('#tip-trigger');
await waitOpen(p);
}
async function blurAway(p) {
await p.evaluate(() => document.activeElement && document.activeElement.blur());
await waitClosed(p);
}
async function runSuite(p) {
const R = {};
// 1. hover opens, mouseleave closes.
await fresh(p);
await hoverOpen(p);
R.hoverOpened = await snap(p);
await hoverAway(p);
R.hoverClosed = await snap(p);
// 2. focus opens, blur closes.
await fresh(p);
await focusOpen(p);
R.focusOpened = await snap(p);
await blurAway(p);
R.focusClosed = await snap(p);
// 3. stays open across a content patch, and the content actually updates.
await fresh(p);
await hoverOpen(p);
await jsClick(p, 'btn-content');
await p.waitForFunction(() => document.getElementById('tip-body')?.textContent.includes('tip 1'), null, { timeout: 6000 }).catch(() => {});
await settle(p);
R.contentPatch = await snap(p);
// 4. stays open across attribute + sibling patches.
await fresh(p);
await hoverOpen(p);
await jsClick(p, 'btn-attr');
await p.waitForFunction(() => document.getElementById('tip').getAttribute('data-rev') === '1', null, { timeout: 6000 }).catch(() => {});
await settle(p);
R.attrPatch = await snap(p);
await jsClick(p, 'btn-sib');
await p.waitForFunction(() => document.getElementById('sib')?.textContent.includes('sib 1'), null, { timeout: 6000 }).catch(() => {});
await settle(p);
R.sibPatch = await snap(p);
// 5. aria-describedby points at the tooltip id and survives a patch that
// re-renders the trigger; node identity of the tooltip preserved throughout.
await fresh(p);
await hoverOpen(p);
const beforeTrig = await snap(p);
await jsClick(p, 'btn-trig');
await p.waitForFunction(() => document.getElementById('tip-trigger')?.textContent.includes('(1)'), null, { timeout: 6000 }).catch(() => {});
await settle(p);
const afterTrig = await snap(p);
R.triggerRerender = {
beforeAriaDescribedby: beforeTrig.ariaDescribedby,
afterAriaDescribedby: afterTrig.ariaDescribedby,
triggerLabel: afterTrig.triggerLabel,
stillOpen: afterTrig.open,
tipMarkPreserved: afterTrig.tipMark,
};
// 8. anchored positioning (position="bottom") — zero JS, per
// reports/POSITIONING_SPIKE_REPORT.md. Held open via focus (hover would break
// when a patch moves the trigger; focus survives an attribute-only morph).
{
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 tip = document.getElementById('tip');
let open = null; try { open = tip.matches(':popover-open'); } catch {}
return { open, btn: r(document.getElementById('tip-trigger')), pop: r(tip) };
});
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);
// Park the pointer away from the trigger first: earlier hover tests leave the
// mouse ON it, and when btn-move shifts the trigger out from under a stationary
// pointer, CHROMIUM fires mouseleave (Firefox/WebKit do not) — the hook then
// correctly closes the tooltip. Real engine difference, not a positioning bug;
// this test isolates geometry, so it holds the tooltip open via focus only.
await p.mouse.move(0, 0);
await focusOpen(p);
const g0 = await geo();
await jsClick(p, 'btn-content');
await p.waitForFunction(() => document.getElementById('tip-body').textContent.includes('tip 1'), null, { timeout: 6000 });
await settle(p);
const g1 = await geo();
await jsClick(p, 'btn-move');
await p.waitForFunction((prev) => document.getElementById('tip-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('tip-trigger').getBoundingClientRect().bottom > 600, null, { timeout: 6000 });
await settle(p);
const g3 = await geo();
await jsClick(p, 'btn-reset');
await p.waitForFunction(() => document.getElementById('tip-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('tooltip-reference-results.json', JSON.stringify(all, null, 2));
console.log('written tooltip-reference-results.json');
// Fuse: the shipped tooltip component must pass all conformance points on every engine.
const score = (x) => [
x.hoverOpened?.open === true && x.hoverOpened?.ariaDescribedby === 'tip',
x.hoverClosed?.open === false,
x.focusOpened?.open === true && x.focusOpened?.activeIsTrigger === true,
x.focusClosed?.open === false,
x.contentPatch?.open === true && x.contentPatch?.body === 'tip 1' && x.contentPatch?.tipMark === true,
x.attrPatch?.open === true && x.sibPatch?.open === true,
x.triggerRerender?.beforeAriaDescribedby === 'tip' && x.triggerRerender?.afterAriaDescribedby === 'tip'
&& x.triggerRerender?.stillOpen === true && x.triggerRerender?.triggerLabel?.includes('(1)')
&& x.triggerRerender?.tipMarkPreserved === true,
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(`TOOLTIP FUSE TRIPPED ${name}: ${passed}/8 — ${JSON.stringify(score(run))}`); ok = false; }
}
}
if (!ok) process.exit(1);
console.log('\n✓ tooltip reference fuse OK — 8/8 on all engines (hover open/close, focus open/close, patch-safe content/attr/sibling, aria-describedby survives trigger re-render, node identity preserved, zero-JS anchored positioning)');