lc-drive.ts
// Drives the sw-list gallery entry (the list region component composed into a
// megacolumn console; sw-live-list under it) with TRUSTED input via CDP.
//
// The demos churn on their own 4s timers; the scenario STOPS those timers up
// front and drives data churn via JS at chosen moments -- data churn is
// programmatic in the real app too, and the thing under test is exactly the
// race between that and trusted keyboard input. Covers: navigation into and
// through live-list rows, ghost navigability (Enter-on-ghost inspects; aux
// gone), aux removal, x/Delete (each flashing the x as acknowledgment), the
// key-activity pin (holds the FOCUSED ghost through a sub-200ms key burst;
// decays keyPinMs=200 after the last key; an UNFOCUSED ghost collapses on its
// own schedule despite the same typing), the pointerSafety split (click-guard,
// the default: hovering defers nothing and a trusted click on a row displaced
// by an in-flight collapse is swallowed while rows above the motion stay
// clickable; freeze, the opt-in: the global hover pin, pending insertions, and
// the pointer-leave release), the churn lockout (an Enter landing right after
// a fallback move is DROPPED; flows again after the window), instant prepend
// under key burst (p), focus memory across Tab-away, the drain-to-empty +
// revive story, and the LIST NORMS, sw-list's own behavior: the header row
// (a nav stop whose focus washes the whole list region) and the add-row
// (a dives straight into text entry -- the direct marking on a text row;
// Enter commits via the cancelable sw-list-add event; Escape abandons), and
// the LIST-SCOPED KEYS: t (back to the list's header) and n (past the list),
// claimed by the sw-list host element off the bubbling megacolumn-key dispatch
// and answered with megacolumn-focus-request; outside the list's rows the
// letters are nobody's. Structural row ids are name-prefixed by sw-list
// (name="queue": queue-header, queue-add).
// Ported from the Python original (demoscene.meta screenshots, stage-livecolumn,
// updated in place by stage-list-component).
import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
import { openGallery } from "yukey-testkit/gallery";
import { sleep } from "yukey-testkit/sleep";
// demo(i): the i-th swg-livecolumn-demo (0 = refill, 1 = drain).
const demo = (i: number) =>
`document.querySelector('sw-gallery').shadowRoot.querySelectorAll('swg-livecolumn-demo')[${i}]`;
// Full state of one demo column: every rendered rowslot with its phase-visible
// attributes, the machine-visible focus (ring classes + aria), and the log line.
const stateJs = (demoExpr: string) => `
(() => {
const demo = ${demoExpr};
const root = demo.shadowRoot;
const container = root.querySelector('[data-mc-container]');
const rows = [...root.querySelectorAll('[data-key]')].map(el => ({
key: el.dataset.key,
mcRow: el.dataset.mcRow ?? null,
disabled: el.getAttribute('aria-hidden') === 'true', // "closed": aria-hidden is the one signal now
hidden: el.getAttribute('aria-hidden'),
committed: el.querySelector('.task')?.classList.contains('committed') ?? false,
hasAux: el.querySelector('[data-mc-aux]') !== null,
}));
return JSON.stringify({
rows,
aria: container.getAttribute('aria-activedescendant'),
focusWithin: container.classList.contains('mc-focus-within'),
emptyFocus: container.classList.contains('mc-empty-focus'),
textMode: container.classList.contains('mc-text-mode'),
inputValue: root.querySelector('.sw-list-add [data-mc-input]')?.value ?? null,
inputFocused: root.activeElement === root.querySelector('.sw-list-add [data-mc-input]'),
// The header-focus region wash: a computed background on the group wrapper
// (driven by the :has() rule in the demo's stylesheet, nothing script-side).
groupWashed: (() => {
const g = root.querySelector('sw-list');
if (g === null) return null;
const bg = getComputedStyle(g).backgroundColor;
return bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
})(),
groupOutline: root.querySelector('sw-list') === null ? null
: getComputedStyle(root.querySelector('sw-list')).outlineStyle,
rowFocused: root.querySelector('.mc-row-focused')?.dataset?.mcRow
?? root.querySelector('.mc-row-focused')?.closest('[data-mc-row]')?.dataset?.mcRow ?? null,
auxFocused: root.querySelector('[data-mc-aux].mc-focused') !== null,
log: root.querySelector('.log')?.textContent ?? '',
idle: root.querySelector('.idle')?.textContent ?? null,
dataIds: demo.tasks.map(t => t.id),
});
})()
`;
interface LcRow {
key: string;
mcRow: string | null;
disabled: boolean;
hidden: string | null;
committed: boolean;
hasAux: boolean;
}
interface LcState {
rows: LcRow[];
aria: string | null;
focusWithin: boolean;
emptyFocus: boolean;
textMode: boolean;
inputValue: string | null;
inputFocused: boolean;
groupWashed: boolean | null;
groupOutline: string | null;
rowFocused: string | null;
auxFocused: boolean;
log: string;
idle: string | null;
dataIds: string[];
}
async function run(ctx: ScenarioContext): Promise<void> {
const { cdp, checks } = ctx;
const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
await openGallery(
ctx,
"#sw-list",
"document.readyState === 'complete' && document.querySelector('sw-gallery')?.shadowRoot?.querySelectorAll('swg-livecolumn-demo')?.length === 2",
);
// Determinism: stop both demos' own churn timers; the scenario churns by hand.
await cdp.js(`clearInterval(${demo(0)}.timer); clearInterval(${demo(1)}.timer)`);
const st = async (i = 0): Promise<LcState> =>
JSON.parse((await cdp.js(stateJs(demo(i)))) as string);
const row = (s: LcState, key: string): LcRow | null => s.rows.find((r) => r.key === key) ?? null;
// Data-side churn, like the real queue does between renders.
const dropHead = async (i = 0): Promise<void> => {
await cdp.js(`const d = ${demo(i)}; d.tasks = d.tasks.slice(1)`);
};
await cdp.mouseMove(5, 5); // park the mouse: no hover pin in play unless asked
// --- phase 1: composition basics -------------------------------------
let landed = false;
for (let i = 0; i < 12; i++) {
await cdp.key("Tab");
if ((await st()).focusWithin) {
landed = true;
break;
}
}
let s = await st();
check("tab-into-column", landed && s.rowFocused === "prepend", s.rowFocused ?? "");
const first = s.dataIds[0] as string;
// One down from the prepend row is the LIST HEADER ROW: a normal nav stop
// that speaks for the region. While it holds focus the whole list washes
// (sw-list's own :has() rule); the focus treatment stays on the header
// itself -- the sw-list host draws no outline of its own.
await cdp.key("ArrowDown");
s = await st();
check(
"header-row-is-a-nav-stop",
s.rowFocused === "queue-header" && s.aria === "mc-queue-header",
JSON.stringify([s.rowFocused, s.aria]),
);
check("header-focus-washes-region", s.groupWashed === true, JSON.stringify(s.groupWashed));
check("no-perimeter-outline-on-group", s.groupOutline === "none", s.groupOutline ?? "");
const ring = await cdp.js(`
(() => { const h = ${demo(0)}.shadowRoot.querySelector('[data-mc-row="queue-header"]');
return getComputedStyle(h).outlineStyle; })()
`);
check("ring-stays-on-header-row", ring === "solid", String(ring));
await shot(cdp, "list-header-focused.png");
await cdp.key("ArrowDown");
s = await st();
check(
"arrow-into-live-row",
s.rowFocused === first && s.aria === `mc-${first}`,
JSON.stringify([s.rowFocused, s.aria]),
);
check("wash-follows-header-only", s.groupWashed === false, JSON.stringify(s.groupWashed));
await cdp.key("ArrowRight");
s = await st();
check("right-reaches-aux", s.auxFocused, JSON.stringify(s.rows));
// Enter on the aux removes the task; the row ghosts in place, sheds its aux,
// STAYS focused (identity survives), and position snaps back to main.
// The row also FLASHES (sw-pressed) as instant acknowledgment: the fade to
// ghost alone was too quiet to confirm the keystroke landed. The flash is
// 140ms, so a MutationObserver armed before the keystroke catches it.
await cdp.js(`
(() => {
const root = ${demo(0)}.shadowRoot;
window.__flashSeen = 0;
window.__flashMo = new MutationObserver(muts => {
for (const m of muts) if (m.target.classList.contains('sw-pressed')) window.__flashSeen += 1;
});
window.__flashMo.observe(root.querySelector('sw-live-list'),
{ attributes: true, attributeFilter: ['class'], subtree: true });
})()
`);
await cdp.key("Enter");
s = await st();
let g = row(s, first);
const flashes = await cdp.js("window.__flashMo.disconnect(), window.__flashSeen");
check("aux-enter-removes", s.log.includes("removed") && !s.dataIds.includes(first), s.log);
check("removal-flashes-the-row", typeof flashes === "number" && flashes >= 1, String(flashes));
check(
"ghost-in-place",
g !== null && g.mcRow === first && g.committed && !g.disabled,
JSON.stringify(g),
);
check("ghost-sheds-aux", g !== null && !g.hasAux, JSON.stringify(g));
check(
"ghost-keeps-focus",
s.rowFocused === first && !s.auxFocused,
JSON.stringify([s.rowFocused, s.auxFocused]),
);
await shot(cdp, "ghost-focused.png");
// Enter on the ghost still means something: inspect, not error, not removal.
await cdp.key("Enter");
s = await st();
check("enter-on-ghost-inspects", s.log.includes("inspected a completed task"), s.log);
// --- phase 2: the key-activity pin ------------------------------------
// The pin defers ONLY the focused row's collapse (the keyboard aims by
// identity; only the aimed-at row needs holding still), and it decays
// keyPinMs=200 after the last key -- so holding the ghost takes a burst
// at sub-200ms cadence, and the moment the fingers stop, the world moves.
// Focus is on the ghost now; work IT (Enter inspects, focus never leaves)
// well past holdMs=1200 from the removal above.
for (let i = 0; i < 16; i++) {
await cdp.key("Enter");
await sleep(100);
}
s = await st();
g = row(s, first);
check("key-pin-holds-focused-ghost", g !== null && !g.disabled, JSON.stringify(g));
await shot(cdp, "pin-held.png");
// Stop typing: the pin decays 200ms after the last key, the deferred
// collapse plays (300ms), and the fallback moves focus off the dying row.
await sleep(900);
s = await st();
check("pin-decay-collapses-ghost", row(s, first) === null, JSON.stringify(s.rows));
check(
"fallback-moved-focus",
s.rowFocused !== null && s.rowFocused !== first,
s.rowFocused ?? "",
);
await shot(cdp, "post-fallback.png");
// An UNFOCUSED ghost collapses on its natural schedule despite continuous
// typing (the semantics the focused-only pin buys): x the focused row (the
// ghost keeps focus), prove it is still a nav stop (arrow off and back on),
// then move OFF it and keep pressing keys at pin-holding cadence on a
// neighbor -- left/right cycle within a two-control row, so row focus
// never returns to the ghost. It must collapse ~holdMs after its removal
// even though the pin never once decayed.
s = await st();
const bkey = s.rowFocused as string;
await cdp.key("x");
s = await st();
check(
"x-ghost-keeps-focus",
!s.dataIds.includes(bkey) && s.rowFocused === bkey,
JSON.stringify([s.rowFocused, s.log]),
);
await cdp.key("ArrowDown");
await cdp.key("ArrowUp");
s = await st();
check("ghost-is-a-nav-stop", s.rowFocused === bkey, s.rowFocused ?? "");
await cdp.key("ArrowDown"); // leave the ghost; it is nobody's aim from here on
let aliveEarly: boolean | null = null;
for (let i = 0; i < 18; i++) {
// ~1.9s of sub-200ms keys, spanning the ghost's 1.2s schedule
await cdp.key(i % 2 === 0 ? "ArrowRight" : "ArrowLeft");
await sleep(100);
if (i === 6) {
// ~0.8s in: before the schedule lands, the ghost still holds
s = await st();
g = row(s, bkey);
aliveEarly = g !== null && !g.disabled;
}
}
s = await st();
g = row(s, bkey);
const goneLate = g === null || g.disabled;
check("unfocused-ghost-holds-until-due", aliveEarly === true, JSON.stringify(aliveEarly));
check("unfocused-ghost-collapses-mid-typing", goneLate, JSON.stringify(g));
await sleep(600); // let the collapse finish before the next phase
// --- phase 3: the churn lockout ---------------------------------------
// Arrange: focus a row, drop it from the data (ghost), then wait for the
// collapse to START (aria-hidden appears; no keys during the wait, so the
// pin decays and only holdMs gates it) and immediately fire a trusted
// Enter: the fallback just moved focus, so the decisive key must be DROPPED.
s = await st();
const victim = s.rowFocused as string;
const logBefore = s.log;
await dropHead(0); // victim is the head (fallback landed there after phase 2)
{
let began = false;
for (let i = 0; i < 60; i++) {
s = await st();
g = row(s, victim);
if (g?.disabled) {
began = true;
break;
}
await sleep(40);
}
if (!began) throw new Error("victim never began collapsing");
}
await cdp.key("Enter");
s = await st();
check("lockout-drops-decisive-key", s.log === logBefore, JSON.stringify([s.log, logBefore]));
check(
"lockout-fallback-landed",
s.rowFocused !== null && s.rowFocused !== victim,
s.rowFocused ?? "",
);
// ... and after the 350ms window, decisive keys flow again.
await sleep(500);
await cdp.key("Enter");
s = await st();
check("post-lockout-key-flows", s.log.includes("inspected") && s.log !== logBefore, s.log);
// --- phase 4: x / Delete / instant prepend under burst -----------------
s = await st();
const target = s.rowFocused as string;
await cdp.key("x");
s = await st();
check("x-removes-from-main", s.log.includes("removed") && !s.dataIds.includes(target), s.log);
// p is the prepend button's column-scoped hotkey, marked DIRECT (a prepend
// is cheaply undone, the see-before-commit exception): it fires from ANY
// row, focus follows to the orders row, and the new head appears instantly
// (visible, not pending) although the key burst above has the key pin
// armed right now.
await cdp.key("p");
s = await st();
const newest = s.dataIds[0] as string;
const fresh = row(s, newest);
check("p-prepends-from-anywhere", s.log.includes("prepended"), s.log);
check("p-direct-moves-focus-to-actor", s.rowFocused === "prepend", s.rowFocused ?? "");
check(
"prepend-visible-under-pin",
fresh !== null && fresh.hidden === null && !fresh.disabled,
JSON.stringify(fresh),
);
// Focus landed on the orders row with the direct p; move to the fresh live
// task for the Delete test (Home = prepend row, two down = past the header
// to the new data head).
await cdp.key("Home");
await cdp.key("ArrowDown");
await cdp.key("ArrowDown");
s = await st();
const deltarget = s.rowFocused as string;
await cdp.key("Delete");
s = await st();
check("delete-removes", s.log.includes("removed") && !s.dataIds.includes(deltarget), s.log);
// --- phase 5: click-guard, the default pointer story ---------------------
// Hovering defers NOTHING now: a ghost under the pointer collapses on its
// own schedule, and an insertion appears immediately with the pointer inside.
// The protection is target-scoped instead: a trusted click whose target row
// is displaced by an in-flight motion is swallowed; rows above the motion
// answer clicks throughout; the guard dies ~50ms after the motion ends.
s = await st();
const ghosts = s.rows.filter((r) => r.committed && !r.disabled).map((r) => r.key);
check("ghost-present-for-pointer-phase", ghosts.length > 0, JSON.stringify(s.rows));
const gkey = ghosts[0] as string;
const gbox = JSON.parse(
(await cdp.js(`
(() => { const r = ${demo(0)}.shadowRoot.querySelector('[data-key="${gkey}"]').getBoundingClientRect();
return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
`)) as string,
) as { x: number; y: number };
await cdp.mouseMove(gbox.x, gbox.y); // pointer parked ON the ghost, pre-expiry
await sleep(2200); // holdMs + collapse + slack, pointer inside throughout
s = await st();
check("hover-does-not-defer-collapse", row(s, gkey) === null, JSON.stringify(s.rows));
// An insertion under the hovering pointer: immediate, full-size, no pending
// phase. Park the pointer at the LIST's own center first (the collapse may
// have shrunk the list away from where the ghost was).
const lbox = JSON.parse(
(await cdp.js(`
(() => { const r = ${demo(0)}.shadowRoot.querySelector('sw-live-list').getBoundingClientRect();
return JSON.stringify({x: r.x + r.width/2, y: r.y + Math.max(r.height/2, 4)}); })()
`)) as string,
) as { x: number; y: number };
await cdp.mouseMove(lbox.x, lbox.y);
await cdp.js(`${demo(0)}.prependTask()`);
await sleep(500); // entrance animation (300ms) settles
s = await st();
let pend = s.dataIds[0] as string;
let prow = row(s, pend);
let pheight = await cdp.js(`
(() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]');
return el === null ? null : el.getBoundingClientRect().height; })()
`);
check(
"insertion-appears-under-pointer",
prow !== null && prow.hidden === null,
JSON.stringify(prow),
);
check(
"inserted-row-holds-full-space",
typeof pheight === "number" && pheight > 20,
JSON.stringify(pheight),
);
// The trusted-click guard. Stock the queue so a mid-list collapse has rows
// both above and below it, and settle all entrances first.
await cdp.js(`${demo(0)}.prependTask(); ${demo(0)}.prependTask()`);
await sleep(600);
await cdp.key("Home"); // focus the prepend row: a stable not-a-task vantage
s = await st();
const ids = s.dataIds;
const [above, cgVictim, below] = [ids[0] as string, ids[1] as string, ids[2] as string];
const rowCenter = async (k: string): Promise<{ x: number; y: number } | null> => {
const v = await cdp.js(`
(() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${k}"]');
if (el === null) return null;
const r = el.getBoundingClientRect();
return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
`);
return v === null ? null : JSON.parse(v as string);
};
await cdp.js(
`(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${cgVictim}') })()`,
);
{
let began = false;
for (let i = 0; i < 60; i++) {
// wait out the hold; catch the collapse the frame it starts
s = await st();
g = row(s, cgVictim);
if (g?.disabled) {
began = true;
break;
}
await sleep(40);
}
if (!began) throw new Error("click-guard victim never began collapsing");
}
// Mid-collapse (300ms window): the row below the collapse is being pulled
// upward -- a trusted click on it must be swallowed (not even a focus sync);
// the row above has not moved and its click lands.
let pb = (await rowCenter(below)) as { x: number; y: number };
await cdp.click(pb.x, pb.y);
s = await st();
check("mid-collapse-click-below-swallowed", s.rowFocused === "prepend", s.rowFocused ?? "");
const pa = (await rowCenter(above)) as { x: number; y: number };
await cdp.click(pa.x, pa.y);
s = await st();
check("mid-collapse-click-above-lands", s.rowFocused === above, s.rowFocused ?? "");
// After the motion (plus the 50ms guard tail), the same below-row click lands.
await sleep(600);
pb = (await rowCenter(below)) as { x: number; y: number };
await cdp.click(pb.x, pb.y);
s = await st();
check("post-motion-click-lands", s.rowFocused === below, s.rowFocused ?? "");
await cdp.mouseMove(5, 5);
// --- phase 5f: freeze, the opt-in quiescence mode -----------------------
// pointerSafety is runtime-togglable; raise freeze on the live list and the
// old global hover pin story must hold: a hovered ghost defers past every
// timer, an insertion waits as a zero-height pending row, and pointer-leave
// releases both.
const LIST = `${demo(0)}.shadowRoot.querySelector('sw-live-list')`;
await cdp.js(`${LIST}.pointerSafety = 'freeze'`);
s = await st();
const fkey = s.dataIds[0] as string;
const fbox = (await rowCenter(fkey)) as { x: number; y: number };
await cdp.mouseMove(fbox.x, fbox.y); // pointer onto the head task row
await cdp.js(
`(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${fkey}') })()`,
);
await sleep(2200); // far past holdMs + collapse; the hover pin holds
s = await st();
g = row(s, fkey);
check("freeze-hover-pin-holds-ghost", g !== null && !g.disabled, JSON.stringify(s.rows));
// While the pointer pins the list, an insertion defers as a PENDING row --
// and a pending row must hold its display position at ZERO height. This
// pins the phantom-space state (held-p prepends with the pointer inside
// painted a blank band at the list top: the demo's [data-mc-row]
// min-height was clamping closed rowslots open to 24px).
await cdp.js(`${demo(0)}.prependTask()`);
await sleep(300);
s = await st();
pend = s.dataIds[0] as string;
prow = row(s, pend);
pheight = await cdp.js(`
(() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]');
return el === null ? null : el.getBoundingClientRect().height; })()
`);
check(
"freeze-hover-defers-insertion",
prow !== null && prow.hidden === "true",
JSON.stringify(prow),
);
check("freeze-pending-row-holds-no-space", pheight === 0, JSON.stringify(pheight));
await cdp.mouseMove(5, 5); // pointer leaves: deferred motion plays
await sleep(800);
s = await st();
check("freeze-pointer-leave-releases", row(s, fkey) === null, JSON.stringify(s.rows));
prow = row(s, pend);
check(
"freeze-pointer-leave-shows-pending",
prow !== null && prow.hidden === null,
JSON.stringify(prow),
);
await cdp.js(`${LIST}.pointerSafety = 'click-guard'`); // back to the default
// Re-seat DOM focus on the column container before the Tab phases: a real
// mouse click natively focuses the list's tabindex=-1 item container (the
// nearest click-focusable ancestor of a row), and Tab order from there is
// not what phase 6 is testing. Arrangement, not assertion.
await cdp.js(`${demo(0)}.shadowRoot.querySelector('[data-mc-container]').focus()`);
// --- phase 6: focus memory across Tab-away ------------------------------
s = await st();
const resume = s.rowFocused;
await cdp.key("Tab"); // leaves the refill column (next stop: drain column)
s = await st();
check("tab-leaves-column", !s.focusWithin, JSON.stringify(s.focusWithin));
await cdp.key("Tab", { shift: true });
s = await st();
check(
"focus-memory-on-return",
s.focusWithin && s.rowFocused === resume,
JSON.stringify([s.rowFocused, resume]),
);
// --- phase 7: the drain column: empty state and revival -----------------
await cdp.key("Tab");
let s1 = await st(1);
check("tab-into-drain-column", s1.focusWithin, JSON.stringify(s1));
// The queue starts empty (renderEmpty shows), but the COLUMN is not: the
// prepend row is a focusable stop, so eager focus sits there.
check(
"drain-starts-empty",
s1.idle !== null && s1.rowFocused === "prepend" && s1.aria === "mc-prepend",
JSON.stringify([s1.idle, s1.rowFocused, s1.aria]),
);
await shot(cdp, "drain-empty.png");
// p prepends a task; focus STAYS on the prepend row (it survives churn;
// nothing involuntary happened), and the new row is immediately reachable
// (two downs now: the list header row sits between prepend and the list).
await cdp.key("p");
s1 = await st(1);
const drainNewest = s1.dataIds.length > 0 ? (s1.dataIds[0] as string) : null;
check(
"drain-revives",
drainNewest !== null && s1.rowFocused === "prepend",
JSON.stringify(s1.dataIds),
);
await cdp.key("ArrowDown");
await cdp.key("ArrowDown");
s1 = await st(1);
check(
"drain-new-row-reachable",
s1.rowFocused === drainNewest && s1.aria === `mc-${drainNewest}`,
JSON.stringify([s1.rowFocused, s1.aria]),
);
// Kill it with x and let it collapse: focus survives the LAST task dying,
// falling DOWN to the add-row (the fallback prefers downward, and the
// add-row is the list's permanent neighbor below); the empty state returns.
await cdp.key("x");
s1 = await st(1);
check(
"drain-x-removes",
s1.log.includes("removed") && s1.rowFocused === drainNewest,
JSON.stringify([s1.log, s1.rowFocused]),
);
await sleep(2200); // pin decays (200) + hold (1200) + collapse (300) + slack
s1 = await st(1);
check(
"drain-back-to-empty",
s1.idle !== null && s1.rowFocused === "queue-add",
JSON.stringify([s1.idle, s1.rowFocused]),
);
await cdp.key("p");
await cdp.key("ArrowDown");
await cdp.key("ArrowDown");
s1 = await st(1);
check(
"drain-revives-again",
s1.dataIds.length === 1 && s1.rowFocused === s1.dataIds[0],
JSON.stringify(s1.rowFocused),
);
await shot(cdp, "drain-revived.png");
// --- phase 8: the add-row (append as a nav stop; commit/abandon) --------
// Still in the drain column, one live task. The add-row is the append
// norm, and its marked a is DIRECT: one keystroke dives straight into
// text entry (direct on a text row means what Enter means there), earned
// under the easy-undo doctrine -- Escape abandons, nothing touches the
// queue until the Enter commit. Enter commits, Escape abandons.
s1 = await st(1);
const countBefore = s1.dataIds.length;
await cdp.key("a");
s1 = await st(1);
check(
"a-dives-into-add-row",
s1.rowFocused === "queue-add" && s1.aria === "mc-queue-add",
JSON.stringify([s1.rowFocused, s1.aria]),
);
check(
"a-dive-enters-text-mode",
s1.textMode && s1.inputFocused,
JSON.stringify([s1.textMode, s1.inputFocused]),
);
// The triggering a must not type itself into the input it focused
// (the dive preventDefaults the keydown, same as Enter's dive).
check("a-does-not-type-itself", s1.inputValue === "", JSON.stringify(s1.inputValue));
check("a-dive-appends-nothing", s1.dataIds.length === countBefore, JSON.stringify(s1.dataIds));
for (const ch of "calibrate the array") {
await cdp.key(ch);
}
s1 = await st(1);
check(
"typing-lands-in-add-input",
s1.inputValue === "calibrate the array",
JSON.stringify(s1.inputValue),
);
await shot(cdp, "add-row-typing.png");
// Escape ABANDONS: text mode exits, the draft clears, nothing appended.
// (The trap this pins: change fires on blur-with-changed-value, and the
// exit blurs the input -- a change-driven commit would append here.)
const logBefore8 = s1.log;
await cdp.key("Escape");
s1 = await st(1);
check(
"escape-abandons",
!s1.textMode && s1.inputValue === "" && s1.dataIds.length === countBefore,
JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds]),
);
check(
"escape-appends-nothing-to-log",
s1.log === logBefore8,
JSON.stringify([s1.log, logBefore8]),
);
// Row focus survived the exit on the add-row, so plain Enter re-dives
// (the non-marked path, unchanged by the direct marking).
await cdp.key("Enter");
s1 = await st(1);
check(
"enter-redives-from-row-focus",
s1.textMode && s1.inputFocused,
JSON.stringify([s1.textMode, s1.inputFocused]),
);
// Enter COMMITS: the task appends to the live data (animating in at the
// tail), the input clears, and row focus stayed on the add-row throughout.
for (const ch of "survey the relay") {
await cdp.key(ch);
}
await cdp.key("Enter");
s1 = await st(1);
check(
"enter-commits-once",
s1.dataIds.length === countBefore + 1 && s1.log.includes("survey the relay appended"),
JSON.stringify([s1.dataIds, s1.log]),
);
check(
"commit-clears-input",
s1.inputValue === "" && !s1.textMode,
JSON.stringify([s1.inputValue, s1.textMode]),
);
const newId = s1.dataIds[s1.dataIds.length - 1] as string;
const freshTail = row(s1, newId);
check(
"committed-row-visible-at-tail",
freshTail !== null && freshTail.hidden === null && !freshTail.disabled,
JSON.stringify(freshTail),
);
check("focus-stayed-on-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? "");
// ArrowUp from the add-row reaches the appended task: it landed at the tail.
await cdp.key("ArrowUp");
s1 = await st(1);
check("committed-row-reachable-above-add-row", s1.rowFocused === newId, s1.rowFocused ?? "");
// --- phase 9: the list-scoped keys (t and n) -----------------------------
// The t/n norm: sw-list wires it itself (one megacolumn-key listener on
// the host element answering with megacolumn-focus-request), deliberately
// NOT gold markings. Scope is DOM containment: within the list's rows t
// returns to the header and n jumps past the list; outside it the letters
// are nobody's. Focus is on the tail task from phase 8 -- deep in the list.
await cdp.key("t");
s1 = await st(1);
check(
"t-from-task-returns-to-header",
s1.rowFocused === "queue-header" && s1.aria === "mc-queue-header",
JSON.stringify([s1.rowFocused, s1.aria]),
);
check("t-landing-washes-region", s1.groupWashed === true, JSON.stringify(s1.groupWashed));
// n from the header (the header counts as within): past the whole list to
// the telemetry row, no arrowing through the tasks.
await cdp.key("n");
s1 = await st(1);
check(
"n-jumps-past-the-list",
s1.rowFocused === "status" && s1.aria === "mc-status",
JSON.stringify([s1.rowFocused, s1.aria]),
);
// The status row sits OUTSIDE the group: t there is unclaimed -- it falls
// through to normal resolution (no marking answers it) and nothing moves.
await cdp.key("t");
s1 = await st(1);
check("t-outside-group-unclaimed", s1.rowFocused === "status", s1.rowFocused ?? "");
// The add-row counts as within: t from it returns to the header too.
await cdp.key("ArrowUp");
s1 = await st(1);
check("arrowup-from-status-is-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? "");
await cdp.key("t");
s1 = await st(1);
check("t-from-add-row-returns-to-header", s1.rowFocused === "queue-header", s1.rowFocused ?? "");
// ... and n from a mid-list task row (not just the header) jumps past too.
await cdp.key("ArrowDown");
s1 = await st(1);
check("arrowdown-into-list", s1.dataIds.includes(s1.rowFocused ?? ""), s1.rowFocused ?? "");
await cdp.key("n");
s1 = await st(1);
check("n-from-task-jumps-past", s1.rowFocused === "status", s1.rowFocused ?? "");
// The prepend row is outside the group on the other side: t stays put.
await cdp.key("Home");
await cdp.key("t");
s1 = await st(1);
check("t-on-prepend-unclaimed", s1.rowFocused === "prepend", s1.rowFocused ?? "");
// Text mode dominates: while typing in the add-row, t and n are just letters.
await cdp.key("a"); // the direct marking dives straight into text entry
await cdp.key("t");
await cdp.key("n");
s1 = await st(1);
check(
"text-mode-t-n-type",
s1.textMode && s1.inputValue === "tn" && s1.rowFocused === "queue-add",
JSON.stringify([s1.textMode, s1.inputValue, s1.rowFocused]),
);
await cdp.key("Escape"); // abandon the probe draft
s1 = await st(1);
check(
"text-mode-probe-abandoned",
!s1.textMode && s1.inputValue === "",
JSON.stringify([s1.textMode, s1.inputValue]),
);
// --- phase 10: the sw-list component surface -----------------------------
// Newly reachable since the list norms became a component (the
// list-component stage): the group identity for AT, and the cancelable
// commit (an owner that preventDefaults sw-list-add refuses the commit;
// the draft and text mode must survive so the user can fix the text).
// The demo's own handler never refuses, and a listener on the same element
// would run after it -- so the refusal is arranged upstream: a one-shot
// capture listener on the container cancels AND stops propagation (from
// sw-list's side that IS a refusing owner: sw-list-add came back
// defaultPrevented and nobody appended).
await cdp.js(`
${demo(1)}.shadowRoot.querySelector('[data-mc-container]')
.addEventListener('sw-list-add', e => { e.preventDefault(); e.stopPropagation(); },
{ once: true, capture: true })
`);
const role = await cdp.js(`
(() => { const l = ${demo(1)}.shadowRoot.querySelector('sw-list');
return JSON.stringify([l.getAttribute('role'), l.getAttribute('aria-label')]); })()
`);
check(
"sw-list-group-identity",
JSON.stringify(JSON.parse(role as string)) === JSON.stringify(["group", "task queue (live)"]),
String(role),
);
const countBefore10 = (await st(1)).dataIds.length;
// Focus sits ON the add-row after the phase-9 probe, where its own marking
// is deliberately a no-op (double-tap must not out-power single-tap) --
// so step off it first, then dive by marking. Once in text mode every
// letter (t and n included) types.
await cdp.key("Home");
await cdp.key("a");
for (const ch of "rejected draft") {
await cdp.key(ch);
}
await cdp.key("Enter");
s1 = await st(1);
check(
"refused-commit-keeps-draft-and-text-mode",
s1.textMode && s1.inputValue === "rejected draft" && s1.dataIds.length === countBefore10,
JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds.length]),
);
// The once-listener is spent: the same Enter now commits.
await cdp.key("Enter");
await sleep(100);
s1 = await st(1);
check(
"commit-flows-after-refusal",
!s1.textMode &&
s1.inputValue === "" &&
s1.dataIds.length === countBefore10 + 1 &&
s1.log.includes("rejected draft appended"),
JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds, s1.log]),
);
}
export const lcDrive: Scenario = { name: "lc", run };
packages/e2e/scenarios/lc-drive.ts · 32.7 KiB · 5c002c2529