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 cursor-spike spike.mjs
Raw

priv/cursor-spike/spike.mjs

// Cursor replace-with-same-id spike driver.
// Moves the cursor to a non-first item, then forces DOM changes that (in some
// cases) REPLACE the node while keeping the logical id, and records whether the
// cursor state survives, by which mechanism it rebinds, and whether ARIA/focus
// stay pointed at live nodes. Tests are the source of truth. Output: results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4126';
const settle = (page) => page.waitForTimeout(160);
const jsClick = (page, sel) => page.evaluate((sel) => document.querySelector(sel).click(), sel);
const waitConnected = (page) =>
page.waitForFunction(() => document.querySelector('[data-phx-main]')?.classList.contains('phx-connected'), null, { timeout: 12000 });
const nodeId = (page, logical) => page.evaluate((l) => document.querySelector(`[data-item="${l}"]`)?.id ?? null, logical);
const setItemMark = (page, logical) =>
page.evaluate((l) => { const n = document.querySelector(`[data-item="${l}"]`); if (n) n.__mark = 'ITEM:' + l; return !!n; }, logical);
const itemMarkAlive = (page, logical) =>
page.evaluate((l) => { const n = document.querySelector(`[data-item="${l}"]`); return n ? n.__mark === 'ITEM:' + l : null; }, logical);
const setContainerMark = (page) =>
page.evaluate(() => { const c = document.getElementById('clist'); if (c) c.__cmark = 'CONTAINER'; c.__hookInstanceTag = c.__hookInstanceTag || Math.random(); return !!c; });
const containerMarkAlive = (page) =>
page.evaluate(() => { const c = document.getElementById('clist'); return c ? c.__cmark === 'CONTAINER' : null; });
const snap = (page, activeLogical) =>
page.evaluate((activeLogical) => {
const c = document.getElementById('clist');
const activeNodes = [...document.querySelectorAll('[data-item][data-active="true"]')].map((n) => n.getAttribute('data-item'));
const aria = c ? c.getAttribute('aria-activedescendant') : null;
const ariaTarget = aria ? document.getElementById(aria) : null;
return {
containerExists: !!c,
activeMarked: activeNodes, // which logical ids carry data-active=true
aria, // aria-activedescendant value (a DOM id)
ariaPointsAtLiveNode: !!ariaTarget, // does that DOM id resolve to a node?
ariaMatchesIntended: ariaTarget ? ariaTarget.getAttribute('data-item') === activeLogical : false,
rovingTabZeroOn: [...document.querySelectorAll('[data-item][tabindex="0"]')].map((n) => n.getAttribute('data-item')),
focusLogical: document.activeElement ? document.activeElement.getAttribute?.('data-item') : null,
store: window.__cursorStore.clist || null,
};
}, activeLogical);
const lastMountRebindSource = (page) =>
page.evaluate(() => {
const rebinds = window.__cursorLog.filter((e) => e.ev === 'mounted-rebind' || e.ev === 'mounted-fresh');
return rebinds.length ? rebinds[rebinds.length - 1].ev : null;
});
// move cursor to `charlie` (index 2) via keyboard, then focus it.
async function armCursor(page) {
await page.evaluate(() => document.getElementById('clist').focus());
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown'); // now on charlie
await settle(page);
await page.evaluate(() => document.querySelector('[data-item="charlie"]').focus());
await settle(page);
}
async function oneCase(page, name, action, { markContainer = false } = {}) {
await page.goto(BASE + '/');
await waitConnected(page);
await settle(page);
await armCursor(page);
const activeLogical = 'charlie';
const beforeNodeId = await nodeId(page, activeLogical);
await setItemMark(page, activeLogical);
if (markContainer) await setContainerMark(page);
const before = await snap(page, activeLogical);
const logLenBefore = await page.evaluate(() => window.__cursorLog.length);
await action(page);
await settle(page);
await settle(page);
const afterNodeId = await nodeId(page, activeLogical);
const itemAlive = await itemMarkAlive(page, activeLogical);
const containerAlive = markContainer ? await containerMarkAlive(page) : null;
const after = await snap(page, activeLogical);
const newLog = await page.evaluate((n) => window.__cursorLog.slice(n), logLenBefore);
const rebindSource = await lastMountRebindSource(page);
return {
intent: name,
activeLogical,
itemNodeReplaced: itemAlive === false || (beforeNodeId && afterNodeId && itemAlive === false),
itemNodePresent: afterNodeId !== null,
itemExpandoSurvived: itemAlive,
containerNodeReplaced: markContainer ? containerAlive === false : null,
hookMountedAgain: newLog.some((e) => e.ev === 'mounted-fresh' || e.ev === 'mounted-rebind'),
hookRebindSourceOnRemount: newLog.find((e) => e.ev === 'mounted-rebind' || e.ev === 'mounted-fresh')?.ev || null,
cursorStateSurvived: after.activeMarked.length === 1 && after.activeMarked[0] === activeLogical,
ariaPointsAtLiveNode: after.ariaPointsAtLiveNode,
ariaMatchesIntended: after.ariaMatchesIntended,
focusSurvived: after.focusLogical === activeLogical,
before,
after,
cursorLogDelta: newLog,
};
}
async function runSpike(page) {
const R = {};
// Control: content patch (item labels change; LiveView mutates nodes in place).
R.control_contentPatch = await oneCase(page, 'content patch (expect node MUTATED, cursor survives via node continuity)',
(p) => jsClick(p, "#c-content"));
// Case A: item tag swap li->button. Same id + same data-item, DIFFERENT tag =>
// morphdom must REPLACE the node. This is the intentional node-identity break.
R.caseA_itemTagSwap = await oneCase(page, 'item tag swap li->button (INTENTIONAL node replacement, same logical id)',
async (p) => { await jsClick(p, '#c-item-tag'); await p.waitForFunction(() => document.querySelector('[data-item="charlie"]')?.tagName === 'BUTTON', null, { timeout: 6000 }).catch(()=>{}); });
// Case B: container tag swap ul->ol. Replaces the node carrying the Cursor hook
// => hook instance is destroyed and remounted; tests external-store rebind.
R.caseB_containerTagSwap = await oneCase(page, 'container tag swap ul->ol (replaces hook node; tests external-store rebind)',
async (p) => { await jsClick(p, '#c-container-tag'); await p.waitForFunction(() => document.getElementById('clist')?.tagName === 'OL', null, { timeout: 6000 }).catch(()=>{}); },
{ markContainer: true });
// Case C: wrap toggle changes tree shape around the list.
R.caseC_wrapToggle = await oneCase(page, 'wrap toggle (tree-shape change around list)',
async (p) => { await jsClick(p, '#c-wrap'); await p.waitForFunction(() => !!document.getElementById('wrapper'), null, { timeout: 6000 }).catch(()=>{}); },
{ markContainer: true });
// Case D: hide the ACTIVE item (remove), then it stays gone — deterministic collapse.
R.caseD_hideActive = await oneCase(page, 'hide active item charlie (removed; cursor must collapse deterministically)',
async (p) => { await jsClick(p, '#c-hide-charlie'); await p.waitForFunction(() => !document.querySelector('[data-item="charlie"]'), null, { timeout: 6000 }).catch(()=>{}); });
// Case E: hide active item then show all again — same logical id returns on a NEW node.
R.caseE_hideThenShow = await (async () => {
await page.goto(BASE + '/'); await waitConnected(page); await settle(page); await armCursor(page);
await setItemMark(page, 'charlie');
const before = await snap(page, 'charlie');
await jsClick(page, '#c-hide-charlie');
await page.waitForFunction(() => !document.querySelector('[data-item="charlie"]'), null, { timeout: 6000 }).catch(()=>{});
await settle(page);
const whileHidden = await snap(page, 'charlie');
await jsClick(page, '#c-show-all');
await page.waitForFunction(() => !!document.querySelector('[data-item="charlie"]'), null, { timeout: 6000 }).catch(()=>{});
await settle(page); await settle(page);
const charlieAliveAfterReturn = await itemMarkAlive(page, 'charlie'); // false => new node
const after = await snap(page, 'charlie');
return { intent: 'hide active then show all (same logical id returns on a new node)', before, whileHidden, after, charlieExpandoAfterReturn: charlieAliveAfterReturn };
})();
return R;
}
const engines = [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]];
const all = {};
for (const [name, launcher] of engines) {
console.log('=== ' + name + ' ===');
const browser = await launcher.launch();
all[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[name].runs.push(await runSpike(page)); 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 browser.close();
}
writeFileSync('results.json', JSON.stringify(all, null, 2));
console.log('written results.json');