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 harness suite.mjs
Raw

priv/harness/suite.mjs

// live_interaction_contracts — conformance harness driver.
// Runs a uniform patch matrix against every browser-native machine, in real
// Chromium/Firefox/WebKit over a real LiveSocket, twice each. Records node-object
// survival (JS expando), each machine's state at its native source of truth, and
// hook lifecycle. Tests are the source of truth. Output: results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync, readFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:' + (process.env.PORT || '4123');
let VERSIONS = {};
try { VERSIONS = JSON.parse(readFileSync(new URL('./versions.json', import.meta.url))); } catch {}
const ev = (page, fn, arg) => page.evaluate(fn, arg);
const setMark = (page, id) => ev(page, (id) => { const e = document.getElementById(id); if (e) e.__mark = 'M:' + id; return !!e; }, id);
const alive = (page, id) => ev(page, (id) => { const e = document.getElementById(id); return e ? e.__mark === 'M:' + id : null; }, id);
const hookLen = (page) => ev(page, () => window.__hookLog.length);
const hookSince = (page, n, id) => ev(page, ({ n, id }) => window.__hookLog.slice(n).filter((e) => e.id === id), { n, id });
const jsClick = (page, id) => ev(page, (id) => document.getElementById(id).click(), id);
const waitConnected = (page) => page.waitForFunction(() => { const m = document.querySelector('[data-phx-main]'); return window.liveSocket && window.liveSocket.isConnected() && m && m.classList.contains('phx-connected'); }, null, { timeout: 15000 });
const settle = (page) => page.waitForTimeout(140);
async function bump(page, k) {
const before = await ev(page, (k) => (document.getElementById('bump-mirror-' + k)?.textContent) ?? null, k);
await jsClick(page, 'bump-' + k);
// wait until the DOM reflects some change; generic settle since regions differ
await settle(page);
return before;
}
// State reader per machine kind. Returns the machine's own state at native source.
async function stateOf(page, machine) {
return ev(page, (machine) => {
const g = (id) => document.getElementById(id);
switch (machine) {
case 'pop': { const e = g('pop'); if (!e) return null; try { return { open: e.matches(':popover-open') }; } catch { return { open: 'unsupported' }; } }
case 'dlg': { const d = g('dlg'); if (!d) return null; let modal=null; try{modal=d.matches(':modal');}catch{} return { open: d.open, openAttr: d.hasAttribute('open'), modal, activeInside: !!(document.activeElement && d.contains(document.activeElement)) }; }
case 'det': { const d = g('det'); if (!d) return null; return { open: d.open, openAttr: d.hasAttribute('open') }; }
case 'sel': { const s = g('sel'); if (!s) return null; return { value: s.value, focused: document.activeElement === s }; }
case 'inp': { const e = g('inp'); if (!e) return null; return { focused: document.activeElement === e, value: e.value, selStart: e.selectionStart, selEnd: e.selectionEnd }; }
case 'scr': { const e = g('scroller'); if (!e) return null; return { scrollTop: e.scrollTop }; }
}
}, machine);
}
// Put each machine into its "active/open" browser state via native means only.
async function activate(page, machine) {
switch (machine) {
case 'pop': await page.click('#pop-invoker'); await page.waitForFunction(() => { try { return document.getElementById('pop').matches(':popover-open'); } catch { return false; } }, null, { timeout: 4000 }).catch(()=>{}); break;
case 'dlg': await ev(page, () => g_open()); break; // defined below
case 'det': await ev(page, () => { const d = document.getElementById('det'); if (!d.open) document.getElementById('det-sum').click(); }); break;
case 'sel': await ev(page, () => { const s = document.getElementById('sel'); s.focus(); s.value = 'b'; }); break;
case 'inp': await page.click('#inp'); await page.fill('#inp', 'typed-value'); await ev(page, () => document.getElementById('inp').setSelectionRange(2, 6)); break;
case 'scr': await ev(page, () => (document.getElementById('scroller').scrollTop = 500)); break;
}
await settle(page);
}
// helper injected for dialog open (avoids modal blocking real clicks)
async function installHelpers(page) {
await ev(page, () => { window.g_open = () => { const d = document.getElementById('dlg'); if (!d.open) d.showModal(); document.getElementById('dlg-input').focus(); }; });
}
// The uniform patch matrix. Each entry: [name, apply(page), destructive?]
const PATCHES = (machine, contentKey, attrKey, sibKey) => [
['siblingPatch', async (p) => bump(p, sibKey), false],
['contentPatch', async (p) => bump(p, contentKey), false],
['attributePatch', async (p) => bump(p, attrKey), false],
['reorderNearby', async (p) => { await jsClick(p, 'do-reorder'); await settle(p); }, false],
['streamNearby', async (p) => { await jsClick(p, 'do-sreset'); await settle(p); }, false],
['pushPatch', async (p) => { await jsClick(p, 'do-patch'); await p.waitForFunction(() => document.getElementById('nav-el').getAttribute('data-p') === 'patched', null, { timeout: 6000 }).catch(()=>{}); await settle(p); }, false],
['reconnect', async (p) => { await ev(p, () => window.liveSocket.disconnect()); await p.waitForFunction(() => !window.liveSocket.isConnected(), null, { timeout: 6000 }); await ev(p, () => window.liveSocket.connect()); await waitConnected(p); await settle(p); }, false],
];
const MACHINES = [
{ id: 'pop', node: 'pop', keys: ['pop_content', 'pop_attr', 'pop_sib'], stateHome: 'top-layer / :popover-open pseudo-class' },
{ id: 'dlg', node: 'dlg', keys: ['dlg_content', 'dlg_attr', 'dlg_sib'], stateHome: 'server-reconciled `open` attribute' },
{ id: 'det', node: 'det', keys: ['det_content', 'det_attr', 'det_sib'], stateHome: 'server-reconciled `open` attribute' },
{ id: 'sel', node: 'sel', keys: ['sel_content', 'sel_attr', 'sel_sib'], stateHome: 'browser editing state (value property)' },
{ id: 'inp', node: 'inp', keys: ['inp_val', 'inp_val', 'inp_sib'], stateHome: 'browser editing state (value/selection) — DOM node-bound' },
{ id: 'scr', node: 'scroller', keys: ['scr_content', 'scr_attr', 'scr_sib'], stateHome: 'DOM node-bound (scrollTop)' },
];
async function runMachine(page, m) {
const R = { stateHome: m.stateHome, patches: {} };
const [ck, ak, sk] = m.keys;
for (const [name, apply] of PATCHES(m.id, ck, ak, sk)) {
// fresh page each patch so machines don't contaminate each other
await page.goto(BASE + '/');
await waitConnected(page);
await installHelpers(page);
await settle(page);
await activate(page, m.id);
await setMark(page, m.node);
const before = await stateOf(page, m.id);
const n = await hookLen(page);
await apply(page);
// re-focus dialog input read happens naturally; measure
R.patches[name] = {
before,
after: await stateOf(page, m.id),
nodeAlive: await alive(page, m.node),
hook: await hookSince(page, n, m.node),
};
}
// destructive controls (own fresh pages)
await page.goto(BASE + '/'); await waitConnected(page); await installHelpers(page); await settle(page);
await activate(page, m.id); await setMark(page, m.node);
await jsClick(page, 'do-nav');
await page.waitForSelector('#other-page', { timeout: 6000 });
await waitConnected(page);
await jsClick(page, 'go-back');
await page.waitForSelector('#' + m.node, { state: 'attached', timeout: 6000 });
await waitConnected(page);
await settle(page);
R.patches.pushNavigate = { after: await stateOf(page, m.id), nodeAlive: await alive(page, m.node), destructiveControl: true };
await page.goto(BASE + '/'); await waitConnected(page); await installHelpers(page); await settle(page);
await activate(page, m.id); await setMark(page, m.node);
await ev(page, (node) => { const e = document.getElementById(node); const p = e.parentNode, nx = e.nextSibling; e.remove(); p.insertBefore(e, nx); }, m.node);
await settle(page);
R.patches.removeReinsert = { after: await stateOf(page, m.id), nodeAlive: await alive(page, m.node), destructiveControl: true };
return R;
}
async function runSuite(page) {
const out = { ime: 'not tested — IME composition not reliably synthesizable via Playwright across engines' };
for (const m of MACHINES) out[m.id] = await runMachine(page, m);
return out;
}
const ALL = [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]];
const only = process.env.ONLY_BROWSER;
const engines = only ? ALL.filter(([n]) => n === only) : ALL;
const all = { versions: VERSIONS, engines: {} };
for (const [name, launcher] of engines) {
console.log('=== ' + name + ' ===');
const browser = await launcher.launch();
all.engines[name] = { version: browser.version(), runs: [] };
for (let run = 1; run <= 2; run++) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
try { all.engines[name].runs.push(await runSuite(page)); console.log(`${name} run ${run}: ok`); }
catch (e) { all.engines[name].runs.push({ FATAL: String(e) }); console.log(`${name} run ${run}: FATAL ${e}`); }
await ctx.close();
}
await browser.close();
}
writeFileSync('results.json', JSON.stringify(all, null, 2));
console.log('written results.json');