Files
advanced-camera-card/tests/browser/dom.ts
T

126 lines
4.2 KiB
TypeScript

import { userEvent } from 'vitest/browser';
// `querySelectorAll` does not look inside a shadow root, so a full search has
// to step through them a level at a time. The node's own root counts because a
// Lit element renders into that, not into its children.
const getImmediateShadowRoots = (root: ParentNode): ShadowRoot[] => {
const roots = root instanceof Element && root.shadowRoot ? [root.shadowRoot] : [];
for (const child of root.querySelectorAll('*')) {
if (child.shadowRoot) {
roots.push(child.shadowRoot);
}
}
return roots;
};
/**
* Get every shadow root at or below an element.
*/
export const getAllShadowRoots = (root: ParentNode): ShadowRoot[] =>
getImmediateShadowRoots(root).flatMap((child) => [child, ...getAllShadowRoots(child)]);
/**
* Search an element and every shadow root beneath it. The card nests its own
* components several roots deep, and neither the source tree nor the node
* suite has a helper for this.
*/
export const deepQuery = <T extends Element = Element>(
root: ParentNode,
selector: string,
): T | null => {
const direct = root.querySelector<T>(selector);
if (direct) {
return direct;
}
for (const child of getImmediateShadowRoots(root)) {
const found = deepQuery<T>(child, selector);
if (found) {
return found;
}
}
return null;
};
/**
* Every match for a selector across an element and the shadow roots beneath it,
* for asking how many of something the card rendered rather than whether it
* rendered any.
*/
export const deepQueryAll = <T extends Element = Element>(
root: ParentNode,
selector: string,
): T[] => [
...root.querySelectorAll<T>(selector),
...getImmediateShadowRoots(root).flatMap((child) => deepQueryAll<T>(child, selector)),
];
// `userEvent.keyboard` is given one string naming every key to press, in which
// a name of more than one character is wrapped in braces (`{Escape}`) and a
// single character stands for itself.
// See: https://vitest.dev/guide/browser/interactivity-api.html#userevent-keyboard
const asKeyboardInput = (key: string): string => (key.length === 1 ? key : `{${key}}`);
export const pressKey = async (key: string): Promise<void> =>
await userEvent.keyboard(asKeyboardInput(key));
export const holdKey = async (key: string): Promise<void> =>
await userEvent.keyboard(`{${key}>}`);
export const releaseKey = async (key: string): Promise<void> =>
await userEvent.keyboard(`{/${key}}`);
export const pressTab = async (): Promise<void> => await userEvent.tab();
/**
* Press Tab until the page reaches the state the caller is waiting for,
* reporting whether it got there. `maximumPresses` is a runaway guard rather
* than a count: a page that never reaches that state fails the caller instead
* of tabbing forever.
*/
export const tabUntil = async (
isReached: () => boolean,
maximumPresses: number,
): Promise<boolean> => {
for (let press = 0; press < maximumPresses; ++press) {
await pressTab();
if (isReached()) {
return true;
}
}
return false;
};
/**
* Click an element with a real pointer, which is the only kind that carries the
* browser's own behavior: the press moves focus, and an element that stops the
* press doing so leaves it where it was.
*/
export const clickElement = async (element: Element): Promise<void> =>
await userEvent.click(element);
/**
* Send a `pointerdown` to an element without moving a real pointer, so the page
* stays scrolled where the test left it: `clickElement` scrolls its target into
* view before pressing it.
*
* The browser does nothing of its own with a press it did not itself deliver,
* so what follows is only what the card's own listener does.
*/
export const dispatchPointerDown = (element: Element): void => {
element.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, composed: true }),
);
};
/**
* The element that actually has focus. `document.activeElement` names the
* outermost shadow host in the way, since focus is reported per tree.
*/
export const getFocusedElement = (): Element | null => {
let focused = document.activeElement;
while (focused?.shadowRoot?.activeElement) {
focused = focused.shadowRoot.activeElement;
}
return focused;
};