mc-drive.ts
// Drives the megacolumn gallery demo with TRUSTED key events via CDP,
// reading focus/aria state between steps. Synthetic JS KeyboardEvents can't
// exercise real Tab traversal or real typing; these can.
import type { Scenario, ScenarioContext } from "yukey-testkit/checks";
import { sleep } from "yukey-testkit/sleep";
import { openGallery } from "../gallery.ts";
// Reads the interesting state through the shadow roots: which demo element holds
// real focus, its aria-activedescendant, ring positions, text mode, and the log line.
const STATE_JS = `
(() => {
const shell = document.querySelector("sw-gallery").shadowRoot;
const demos = [...shell.querySelectorAll("swg-megacolumn-demo")];
const active = shell.activeElement;
const out = { realFocus: null, columns: [] };
for (const demo of demos) {
const sr = demo.shadowRoot;
const container = sr.querySelector("[data-mc-container]");
const label = demo.getAttribute("label");
if (demo === active) {
const inner = sr.activeElement;
out.realFocus = label + "/" + (inner === container ? "container" : (inner?.tagName ?? "host").toLowerCase());
}
out.columns.push({
label,
aria: container.getAttribute("aria-activedescendant"),
ringOn: sr.querySelector(".mc-focused")?.textContent?.trim()?.slice(0, 24) ?? null,
rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
textMode: container.classList.contains("mc-text-mode"),
focusWithin: container.classList.contains("mc-focus-within"),
log: sr.querySelector(".log")?.textContent ?? "",
inputValue: sr.querySelector("[data-mc-input]")?.value ?? null,
cargoCount: sr.querySelectorAll("[data-mc-aux]").length,
});
}
return JSON.stringify(out);
})()
`;
interface McColumn {
label: string;
aria: string | null;
ringOn: string | null;
rowFocused: string | null;
textMode: boolean;
focusWithin: boolean;
log: string;
inputValue: string | null;
cargoCount: number;
}
interface McState {
realFocus: string | null;
columns: McColumn[];
}
// State of the nested-scroller demo: the focused row, the box's own scroll
// position, and the page scrollers that must NOT move.
const SCROLL_JS = `
(() => {
const shell = document.querySelector("sw-gallery").shadowRoot;
const demo = shell.querySelector("swg-megacolumn-scroll-demo");
const sr = demo.shadowRoot;
const box = sr.querySelector("[data-mc-container]");
const focused = sr.querySelector(".mc-row-focused");
const b = box.getBoundingClientRect();
const r = focused?.getBoundingClientRect();
return JSON.stringify({
hasFocus: shell.activeElement === demo,
rowFocused: focused?.dataset?.mcRow ?? null,
boxTop: box.scrollTop,
rowInBox: r ? r.top >= b.top - 1 && r.bottom <= b.bottom + 1 : null,
mainTop: shell.querySelector("main").scrollTop,
windowY: window.scrollY,
log: sr.querySelector(".log")?.textContent ?? "",
});
})()
`;
interface McScrollState {
hasFocus: boolean;
rowFocused: string | null;
boxTop: number;
rowInBox: boolean | null;
mainTop: number;
windowY: number;
log: string;
}
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<McState> => JSON.parse((await cdp.js(STATE_JS)) as string);
// Fresh demo state every run: navigate to the entry, wait for the demo element.
await openGallery(
ctx,
"#megacolumn",
"document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-megacolumn-demo')",
);
// The gallery seats initial focus in the entry's first tab stop on open:
// after a reload of #megacolumn that is the port container, no click, no Tab.
// (Seating happens a frame after render; poll briefly.)
let seated: string | null = null;
for (let i = 0; i < 20; i++) {
const s = await st();
seated = s.realFocus;
if (seated === "port console/container") break;
await sleep(50);
}
check(
"initial-focus-seated-on-open",
seated === "port console/container",
JSON.stringify(seated),
);
// Start clean: blur the DEEP active element (document.activeElement is just
// the shadow host; blurring the host does not drop inner focus), then Tab
// until real focus lands back in the first container.
await cdp.js(
"(() => { let a = document.activeElement; while (a && a.shadowRoot && a.shadowRoot.activeElement) a = a.shadowRoot.activeElement; if (a) a.blur(); window.scrollTo(0,0); })()",
);
let landed = false;
let s = await st();
// The cap must exceed the containment cycle's full length: every gallery
// nav link plus the demo's stops. Hand-counting rotted once already (a cap
// of 12 = "six nav links" broke when the gallery grew); count the nav links
// live and add the demo's own stops with margin.
const navLinks = (await cdp.js(
"document.querySelector('sw-gallery').shadowRoot.querySelectorAll('nav a').length",
)) as number;
for (let i = 0; i < navLinks + 12; i++) {
await cdp.key("Tab");
s = await st();
if (s.realFocus === "port console/container") {
landed = true;
break;
}
}
check("tab-reaches-port-container", landed, JSON.stringify(s.realFocus));
let port = s.columns[0] as McColumn;
check(
"initial-focus-first-row",
port.rowFocused === "beacon" && port.aria === "mc-beacon",
JSON.stringify(port),
);
// Tab again: ONE tab stop per column -> straight to starboard.
await cdp.key("Tab");
s = await st();
check(
"tab-skips-rows-to-starboard",
s.realFocus === "starboard console/container",
JSON.stringify(s.realFocus),
);
check(
"port-memory-visible-when-blurred",
s.columns[0]?.rowFocused === "beacon" && s.columns[0]?.focusWithin === false,
JSON.stringify(s.columns[0]),
);
// Back to port; navigate: down x2 -> reactor slider.
await cdp.key("Tab", { shift: true });
await cdp.key("ArrowDown");
await cdp.key("ArrowDown");
s = await st();
port = s.columns[0] as McColumn;
check("arrows-move-row-focus", port.rowFocused === "power", JSON.stringify(port));
// Slider consumes left/right.
await cdp.key("ArrowRight");
await cdp.key("ArrowRight");
await cdp.key("ArrowLeft");
s = await st();
port = s.columns[0] as McColumn;
check("slider-eats-arrows", port.log.includes("reactor power 45%"), port.log);
check("slider-row-unmoved", port.rowFocused === "power", JSON.stringify(port));
// Down into cargo: two-control rows. Right reaches the aux x; Enter jettisons.
await cdp.key("ArrowDown");
s = await st();
port = s.columns[0] as McColumn;
check("gap-jump-into-cargo", port.rowFocused === "crate-ore", JSON.stringify(port));
await cdp.key("ArrowRight");
s = await st();
port = s.columns[0] as McColumn;
check("right-reaches-aux", port.ringOn === "\u00d7", JSON.stringify(port));
await cdp.key("Enter");
s = await st();
port = s.columns[0] as McColumn;
check(
"enter-on-aux-removes",
port.log.includes("ore crate jettisoned") && port.cargoCount === 2,
JSON.stringify(port),
);
check("vanished-row-falls-down", port.rowFocused === "crate-struts", JSON.stringify(port));
// x hotkey removes from main position too.
await cdp.key("x");
s = await st();
port = s.columns[0] as McColumn;
check(
"x-hotkey-removes",
port.log.includes("strut bundle jettisoned") && port.cargoCount === 1,
JSON.stringify(port),
);
// Enter on main inspects, does NOT remove.
await cdp.key("Enter");
s = await st();
port = s.columns[0] as McColumn;
check(
"enter-on-main-inspects",
port.log.includes("fuel cells inspected") && port.cargoCount === 1,
JSON.stringify(port),
);
// Down to the hail row; Enter dives into the input; type; every key goes in.
await cdp.key("ArrowDown");
await cdp.key("Enter");
s = await st();
port = s.columns[0] as McColumn;
check(
"text-mode-entered",
port.textMode && s.realFocus === "port console/input",
JSON.stringify({ f: s.realFocus, tm: port.textMode }),
);
for (const ch of "sos x") {
await cdp.key(ch);
}
s = await st();
port = s.columns[0] as McColumn;
check("typing-goes-to-input", port.inputValue === "sos x", JSON.stringify(port.inputValue));
check("x-did-not-jettison-while-typing", port.cargoCount === 1, String(port.cargoCount));
// Escape exits back to the container; row focus stays on hail.
await cdp.key("Escape");
s = await st();
port = s.columns[0] as McColumn;
check(
"escape-exits-text",
!port.textMode && s.realFocus === "port console/container",
JSON.stringify({ f: s.realFocus, tm: port.textMode }),
);
check("row-focus-resumed-on-hail", port.rowFocused === "hail", JSON.stringify(port));
// Down reaches the offline encrypted row: visible-but-inactive rows are
// nav stops (i must be able to reach them; stride counts stay stable).
await cdp.key("ArrowDown");
s = await st();
port = s.columns[0] as McColumn;
check("offline-row-is-a-stop", port.rowFocused === "encrypted", JSON.stringify(port));
// ... but activation is dead there: Enter changes nothing.
const beforeLog = port.log;
await cdp.key("Enter");
s = await st();
port = s.columns[0] as McColumn;
check("offline-activation-dead", port.log === beforeLog, port.log);
// Home jumps to the first row.
await cdp.key("Home");
s = await st();
port = s.columns[0] as McColumn;
check("home-jumps-to-first", port.rowFocused === "beacon", JSON.stringify(port));
// Enter activates the beacon row (plain activatable).
await cdp.key("Enter");
s = await st();
port = s.columns[0] as McColumn;
check("enter-activates-row", port.log.includes("beacon cycled (1)"), port.log);
await cdp.key(" ");
s = await st();
port = s.columns[0] as McColumn;
check("space-mirrors-enter", port.log.includes("beacon cycled (2)"), port.log);
// Hotkey jumps: the gold letters move focus, Enter commits (see-before-commit).
// g leaps from the top section to the bottom one; the log must NOT change on
// the jump itself.
await cdp.key("g");
s = await st();
port = s.columns[0] as McColumn;
check("hotkey-jumps-to-gyros", port.rowFocused === "gyros", JSON.stringify(port));
check("jump-does-not-activate", port.log.includes("beacon cycled (2)"), port.log);
await cdp.key("Enter");
s = await st();
port = s.columns[0] as McColumn;
check("enter-commits-after-jump", port.log.includes("gyros recalibrated"), port.log);
// The offline row is a jump target too: e lands there (i can ask why it's off).
await cdp.key("e");
s = await st();
port = s.columns[0] as McColumn;
check("jump-reaches-offline-row", port.rowFocused === "encrypted", JSON.stringify(port));
// ... and b jumps back up to the beacon row for the phases below.
await cdp.key("b");
s = await st();
port = s.columns[0] as McColumn;
check("jump-back-to-beacon", port.rowFocused === "beacon", JSON.stringify(port));
// Tab away and back: focus memory.
await cdp.key("ArrowDown"); // -> dock, a memorable non-default row
await cdp.key("Tab");
await cdp.key("Tab", { shift: true });
s = await st();
port = s.columns[0] as McColumn;
check(
"focus-memory-across-tab",
port.rowFocused === "dock" && s.realFocus === "port console/container",
JSON.stringify({ f: s.realFocus, r: port.rowFocused }),
);
// Starboard is untouched by all of this.
const sb = s.columns[1] as McColumn;
check(
"starboard-independent",
sb.rowFocused === "beacon" && sb.cargoCount === 3 && sb.log === "",
JSON.stringify(sb),
);
// --- pointer activation: a trusted CLICK is the same event as Enter ---
// Plain rows have no widget: their activation is the controller's synthesized
// megacolumn-key. A tap produces a click, so this is also the touch story.
const portCenter = async (selector: string): Promise<{ x: number; y: number }> => {
const v = await cdp.js(`
(() => { const el = document.querySelector("sw-gallery").shadowRoot
.querySelector("swg-megacolumn-demo").shadowRoot.querySelector('${selector}');
el.scrollIntoView({block: "nearest", behavior: "instant"});
const r = el.getBoundingClientRect();
return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })()
`);
return JSON.parse(v as string);
};
// Click the beacon row (a plain activatable row: earlier Enter and Space
// left its counter at 2, so a working click reads 3).
let pt = await portCenter("[data-mc-row=\"beacon\"]");
await cdp.click(pt.x, pt.y);
s = await st();
port = s.columns[0] as McColumn;
check("click-activates-plain-row", port.log.includes("beacon cycled (3)"), port.log);
check("click-focuses-clicked-row", port.rowFocused === "beacon", JSON.stringify(port));
// Click the surviving crate's aux ×: the synthesis carries the aux position,
// so the click removes exactly as Enter-on-aux does.
pt = await portCenter("[data-mc-row=\"crate-cells\"] [data-mc-aux]");
await cdp.click(pt.x, pt.y);
s = await st();
port = s.columns[0] as McColumn;
check(
"click-on-aux-removes",
port.log.includes("fuel cells jettisoned") && port.cargoCount === 0,
JSON.stringify(port),
);
// --- nested scroller: the relay-bank variant (28 static rows in a 240px
// overflow-y box). The controller's scrollIntoView(block:"nearest") must
// scroll the BOX, never the page (the gallery's page scroller is the
// shell's <main>; window never scrolls in this layout, asserted anyway). ---
const sst = async (): Promise<McScrollState> => JSON.parse((await cdp.js(SCROLL_JS)) as string);
// Tab from port down to the relay bank (starboard and the ops console are
// tab stops on the way; hunt, don't count). Tabbing in seats row focus on
// relay-01 and MAY scroll the page scroller to reveal the demo -- so the
// page-scroll baseline is recorded after landing, not before.
landed = false;
let ss = await sst();
for (let i = 0; i < 8; i++) {
await cdp.key("Tab");
ss = await sst();
if (ss.hasFocus) {
landed = true;
break;
}
}
check(
"nested-tab-reaches-relay-bank",
landed && ss.rowFocused === "relay-01" && ss.boxTop === 0,
JSON.stringify(ss),
);
const pageBefore = [ss.mainTop, ss.windowY] as const;
const pageSame = (v: McScrollState) => v.mainTop === pageBefore[0] && v.windowY === pageBefore[1];
// A hotkey jump across the scroller: 2 lands on relay-20, far off-screen
// from the top -- the box must scroll, the page must not.
await cdp.key("2");
ss = await sst();
check("nested-jump-lands-on-relay-20", ss.rowFocused === "relay-20", JSON.stringify(ss));
check("nested-jump-scrolls-the-box", ss.boxTop > 0, JSON.stringify(ss));
check(
"nested-jump-leaves-page-alone",
pageSame(ss),
JSON.stringify([pageBefore, ss.mainTop, ss.windowY]),
);
check("nested-jump-row-visible-in-box", ss.rowInBox === true, JSON.stringify(ss));
// End slams to the last relay; Home slams back and the box must return
// exactly to the top (relay-01 flush at scrollTop 0).
await cdp.key("End");
ss = await sst();
check(
"nested-end-reaches-last-in-view",
ss.rowFocused === "relay-28" && ss.rowInBox === true,
JSON.stringify(ss),
);
await cdp.key("Home");
ss = await sst();
check(
"nested-home-returns-box-to-top",
ss.rowFocused === "relay-01" && ss.boxTop === 0 && ss.rowInBox === true,
JSON.stringify(ss),
);
check(
"nested-slams-leave-page-alone",
pageSame(ss),
JSON.stringify([pageBefore, ss.mainTop, ss.windowY]),
);
}
export const mcDrive: Scenario = { name: "mc", run };
packages/e2e/scenarios/mc-drive.ts · 15.1 KiB · 945c8644e5