yk-button-drive.ts
// Drives the yk-button gallery entry with TRUSTED key events via CDP,
// reading state through the shadow roots between steps. Covers: standalone
// tab traversal + native activation + focused hotkey/i, the in-column claims
// (Enter/Space, column-scoped hotkeys, x/Delete on aux, i, disabled
// claim-and-decline), the two-column scoping proof, and activation feedback
// (the yk-held / yk-pressed classes; the 120ms flash is caught with a
// MutationObserver installed before the keystroke, not with a screenshot race).
import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks";
import { sleep } from "yukey-testkit/sleep";
import { openGallery } from "../gallery.ts";
// State through the shadow roots: which element holds real focus (host chain),
// each demo's log line, and the columns' focused rows.
const STATE_JS = `
(() => {
const shell = document.querySelector("yk-gallery").shadowRoot;
const out = { realFocus: null, standalone: null, columns: [] };
const active = shell.activeElement;
const sa = shell.querySelector("ykg-button-standalone");
if (sa) {
const sr = sa.shadowRoot;
out.standalone = {
log: sr.querySelector(".log")?.textContent ?? "",
logIsInfo: sr.querySelector(".log")?.classList.contains("info") ?? false,
focusedLabel: sr.activeElement?.label ?? null,
};
if (sa === active) out.realFocus = "standalone/" + (sr.activeElement?.label ?? "?");
}
for (const col of shell.querySelectorAll("ykg-button-column")) {
const sr = col.shadowRoot;
const container = sr.querySelector("[data-mc-container]");
const label = col.getAttribute("label");
if (col === active) out.realFocus = label + "/container";
out.columns.push({
label,
rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
focusWithin: container.classList.contains("mc-focus-within"),
log: sr.querySelector(".log")?.textContent ?? "",
logIsInfo: sr.querySelector(".log")?.classList.contains("info") ?? false,
cargoRows: sr.querySelectorAll("[data-mc-aux]").length,
});
}
return JSON.stringify(out);
})()
`;
interface YkColumn {
label: string;
rowFocused: string | null;
focusWithin: boolean;
log: string;
logIsInfo: boolean;
cargoRows: number;
}
interface YkState {
realFocus: string | null;
standalone: { log: string; logIsInfo: boolean; focusedLabel: string | null };
columns: YkColumn[];
}
const READY_JS =
"document.readyState === 'complete' && !!document.querySelector('yk-gallery')?.shadowRoot?.querySelector('ykg-button-standalone')";
async function run(ctx: ScenarioContext): Promise<void> {
const { cdp, checks } = ctx;
const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail);
const st = async (): Promise<YkState> => JSON.parse((await cdp.js(STATE_JS)) as string);
const centerOf = async (jsElExpr: string): Promise<{ x: number; y: number }> => {
const box = await cdp.js(`
(() => {
const r = (${jsElExpr}).getBoundingClientRect();
return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2});
})()
`);
return JSON.parse(box as string);
};
await openGallery(ctx, "#yk-button", READY_JS);
// --- standalone variant ---
// The gallery seats initial focus in the entry's first tab stop on open:
// for #yk-button that is the first standalone button. (Seating happens a
// frame after render; poll briefly.)
let landed = false;
let s = await st();
for (let i = 0; i < 20; i++) {
s = await st();
if (s.realFocus === "standalone/Dock at station") {
landed = true;
break;
}
await sleep(50);
}
check("initial-focus-seats-standalone-button", landed, JSON.stringify(s.realFocus));
// Native Enter activation.
await cdp.key("Enter");
s = await st();
check("standalone-enter-activates", s.standalone.log === "docked", s.standalone.log);
// The focused button's own hotkey. (Tab to units first: its hotkey is 7,
// distinct from Enter's effect, so the log proves the hotkey path.)
await cdp.key("Tab");
s = await st();
check("tab-to-units", s.realFocus === "standalone/units", JSON.stringify(s.realFocus));
await cdp.key("7");
s = await st();
check("standalone-hotkey-activates", s.standalone.log === "units opened", s.standalone.log);
// i for info on the focused button.
await cdp.key("i");
s = await st();
check(
"standalone-i-informs",
s.standalone.log === "info: opens the units registry" && s.standalone.logIsInfo,
s.standalone.log,
);
// The hotkey answers only while ITS button is focused: units' 7 from dock does nothing.
await cdp.key("Tab", { shift: true }); // back to dock
await cdp.key("7");
s = await st();
check(
"standalone-hotkey-needs-focus",
s.standalone.log === "info: opens the units registry",
s.standalone.log,
);
// Disabled: still a tab stop, Enter dead, i answers. (Four tabs: the
// standalone row gained the active 'cruise' example at stage-migrate-infocard:
// dock -> units -> transfer -> cruise -> self destruct.)
for (let i = 0; i < 4; i++) {
await cdp.key("Tab");
}
s = await st();
check(
"disabled-is-a-tab-stop",
s.realFocus === "standalone/self destruct",
JSON.stringify(s.realFocus),
);
await cdp.key("Enter");
s = await st();
check(
"disabled-enter-dead",
s.standalone.log === "info: opens the units registry",
s.standalone.log,
);
await cdp.key("i");
s = await st();
check(
"disabled-i-answers",
s.standalone.log === "info: offline: requires command authorization",
s.standalone.log,
);
// Mouse click on the dock button (trusted click via CDP).
const SA_ROOT = "document.querySelector(\"yk-gallery\").shadowRoot.querySelector(\"ykg-button-standalone\")";
let pt = await centerOf(
`[...${SA_ROOT}.shadowRoot.querySelectorAll("yk-button")].find(x => x.label === "Dock at station")`,
);
await cdp.click(pt.x, pt.y);
s = await st();
check("standalone-mouse-click-activates", s.standalone.log === "docked", s.standalone.log);
// --- ops console (single column) variant ---
// The click above moved real focus onto the dock button, so Tab from there
// walks the remaining standalone buttons before reaching the container.
landed = false;
for (let i = 0; i < 8; i++) {
await cdp.key("Tab");
s = await st();
if (s.realFocus === "ops console/container") {
landed = true;
break;
}
}
check("tab-into-ops-console", landed, JSON.stringify(s.realFocus));
const ops = (v: YkState): YkColumn => v.columns[0] as YkColumn;
check("ops-first-row-dock", ops(s).rowFocused === "dock", JSON.stringify(ops(s)));
// Enter on the focused button row.
await cdp.key("Enter");
s = await st();
check("row-enter-activates", ops(s).log === "docked", ops(s).log);
// Column-scoped hotkey: b (beacon) is a JUMP -- focus moves to the beacon
// row, nothing activates (see-before-commit: Enter is the commit).
await cdp.key("b");
s = await st();
check("column-hotkey-moves-focus", ops(s).rowFocused === "beacon", JSON.stringify(ops(s)));
check("column-hotkey-does-not-activate", ops(s).log === "docked", ops(s).log);
await cdp.key("Enter");
s = await st();
check("enter-after-jump-activates", ops(s).log === "beacon cycled", ops(s).log);
// ... and d jumps back; Space mirrors Enter as the commit.
await cdp.key("d");
s = await st();
check(
"jump-back-to-dock",
ops(s).rowFocused === "dock" && ops(s).log === "beacon cycled",
JSON.stringify(ops(s)),
);
await cdp.key(" ");
s = await st();
check("row-space-activates", ops(s).log === "docked", ops(s).log);
// i on the focused row's button.
await cdp.key("i");
s = await st();
check(
"row-i-informs",
ops(s).log === "info: requests docking clearance" && ops(s).logIsInfo,
ops(s).log,
);
// The disabled purge row: focusable, Space claims but declines.
await cdp.key("ArrowDown");
await cdp.key("ArrowDown");
s = await st();
check("disabled-row-focusable", ops(s).rowFocused === "purge", JSON.stringify(ops(s)));
const MAIN_SCROLL = "document.querySelector('yk-gallery').shadowRoot.querySelector('main').scrollTop";
const before = await cdp.js(MAIN_SCROLL);
await cdp.key(" ");
s = await st();
const after = await cdp.js(MAIN_SCROLL);
check(
"disabled-space-claimed-not-acted",
ops(s).log === "info: requests docking clearance",
ops(s).log,
);
check("disabled-space-no-scroll", before === after, `${before} -> ${after}`);
await cdp.key("i");
s = await st();
check(
"disabled-row-i-answers",
ops(s).log === "info: offline: filters already clean",
ops(s).log,
);
// Cargo rows: x from main jettisons via the aux button.
await cdp.key("ArrowDown");
s = await st();
check("into-cargo", ops(s).rowFocused === "ore crate", JSON.stringify(ops(s)));
await cdp.key("Enter");
s = await st();
check("cargo-enter-inspects", ops(s).log === "ore crate inspected", ops(s).log);
await cdp.key("x");
s = await st();
check(
"x-jettisons-via-aux-button",
ops(s).log === "ore crate jettisoned" && ops(s).cargoRows === 1,
JSON.stringify(ops(s)),
);
// Delete works too; first arrow onto the surviving crate row's aux and use Enter instead.
s = await st();
check("focus-fell-to-next-crate", ops(s).rowFocused === "strut bundle", JSON.stringify(ops(s)));
await cdp.key("ArrowRight");
await cdp.key("i");
s = await st();
check("aux-i-informs", ops(s).log.includes("jettisons this crate"), ops(s).log);
await cdp.key("Enter");
s = await st();
check(
"enter-on-aux-jettisons",
ops(s).log === "strut bundle jettisoned" && ops(s).cargoRows === 0,
JSON.stringify(ops(s)),
);
// A trusted mouse click on a row-mode yk-button: exactly ONE activation.
// The controller synthesizes Enter from unclaimed row clicks (pointer
// activation for plain rows); yk-button claims its own clicks so the
// synthesis skips it -- a broken dedup would log two activations here.
const OPS_BEACON = `document.querySelector("yk-gallery").shadowRoot
.querySelector("ykg-button-column").shadowRoot
.querySelector('[data-mc-row="beacon"] yk-button')`;
await cdp.js(
`window.__acts = 0; (${OPS_BEACON}).addEventListener('click', () => { window.__acts += 1 })`,
);
pt = await centerOf(OPS_BEACON);
await cdp.click(pt.x, pt.y);
s = await st();
check("row-button-click-activates", ops(s).log === "beacon cycled", ops(s).log);
const acts = await cdp.js("window.__acts");
check("row-button-click-activates-exactly-once", acts === 1, JSON.stringify(acts));
// --- scoping variant: two columns, both mark d ---
await cdp.key("Tab");
s = await st();
check("tab-into-port", s.realFocus === "port console/container", JSON.stringify(s.realFocus));
const port = (v: YkState): YkColumn => v.columns[1] as YkColumn;
const starboard = (v: YkState): YkColumn => v.columns[2] as YkColumn;
// The focused row's OWN hotkey does nothing: not a jump (already here),
// not an activation (double-tap must not out-power single-tap).
await cdp.key("d"); // focus sits on port's dock row
s = await st();
check(
"own-hotkey-does-nothing",
port(s).rowFocused === "dock" && port(s).log === "",
JSON.stringify(port(s)),
);
// From another row, d jumps only in the focused column.
await cdp.key("ArrowDown"); // -> beacon row
await cdp.key("d");
s = await st();
check(
"d-jumps-in-port",
port(s).rowFocused === "dock" && port(s).log === "",
JSON.stringify(port(s)),
);
check(
"starboard-never-hears-d",
starboard(s).rowFocused === "dock" && starboard(s).log === "",
JSON.stringify(starboard(s)),
);
await cdp.key("Enter");
s = await st();
check("port-enter-commits", port(s).log === "docked", port(s).log);
await cdp.key("Tab");
s = await st();
check(
"tab-into-starboard",
s.realFocus === "starboard console/container",
JSON.stringify(s.realFocus),
);
await cdp.key("c"); // starboard's beacon letter: a jump
s = await st();
check(
"starboard-own-letter-jumps",
starboard(s).rowFocused === "beacon",
JSON.stringify(starboard(s)),
);
await cdp.key("Enter");
s = await st();
check("starboard-enter-commits", starboard(s).log === "beacon cycled", starboard(s).log);
await cdp.key("d"); // jump back to starboard's dock
await cdp.key("Enter");
s = await st();
check(
"d-jumps-in-starboard",
starboard(s).rowFocused === "dock" && starboard(s).log === "docked",
JSON.stringify(starboard(s)),
);
check(
"port-log-unchanged",
port(s).log === "docked" && !port(s).focusWithin,
JSON.stringify(port(s)),
);
// --- scroll safety: a hotkey jump to an off-screen row scrolls it into view ---
// Browsers do not scroll aria-activedescendant targets on their own; the
// controller does, on every focus identity change. Shrink the viewport so
// the page overflows its scroller, park the scroller at the bottom (the ops
// console's dock row far off-screen above), and jump to it with d.
await cdp.cmd("Emulation.setDeviceMetricsOverride", {
width: 1440,
height: 400,
deviceScaleFactor: 1,
mobile: false,
});
landed = false;
for (let i = 0; i < 16; i++) {
await cdp.key("Tab");
s = await st();
if (s.realFocus === "ops console/container") {
landed = true;
break;
}
}
check("scroll-tab-into-ops", landed, JSON.stringify(s.realFocus));
check("scroll-memory-not-on-dock", ops(s).rowFocused !== "dock", JSON.stringify(ops(s)));
const DOCK_VIS = `
(() => {
const m = document.querySelector('yk-gallery').shadowRoot.querySelector('main');
const row = document.querySelector('yk-gallery').shadowRoot
.querySelector('ykg-button-column').shadowRoot
.querySelector('[data-mc-row="dock"]');
const mr = m.getBoundingClientRect(), rr = row.getBoundingClientRect();
return JSON.stringify({scrollTop: m.scrollTop,
visible: rr.bottom > mr.top && rr.top < mr.bottom});
})()`;
await cdp.js(
"const m = document.querySelector('yk-gallery').shadowRoot.querySelector('main'); m.scrollTop = m.scrollHeight",
);
const visBefore = JSON.parse((await cdp.js(DOCK_VIS)) as string) as {
scrollTop: number;
visible: boolean;
};
check("scroll-setup-dock-off-screen", !visBefore.visible, JSON.stringify(visBefore));
await cdp.key("d"); // the jump: focus lands on dock, and the ring must be visible
s = await st();
const visAfter = JSON.parse((await cdp.js(DOCK_VIS)) as string) as {
scrollTop: number;
visible: boolean;
};
check("scroll-jump-landed-on-dock", ops(s).rowFocused === "dock", JSON.stringify(ops(s)));
check(
"scroll-jump-scrolled-into-view",
visAfter.visible && visAfter.scrollTop !== visBefore.scrollTop,
JSON.stringify([visBefore, visAfter]),
);
await shot(cdp, "hotkey-jump.png");
await cdp.cmd("Emulation.clearDeviceMetricsOverride");
// --- activation feedback: the yk-held / yk-pressed classes ---
// Reload to reset the demos; initial focus re-seats on the first standalone button.
await cdp.cmd("Page.reload", { ignoreCache: true });
for (let i = 0; i < 50; i++) {
if ((await cdp.js(READY_JS)) === true) break;
await sleep(100);
}
for (let i = 0; i < 20; i++) {
s = await st();
if (s.realFocus === "standalone/Dock at station") break;
await sleep(50);
}
// A recorder beats a screenshot race: the observer is installed before the
// keystroke, so a 120ms flash cannot slip between CDP roundtrips.
// Each arm disconnects the previous observer: stale observers otherwise
// increment whatever __flash is current when their callbacks run.
// find: a JS expression yielding the yk-button host to watch.
const armFlash = async (find: string): Promise<void> => {
await cdp.js(`
(() => {
window.__flash?.obs.disconnect();
const b = ${find};
const obs = new MutationObserver(() => {
if (b.classList.contains("yk-pressed")) window.__flash.count += 1;
});
obs.observe(b, { attributes: true, attributeFilter: ["class"] });
window.__flash = { el: b, count: 0, obs };
})()
`);
};
const flashSeen = async (): Promise<boolean> => {
for (let i = 0; i < 30; i++) {
if ((await cdp.js("window.__flash.count > 0")) === true) return true;
await sleep(20);
}
return false;
};
const flashCleared = async (): Promise<boolean> => {
for (let i = 0; i < 30; i++) {
if ((await cdp.js("!window.__flash.el.classList.contains('yk-pressed')")) === true) {
return true;
}
await sleep(20);
}
return false;
};
const SA_BUTTON = (label: string) =>
`[...document.querySelector("yk-gallery").shadowRoot
.querySelector("ykg-button-standalone").shadowRoot
.querySelectorAll("yk-button")].find(x => x.label === "${label}")`;
// Keyboard activation flashes: trusted Enter on the focused standalone button.
await armFlash(SA_BUTTON("Dock at station"));
await cdp.key("Enter");
check("flash-on-trusted-enter", await flashSeen());
check("flash-clears-after-press-ms", await flashCleared());
// The focused button's hotkey flashes too (tab to units, hotkey 7).
await cdp.key("Tab");
await armFlash(SA_BUTTON("units"));
await cdp.key("7");
check("flash-on-trusted-hotkey", await flashSeen());
check("hotkey-flash-clears", await flashCleared());
// Held under a trusted pointer: press without release, look, release.
pt = await centerOf(SA_BUTTON("Dock at station"));
await cdp.mouseDown(pt.x, pt.y);
const held = await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('yk-held')`);
check("held-while-pointer-down", held === true);
// The held look IS the flash look, and it sits still: the one screenshot
// a 120ms transient can't reliably give us.
await shot(cdp, "press-flash.png");
await cdp.mouseUp(pt.x, pt.y);
const released = await cdp.js(`!(${SA_BUTTON("Dock at station")}).classList.contains('yk-held')`);
check("held-releases-on-pointer-up", released === true);
// The release click flashes Dock; let that settle before the disabled check
// so no mutation from it is in flight when the observer moves.
for (let i = 0; i < 30; i++) {
if (
(await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('yk-pressed')`)) !== true
) {
break;
}
await sleep(20);
}
// Disabled: neither held nor flashed (nothing happened; nothing may claim otherwise).
pt = await centerOf(SA_BUTTON("self destruct"));
await armFlash(SA_BUTTON("self destruct"));
await cdp.mouseDown(pt.x, pt.y);
const heldDisabled = await cdp.js(
`(${SA_BUTTON("self destruct")}).classList.contains('yk-held')`,
);
await cdp.mouseUp(pt.x, pt.y);
check("disabled-never-held", heldDisabled !== true);
await sleep(300); // give a wrong flash time to have happened
check("disabled-never-flashed", (await cdp.js("window.__flash.count > 0")) !== true);
// Row mode: Enter on the focused row flashes its button; a column-scoped
// hotkey JUMP flashes nothing (a focus move is navigation -- ring movement
// and flash must stay distinct vocabularies), and the Enter that follows
// flashes the landed row's button.
landed = false;
for (let i = 0; i < 8; i++) {
await cdp.key("Tab");
s = await st();
if (s.realFocus === "ops console/container") {
landed = true;
break;
}
}
check("feedback-tab-into-ops", landed, JSON.stringify(s.realFocus));
const COL_BUTTON = (row: string) =>
`document.querySelector("yk-gallery").shadowRoot
.querySelector("ykg-button-column").shadowRoot
.querySelector('[data-mc-row="${row}"] yk-button')`;
await armFlash(COL_BUTTON("dock"));
await cdp.key("Enter");
check("row-flash-on-trusted-enter", await flashSeen());
check("row-flash-clears", await flashCleared());
await armFlash(COL_BUTTON("beacon"));
await cdp.key("b"); // a jump: focus moves to beacon, nothing activates
await sleep(300); // give a wrong flash time to have happened
s = await st();
check("no-flash-on-hotkey-jump", (await cdp.js("window.__flash.count > 0")) !== true);
check("jump-landed-on-beacon", ops(s).rowFocused === "beacon", JSON.stringify(ops(s)));
await cdp.key("Enter"); // the commit: now the flash names the actor
check("flash-on-enter-after-jump", await flashSeen());
// Plain megacolumn rows (no widget): the demo host flashes the row itself.
await cdp.js("location.hash = '#megacolumn'");
await cdp.cmd("Page.reload", { ignoreCache: true });
for (let i = 0; i < 50; i++) {
if (
(await cdp.js(
"document.readyState === 'complete' && !!document.querySelector('yk-gallery')?.shadowRoot?.querySelector('ykg-megacolumn-demo')",
)) === true
) {
break;
}
await sleep(100);
}
landed = false;
for (let i = 0; i < 20; i++) {
if (
(await cdp.js(`document.querySelector("yk-gallery").shadowRoot
.querySelector("ykg-megacolumn-demo").shadowRoot
.querySelector("[data-mc-container]").classList.contains("mc-focus-within")`)) === true
) {
landed = true;
break;
}
await sleep(50);
}
check("plain-rows-focus-seated", landed);
await armFlash(`document.querySelector("yk-gallery").shadowRoot
.querySelector("ykg-megacolumn-demo").shadowRoot
.querySelector('[data-mc-row="beacon"]')`);
await cdp.key("Enter");
check("plain-row-flash-on-trusted-enter", await flashSeen());
check("plain-row-flash-clears", await flashCleared());
}
export const ykDrive: Scenario = { name: "yk", run };
packages/e2e/scenarios/yk-button-drive.ts · 20.6 KiB · f2c451cb3e