test: Add browser based tests for keyboard focus handling (#2647)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { css, html, LitElement, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { SIDE_LOADED_ELEMENTS } from '../../src/ha/side-load-ha-elements';
|
||||
|
||||
@@ -79,8 +80,11 @@ class HACardStub extends HAElementStub {
|
||||
* A round tap target sized by `--ha-icon-button-size`, which is how Home
|
||||
* Assistant's own icon button sizes itself and what the card sets to lay its
|
||||
* menu out. Without it a button is only as big as whatever it contains.
|
||||
*
|
||||
* Home Assistant draws a real `<button>` within, so a menu button takes focus
|
||||
* when it is pressed, can be tabbed to, and ignores a press while disabled.
|
||||
*/
|
||||
class HAIconButtonStub extends HAElementStub {
|
||||
class HAIconButtonStub extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
@@ -92,7 +96,26 @@ class HAIconButtonStub extends HAElementStub {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public disabled = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<button ?disabled=${this.disabled}><slot></slot></button>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,15 +133,58 @@ class HAIconStub extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
interface PictureElementConfig {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ConditionalElementConfig {
|
||||
elements?: PictureElementConfig[];
|
||||
}
|
||||
|
||||
// Every Home Assistant element takes its own shape of configuration through the
|
||||
// same call, and nothing here reads what is passed.
|
||||
interface ConfigurableElement extends HTMLElement {
|
||||
setConfig(config: unknown): void;
|
||||
}
|
||||
|
||||
const isConfigurable = (element: HTMLElement): element is ConfigurableElement =>
|
||||
'setConfig' in element;
|
||||
|
||||
const CUSTOM_ELEMENT_PREFIX = 'custom:';
|
||||
const CARD_ELEMENT_PREFIX = `${CUSTOM_ELEMENT_PREFIX}advanced-camera-card-`;
|
||||
|
||||
/**
|
||||
* Home Assistant's conditional picture element, which the card builds one of on
|
||||
* every mount to host whatever picture elements are configured.
|
||||
*
|
||||
* Home Assistant creates one element per entry. The card's own menu and status
|
||||
* bar items are elements that ask to be added when they are connected, so
|
||||
* without that a configured menu button never reaches the menu.
|
||||
*
|
||||
* Only the card's own elements are created here. Other elements (e.g. Home
|
||||
* Assistant's `icon` or `image`) are skipped, so a test that needs one must add
|
||||
* it to this stub first.
|
||||
*/
|
||||
class HuiConditionalElementStub extends HTMLElement {
|
||||
public hass?: unknown;
|
||||
|
||||
public setConfig(): void {
|
||||
// The card only needs the call to succeed; nothing renders from it here.
|
||||
public setConfig(config: ConditionalElementConfig): void {
|
||||
this.replaceChildren();
|
||||
|
||||
for (const element of config.elements ?? []) {
|
||||
if (!element.type.startsWith(CARD_ELEMENT_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = document.createElement(
|
||||
// Example: custom:advanced-camera-card-menu-icon -> advanced-camera-card-menu-icon
|
||||
element.type.slice(CUSTOM_ELEMENT_PREFIX.length),
|
||||
);
|
||||
if (isConfigurable(child)) {
|
||||
child.setConfig(element);
|
||||
}
|
||||
this.append(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AdvancedCameraCard } from '../../src/card';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
|
||||
import { defineHAElementStubs } from './ha-element-stubs';
|
||||
import { deepQuery, deepQueryAll } from './test-utils';
|
||||
import { clickElement, deepQuery, deepQueryAll } from './test-utils';
|
||||
|
||||
// Home Assistant's masonry columns are `max-width: 500px`, so this is the width
|
||||
// a card usually gets. The card derives height from the media it is showing.
|
||||
@@ -35,6 +35,19 @@ interface ConsoleEntry {
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
interface ConsoleWaiter {
|
||||
// The number of occurrences required to satisfy this waiter.
|
||||
count: number;
|
||||
level: ConsoleLevel;
|
||||
message: RegExp;
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
interface ConsoleWaiterOptions {
|
||||
count?: number;
|
||||
level?: ConsoleLevel;
|
||||
}
|
||||
|
||||
const CONSOLE_LEVELS = ['debug', 'error', 'info', 'log', 'warn'] as const;
|
||||
type ConsoleLevel = (typeof CONSOLE_LEVELS)[number];
|
||||
|
||||
@@ -154,12 +167,15 @@ class ConsoleLedger {
|
||||
private _entries: ConsoleEntry[] = [];
|
||||
private _originals = new Map<ConsoleLevel, (...args: unknown[]) => void>();
|
||||
|
||||
private _waiting: ConsoleWaiter[] = [];
|
||||
|
||||
constructor() {
|
||||
for (const level of CONSOLE_LEVELS) {
|
||||
const original = console[level];
|
||||
this._originals.set(level, original);
|
||||
console[level] = (...args: unknown[]): void => {
|
||||
this._entries.push({ level, args });
|
||||
this._resolveWaiters();
|
||||
original(...args);
|
||||
};
|
||||
}
|
||||
@@ -175,12 +191,55 @@ class ConsoleLedger {
|
||||
return this.getEntries(level).map((entry) => entry.args.map(String).join(' '));
|
||||
}
|
||||
|
||||
public countMessages(message: RegExp, level: ConsoleLevel = 'info'): number {
|
||||
return this.getMessages(level).filter((written) => message.test(written)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a message has been written. The card acts on what a test does to
|
||||
* it without waiting to be asked, so a test that presses a key and reads the
|
||||
* log on the next line usually finds nothing there yet.
|
||||
*/
|
||||
public async waitForMessage(
|
||||
message: RegExp,
|
||||
options?: ConsoleWaiterOptions,
|
||||
): Promise<void> {
|
||||
const waiter = {
|
||||
count: options?.count ?? 1,
|
||||
level: options?.level ?? 'info',
|
||||
message,
|
||||
};
|
||||
|
||||
if (this._isSatisfied(waiter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await new Promise<void>((resolve) => {
|
||||
this._waiting.push({ ...waiter, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
private _isSatisfied(waiter: Omit<ConsoleWaiter, 'resolve'>): boolean {
|
||||
return this.countMessages(waiter.message, waiter.level) >= waiter.count;
|
||||
}
|
||||
|
||||
private _resolveWaiters(): void {
|
||||
this._waiting = this._waiting.filter((waiter) => {
|
||||
if (!this._isSatisfied(waiter)) {
|
||||
return true;
|
||||
}
|
||||
waiter.resolve();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
for (const [level, original] of this._originals) {
|
||||
console[level] = original;
|
||||
}
|
||||
this._originals.clear();
|
||||
this._entries = [];
|
||||
this._waiting = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +254,11 @@ export interface MountOptions {
|
||||
width?: string;
|
||||
height?: string;
|
||||
|
||||
// Where that container is placed, as CSS lengths from the page's top left
|
||||
// corner. The page grows to reach it, so a card put beyond the window can
|
||||
// only be brought into view by scrolling.
|
||||
position?: { top?: string; left?: string };
|
||||
|
||||
// Console errors required to be logged.
|
||||
expectedConsoleErrors?: RegExp[];
|
||||
|
||||
@@ -246,6 +310,11 @@ export class MountedCard {
|
||||
if (options?.height) {
|
||||
this._container.style.height = options.height;
|
||||
}
|
||||
if (options?.position) {
|
||||
this._container.style.position = 'absolute';
|
||||
this._container.style.top = options.position.top ?? '0';
|
||||
this._container.style.left = options.position.left ?? '0';
|
||||
}
|
||||
document.body.append(this._container);
|
||||
|
||||
// Before the card exists, so that nothing it does during its first render is
|
||||
@@ -378,12 +447,16 @@ export class MountedCard {
|
||||
public async clickControl(name: string): Promise<void> {
|
||||
const control = await this._findControl(name);
|
||||
|
||||
control.click();
|
||||
await clickElement(control);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press and keep holding a control until the card takes it as a hold, which
|
||||
* is a second action several controls carry alongside their tap.
|
||||
*
|
||||
* Assembled from events rather than driven with a real pointer, because
|
||||
* `userEvent` offers whole gestures only (click, hover, drag) and none of
|
||||
* them stops part way through a press.
|
||||
*/
|
||||
public async holdControl(name: string): Promise<void> {
|
||||
const control = await this._findControl(name);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { userEvent } from 'vitest/browser';
|
||||
|
||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||
import { createLogAction } from '../../src/utils/action';
|
||||
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
|
||||
|
||||
export const STILL_CAMERA_ENTITY = 'camera.office';
|
||||
@@ -111,6 +114,20 @@ export const createStillImageCardConfig = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// What an initialized card writes, as the pattern the console is searched for.
|
||||
export const CARD_INITIALIZED_MESSAGE = /card initialized/;
|
||||
|
||||
/**
|
||||
* An automation that reports every time the card finishes initializing. A card
|
||||
* announces nothing else when it is ready to be acted on, and it initializes
|
||||
* again each time it returns to the page or Home Assistant comes back, so a
|
||||
* test that acts on a card too early sees nothing happen.
|
||||
*/
|
||||
export const createInitializedAutomation = (): RawAdvancedCameraCardConfig => ({
|
||||
triggers: [{ trigger: 'initialized' }],
|
||||
actions: [createLogAction(CARD_INITIALIZED_MESSAGE.source)],
|
||||
});
|
||||
|
||||
// `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.
|
||||
@@ -183,3 +200,54 @@ export const isLiveMediaShowing = (root: ParentNode): boolean =>
|
||||
deepQueryAll(root, 'advanced-camera-card-live-provider').some(
|
||||
(provider) => !!deepQuery(provider, MEDIA_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();
|
||||
|
||||
/**
|
||||
* Click an element with a real pointer, which is the only kind that carries the
|
||||
* browser's own behaviour: 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user