ip-drive.ts
// Drives the yk-infopop gallery entry with TRUSTED input via CDP: keys via
// Input.dispatchKeyEvent and real mouse movement via Input.dispatchMouseEvent
// (synthetic pointerover can't prove real hover). Covers: keyboard i on every
// row flavor (yk-button's own claim, the plain-row/slider/aux data-info
// fallback), the i toggle (second i on the same anchor dismisses; scope is one
// keypress), hover raising the SAME content, pin precedence over hover (incl.
// i upgrading a hover popup to pinned), dismissal (nav keys close AND navigate,
// pointerdown closes, no resurrect), aria reflection wiring, edge clamping,
// and the bottom flip (short-viewport emulation).
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: the popup's mode/content/position, the demo
// column's focused row and log, and the aria reflection on the current anchor.
const STATE_JS = `
(() => {
const shell = document.querySelector("yk-gallery").shadowRoot;
const pop = shell.querySelector("yk-infopop");
const col = shell.querySelector("ykg-infopop-column")?.shadowRoot;
const rect = pop.getBoundingClientRect();
const anchorRect = pop.anchor?.getBoundingClientRect() ?? null;
return JSON.stringify({
mode: pop.mode,
text: pop.shadowRoot.textContent.trim(),
visible: pop.style.visibility === "visible",
ariaHidden: pop.getAttribute("aria-hidden"),
popRect: {x: rect.x, y: rect.y, w: rect.width, h: rect.height},
anchorRect: anchorRect ? {x: anchorRect.x, y: anchorRect.y, w: anchorRect.width, h: anchorRect.height} : null,
anchorAriaWired: pop.anchor ? (pop.anchor.ariaDescribedByElements?.[0] === pop) : null,
rowFocused: col?.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null,
log: col?.querySelector(".log")?.textContent ?? "",
viewport: {w: window.innerWidth, h: window.innerHeight},
});
})()
`;
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
interface IpState {
mode: string;
text: string;
visible: boolean;
ariaHidden: string | null;
popRect: Rect;
anchorRect: Rect | null;
anchorAriaWired: boolean | null;
rowFocused: string | null;
log: string;
viewport: { w: number; h: number };
}
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<IpState> => 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 el = ${jsElExpr};
const r = el.getBoundingClientRect();
return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2});
})()
`);
return JSON.parse(box as string);
};
const COL = "document.querySelector('yk-gallery').shadowRoot.querySelector('ykg-infopop-column').shadowRoot";
await openGallery(
ctx,
"#yk-infopop",
"document.readyState === 'complete' && !!document.querySelector('yk-gallery')?.shadowRoot?.querySelector('ykg-infopop-column')",
);
// Park the mouse in dead space so no hover state lingers between phases.
await cdp.mouseMove(5, 5);
// --- keyboard: i on every row flavor ---
let landed = false;
for (let i = 0; i < 12; i++) {
await cdp.key("Tab");
const focused = await cdp.js(
`${COL}.querySelector('[data-mc-container]').classList.contains('mc-focus-within')`,
);
if (focused === true) {
landed = true;
break;
}
}
let s = await st();
check("tab-into-column", landed && s.rowFocused === "dock", JSON.stringify(s.rowFocused));
await cdp.key("i");
s = await st();
check("i-on-yk-button-pins", s.mode === "pinned" && s.visible, JSON.stringify(s));
check("i-on-yk-button-content", s.text.includes("a yk-button answers i itself"), s.text);
check("aria-reflection-wired", s.anchorAriaWired === true, JSON.stringify(s.anchorAriaWired));
check(
"popup-below-anchor",
s.anchorRect !== null && s.popRect.y > s.anchorRect.y,
JSON.stringify(s.popRect),
);
await shot(cdp, "kb-pinned.png");
// The toggle: i again on the same anchor dismisses (no blink-reopen)...
await cdp.key("i");
s = await st();
check("i-toggles-off-yk-button", s.mode === "closed", JSON.stringify(s.mode));
// ... and a fresh press raises it right back (suppression is one keypress).
await cdp.key("i");
s = await st();
check(
"i-reopens-yk-button",
s.mode === "pinned" && s.text.includes("a yk-button answers i itself"),
s.text,
);
// Navigation closes the popup AND still navigates (never eats keys).
await cdp.key("ArrowDown");
s = await st();
check("nav-key-closes", s.mode === "closed" && s.ariaHidden === "true", JSON.stringify(s.mode));
check("nav-key-still-navigates", s.rowFocused === "survey", JSON.stringify(s.rowFocused));
// Plain text row: the controller's data-info fallback answers.
await cdp.key("i");
s = await st();
check("i-on-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text);
// The toggle again, through the other producer (the controller fallback).
await cdp.key("i");
s = await st();
check("i-toggles-off-plain-row", s.mode === "closed", JSON.stringify(s.mode));
await cdp.key("i");
s = await st();
check("i-reopens-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text);
// One keypress closes the old popup and raises the next (i on the next row).
await cdp.key("ArrowDown");
await cdp.key("i");
s = await st();
check("i-moves-with-focus", s.mode === "pinned" && s.text.includes("slider row"), s.text);
// The slider still works under/after the popup: left/right adjust.
await cdp.key("ArrowRight");
s = await st();
check(
"slider-key-closes-and-acts",
s.mode === "closed" && s.log === "reactor power 45%",
JSON.stringify({ mode: s.mode, log: s.log }),
);
// The aux x carries its own story, distinct from its row's.
await cdp.key("ArrowDown");
await cdp.key("i");
s = await st();
check("i-on-two-control-main", s.text.includes("probe telemetry feed"), s.text);
await cdp.key("ArrowRight");
await cdp.key("i");
s = await st();
check("i-on-aux-x", s.text.includes("the x's own story"), s.text);
// Escape closes; nothing resurrects.
await cdp.key("Escape");
await sleep(800);
s = await st();
check("escape-closes-no-resurrect", s.mode === "closed", JSON.stringify(s.mode));
// --- hover: the same content, raised by the mouse ---
let pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
await cdp.mouseMove(pt.x, pt.y);
await sleep(150);
s = await st();
check("hover-waits-out-the-delay", s.mode === "closed", JSON.stringify(s.mode));
await sleep(500);
s = await st();
check(
"hover-raises-same-content",
s.mode === "hover" && s.text.includes("plain text row"),
s.text,
);
await shot(cdp, "hover-same-content.png");
// Hover follows to the aux x (nearest carrier wins over the row's).
pt = await centerOf(`${COL}.querySelector('[data-mc-aux]')`);
await cdp.mouseMove(pt.x, pt.y);
await sleep(500);
s = await st();
check(
"hover-nearest-carrier",
s.mode === "hover" && s.text.includes("the x's own story"),
s.text,
);
// Leaving all carriers closes.
await cdp.mouseMove(5, 5);
await sleep(100);
s = await st();
check("hover-leave-closes", s.mode === "closed", JSON.stringify(s.mode));
// --- precedence: a pinned popup outranks hover ---
// (Real focus is still on the column from the keyboard phase.)
await cdp.key("Home");
await cdp.key("i");
s = await st();
check(
"pin-for-precedence",
s.mode === "pinned" && s.text.includes("a yk-button answers i itself"),
s.text,
);
pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
await cdp.mouseMove(pt.x, pt.y);
await sleep(600);
s = await st();
check(
"hover-never-displaces-pin",
s.mode === "pinned" && s.text.includes("a yk-button answers i itself"),
s.text,
);
await shot(cdp, "pin-outranks-hover.png");
// After the pin closes, the parked hover does not resurrect...
await cdp.key("Escape");
await sleep(600);
s = await st();
check("no-hover-resurrect-after-pin", s.mode === "closed", JSON.stringify(s.mode));
// ... but a fresh pointer move re-arms normally.
await cdp.mouseMove(pt.x, pt.y + 1);
await cdp.mouseMove(5, 5);
await sleep(50);
const pt2 = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`);
await cdp.mouseMove(pt2.x, pt2.y);
await sleep(500);
s = await st();
check("fresh-hover-re-arms", s.mode === "hover", JSON.stringify(s.mode));
// i on the element the mouse is hovering PINS the hover popup (only pinned
// popups toggle off). Focus must sit on the same row for i to answer.
await cdp.key("ArrowDown"); // focus the survey row; the keydown closes the hover popup
await cdp.mouseMove(5, 5);
await sleep(50);
await cdp.mouseMove(pt2.x, pt2.y); // a fresh move re-raises hover on survey
await sleep(500);
s = await st();
check("hover-up-for-upgrade", s.mode === "hover", JSON.stringify(s.mode));
await cdp.key("i");
s = await st();
check(
"i-upgrades-hover-to-pin",
s.mode === "pinned" && s.text.includes("plain text row"),
s.text,
);
// And the freshly pinned popup toggles off like any other; reopen so the
// pointerdown check below still closes something real.
await cdp.key("i");
s = await st();
check("upgraded-pin-toggles-off", s.mode === "closed", JSON.stringify(s.mode));
await cdp.key("i");
s = await st();
check("i-reopens-before-click", s.mode === "pinned", JSON.stringify(s.mode));
// Pointerdown closes (trusted click in dead space).
await cdp.click(5, 5);
s = await st();
check("pointerdown-closes", s.mode === "closed", JSON.stringify(s.mode));
// --- edges: clamping and the flip ---
const EDGES = "document.querySelector('yk-gallery').shadowRoot.querySelector('ykg-infopop-edges').shadowRoot";
pt = await centerOf(`${EDGES}.querySelectorAll('yk-button')[1]`); // far right
await cdp.mouseMove(pt.x, pt.y);
await sleep(500);
s = await st();
const anchor = s.anchorRect as Rect;
const anchorCenter = anchor.x + anchor.w / 2;
const centeredX = anchorCenter - s.popRect.w / 2;
check(
"right-edge-clamps",
s.mode === "hover" && s.popRect.x + s.popRect.w <= s.viewport.w && s.popRect.x < centeredX - 1,
JSON.stringify({ pop: s.popRect, viewport: s.viewport }),
);
await shot(cdp, "edge-clamp.png");
await cdp.mouseMove(5, 5);
// The flip: shrink the viewport so the edges row sits near the bottom,
// then hover it -- the popup must open ABOVE the anchor.
const targetH = await cdp.js(`
(() => {
const el = ${EDGES}.querySelectorAll('yk-button')[0];
return Math.round(el.getBoundingClientRect().bottom + 20);
})()
`);
await cdp.cmd("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: Number(targetH),
deviceScaleFactor: 1,
mobile: false,
});
await sleep(200);
pt = await centerOf(`${EDGES}.querySelectorAll('yk-button')[0]`); // far left
await cdp.mouseMove(pt.x, pt.y);
await sleep(500);
s = await st();
check(
"bottom-edge-flips-above",
s.mode === "hover" && s.anchorRect !== null && s.popRect.y + s.popRect.h <= s.anchorRect.y,
JSON.stringify({ pop: s.popRect, anchor: s.anchorRect }),
);
check("left-edge-clamps", s.popRect.x >= 0, JSON.stringify(s.popRect));
await shot(cdp, "edge-flip.png");
await cdp.cmd("Emulation.clearDeviceMetricsOverride");
await cdp.mouseMove(5, 5);
}
export const ipDrive: Scenario = { name: "ip", run };
packages/e2e/scenarios/ip-drive.ts · 11.3 KiB · 1f1e6068b2