From 8d8d486afacc007ae14123c61ea18d36ed436b14 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 7 Aug 2026 16:27:28 -0700 Subject: [PATCH] test: Split browser test-utils (#2662) - Closes: #2660 --- tests/browser/dom.ts | 106 +++++++++++++ tests/browser/mounted-card.ts | 2 +- tests/browser/test-media.ts | 35 ++++- tests/browser/test-utils.ts | 142 +----------------- .../card-element-manager.browser.test.ts | 5 +- .../issues/media-unavailable.browser.test.ts | 15 +- .../keyboard-state-manager.browser.test.ts | 14 +- .../gallery/gallery.browser.test.ts | 2 +- .../viewer/carousel.browser.test.ts | 6 +- 9 files changed, 161 insertions(+), 166 deletions(-) create mode 100644 tests/browser/dom.ts diff --git a/tests/browser/dom.ts b/tests/browser/dom.ts new file mode 100644 index 00000000..9fe04f23 --- /dev/null +++ b/tests/browser/dom.ts @@ -0,0 +1,106 @@ +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 = ( + root: ParentNode, + selector: string, +): T | null => { + const direct = root.querySelector(selector); + if (direct) { + return direct; + } + for (const child of getImmediateShadowRoots(root)) { + const found = deepQuery(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 = ( + root: ParentNode, + selector: string, +): T[] => [ + ...root.querySelectorAll(selector), + ...getImmediateShadowRoots(root).flatMap((child) => deepQueryAll(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 => + await userEvent.keyboard(asKeyboardInput(key)); + +export const holdKey = async (key: string): Promise => + await userEvent.keyboard(`{${key}>}`); + +export const releaseKey = async (key: string): Promise => + await userEvent.keyboard(`{/${key}}`); + +export const pressTab = async (): Promise => 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 => + 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; +}; diff --git a/tests/browser/mounted-card.ts b/tests/browser/mounted-card.ts index e02e0a81..d5ca97bf 100644 --- a/tests/browser/mounted-card.ts +++ b/tests/browser/mounted-card.ts @@ -3,9 +3,9 @@ import { expect, onTestFinished, vi } from 'vitest'; import type { AdvancedCameraCard } from '../../src/card'; import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import { ACTION_HANDLER_HOLD_SECONDS } from '../../src/const'; +import { clickElement, deepQuery, deepQueryAll, getAllShadowRoots } from './dom'; import type { FakeEntityOptions, FakeHASS } from './fake-hass'; import { defineHAElementStubs } from './ha-element-stubs'; -import { clickElement, deepQuery, deepQueryAll, getAllShadowRoots } 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. diff --git a/tests/browser/test-media.ts b/tests/browser/test-media.ts index 5363685d..eafec7fd 100644 --- a/tests/browser/test-media.ts +++ b/tests/browser/test-media.ts @@ -4,6 +4,7 @@ import { beforeAll } from 'vitest'; import { createFixtureURL, SNAPSHOT_FIXTURE_FILENAME } from './fixtures'; +const HTTP_NOT_FOUND = 404; const HTTP_OK = 200; // Where this worker answers. @@ -20,7 +21,7 @@ let inUse = false; /** * See the worker below for what `responses` and `repeat` ask it to do. */ -export const createTestMediaURL = ( +const createTestMediaURL = ( responses: number[], repeat = false, filename: string = SNAPSHOT_FIXTURE_FILENAME, @@ -44,6 +45,36 @@ export const createTestMediaURL = ( ); }; +/** + * A media URL that fails the given number of times and then works from there + * on, so a test can make a camera recover rather than only fail. + */ +export const createTemporarilyFailingMediaURL = ( + failures: number, + filename?: string, +): string => + createTestMediaURL([...Array(failures).fill(HTTP_NOT_FOUND), HTTP_OK], true, filename); + +/** + * A media URL that never works, for a camera that is simply broken. + */ +export const createFailingMediaURL = (): string => + createTestMediaURL([HTTP_NOT_FOUND], true); + +/** + * A media URL that is never answered, for a camera that accepts the request and + * then says nothing. Silence is a different failure from a refusal, and the + * only one that can run a loading timeout out. + */ +export const createUnansweredMediaURL = (): string => createTestMediaURL([]); + +/** + * A media URL that answers once and is then never answered again, for a camera + * that delivers a picture and goes quiet behind it. + */ +export const createStallingMediaURL = (filename?: string): string => + createTestMediaURL([HTTP_OK], false, filename); + /** * Serves a fixture at `/test-media/`, behaving as the query asks: * @@ -71,7 +102,7 @@ const worker = setupWorker( const url = new URL(request.url); const token = url.searchParams.get('token'); if (!token) { - return new HttpResponse(null, { status: 404 }); + return new HttpResponse(null, { status: HTTP_NOT_FOUND }); } const responses = (url.searchParams.get('responses') ?? '') diff --git a/tests/browser/test-utils.ts b/tests/browser/test-utils.ts index da6a8ec2..c673977b 100644 --- a/tests/browser/test-utils.ts +++ b/tests/browser/test-utils.ts @@ -1,5 +1,3 @@ -import { userEvent } from 'vitest/browser'; - import type { PartialAdvancedCameraCardConfig, RawAdvancedCameraCardConfig, @@ -8,10 +6,10 @@ import type { Entity } from '../../src/ha/registry/entity/types'; import type { MediaLoadedInfoEventDetail } from '../../src/types'; import { createLogAction } from '../../src/utils/action'; import { isTruthy } from '../../src/utils/basic'; +import { clickElement, deepQuery, deepQueryAll } from './dom'; import { FakeHASS, type FakeEntityOptions } from './fake-hass'; import { createFixtureURL, SNAPSHOT_FIXTURE_FILENAME } from './fixtures'; import type { MountedCard } from './mounted-card'; -import { createTestMediaURL } from './test-media'; export const CAMERA_ENTITY = 'camera.office'; @@ -41,39 +39,6 @@ export const createStillImageCameraConfig = ( }, }); -const HTTP_NOT_FOUND = 404; -const HTTP_OK = 200; - -/** - * A media URL that fails the given number of times and then works from there - * on, so a test can make a camera recover rather than only fail. - */ -export const createTemporarilyFailingMediaURL = ( - failures: number, - filename?: string, -): string => - createTestMediaURL([...Array(failures).fill(HTTP_NOT_FOUND), HTTP_OK], true, filename); - -/** - * A media URL that never works, for a camera that is simply broken. - */ -export const createFailingMediaURL = (): string => - createTestMediaURL([HTTP_NOT_FOUND], true); - -/** - * A media URL that is never answered, for a camera that accepts the request and - * then says nothing. Silence is a different failure from a refusal, and the - * only one that can run a loading timeout out. - */ -export const createUnansweredMediaURL = (): string => createTestMediaURL([]); - -/** - * A media URL that answers once and is then never answered again, for a camera - * that delivers a picture and goes quiet behind it. - */ -export const createStallingMediaURL = (filename?: string): string => - createTestMediaURL([HTTP_OK], false, filename); - export interface FakeCameraDescription { entityID: string; entity: FakeEntityOptions; @@ -167,60 +132,6 @@ export const createInitializedAutomation = (): RawAdvancedCameraCardConfig => ({ 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. -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 = ( - root: ParentNode, - selector: string, -): T | null => { - const direct = root.querySelector(selector); - if (direct) { - return direct; - } - for (const child of getImmediateShadowRoots(root)) { - const found = deepQuery(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 = ( - root: ParentNode, - selector: string, -): T[] => [ - ...root.querySelectorAll(selector), - ...getImmediateShadowRoots(root).flatMap((child) => deepQueryAll(child, selector)), -]; - export const isMediaLoadedInfoEventDetail = ( detail: unknown, ): detail is MediaLoadedInfoEventDetail => @@ -388,54 +299,3 @@ export const getStatusBarStrings = (root: ParentNode): string[] => */ export const getStatusBarItem = (root: ParentNode, title: string): Element | null => getStatusBarItems(root).find((item) => item.getAttribute('title') === title) ?? null; - -// `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 => - await userEvent.keyboard(asKeyboardInput(key)); - -export const holdKey = async (key: string): Promise => - await userEvent.keyboard(`{${key}>}`); - -export const releaseKey = async (key: string): Promise => - await userEvent.keyboard(`{/${key}}`); - -export const pressTab = async (): Promise => 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 => - 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; -}; diff --git a/tests/card-controller/card-element-manager.browser.test.ts b/tests/card-controller/card-element-manager.browser.test.ts index 6babbbf5..daa7afdb 100644 --- a/tests/card-controller/card-element-manager.browser.test.ts +++ b/tests/card-controller/card-element-manager.browser.test.ts @@ -1,16 +1,13 @@ import { describe, expect, it, onTestFinished } from 'vitest'; import { createLogAction } from '../../src/utils/action'; +import { deepQueryAll, getFocusedElement, pressKey, pressTab } from '../browser/dom'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, createGenericCameraHASS, createInitializedAutomation, createStillImageCardConfig, - deepQueryAll, - getFocusedElement, - pressKey, - pressTab, } from '../browser/test-utils'; // What the automation writes when it runs, written as the pattern the console diff --git a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts index 03c2a9d3..ad62c10e 100644 --- a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts @@ -5,23 +5,24 @@ import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/compo import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/components-lib/media-load-watchdog-controller'; import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog'; import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types'; +import { deepQuery, deepQueryAll } from '../../../browser/dom'; import { MountedCardFactory, type MountedCard, type MountOptions, } from '../../../browser/mounted-card'; -import { useTestMedia } from '../../../browser/test-media'; import { - CAMERA_ENTITY, createFailingMediaURL, - createGenericCameraHASS, createStallingMediaURL, - createStillImageCameraConfig, - createStillImageCardConfig, createTemporarilyFailingMediaURL, createUnansweredMediaURL, - deepQuery, - deepQueryAll, + useTestMedia, +} from '../../../browser/test-media'; +import { + CAMERA_ENTITY, + createGenericCameraHASS, + createStillImageCameraConfig, + createStillImageCardConfig, getBlockNotificationText, getStatusBarItem, isLiveMediaShowing, diff --git a/tests/card-controller/keyboard-state-manager.browser.test.ts b/tests/card-controller/keyboard-state-manager.browser.test.ts index 51036e0f..23d6ba73 100644 --- a/tests/card-controller/keyboard-state-manager.browser.test.ts +++ b/tests/card-controller/keyboard-state-manager.browser.test.ts @@ -2,6 +2,14 @@ import { assert, describe, expect, it } from 'vitest'; import type { LogActionConfig } from '../../src/config/schema/actions/custom/log'; import { createLogAction } from '../../src/utils/action'; +import { + clickElement, + dispatchPointerDown, + getFocusedElement, + holdKey, + pressKey, + releaseKey, +} from '../browser/dom'; import { MountedCardFactory, type MountedCard, @@ -9,15 +17,9 @@ import { } from '../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, - clickElement, createGenericCameraHASS, createInitializedAutomation, createStillImageCardConfig, - dispatchPointerDown, - getFocusedElement, - holdKey, - pressKey, - releaseKey, } from '../browser/test-utils'; const ZOOM_ENTITY = 'input_boolean.zoom'; diff --git a/tests/components/gallery/gallery.browser.test.ts b/tests/components/gallery/gallery.browser.test.ts index becc806c..c0c66d1c 100644 --- a/tests/components/gallery/gallery.browser.test.ts +++ b/tests/components/gallery/gallery.browser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { FrigateEvent } from '../../../src/camera-manager/frigate/types'; import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types'; +import { deepQuery } from '../../browser/dom'; import { createTestFrigateEvent, EVENT_TIME_NEWER, @@ -11,7 +12,6 @@ import { import type { MountedCard } from '../../browser/mounted-card'; import { clickThumbnail, - deepQuery, getBlockNotificationText, getMediaViewerMediaURLs, getThumbnails, diff --git a/tests/components/viewer/carousel.browser.test.ts b/tests/components/viewer/carousel.browser.test.ts index f18f1029..18e5df15 100644 --- a/tests/components/viewer/carousel.browser.test.ts +++ b/tests/components/viewer/carousel.browser.test.ts @@ -2,6 +2,7 @@ import { assert, describe, expect, it, onTestFinished, vi } from 'vitest'; import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../src/components-lib/media-load-watchdog-controller'; import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types'; +import { clickElement, deepQuery } from '../../browser/dom'; import { createTestFrigateEvent, EVENT_TIME_NEWER, @@ -9,13 +10,10 @@ import { mountCardWithFrigate, } from '../../browser/fake-frigate'; import type { MountedCard } from '../../browser/mounted-card'; -import { useTestMedia } from '../../browser/test-media'; +import { createFailingMediaURL, useTestMedia } from '../../browser/test-media'; import { - clickElement, clickNextPreviousMedia, clickThumbnail, - createFailingMediaURL, - deepQuery, getMediaViewerMediaURLs, getSelectedThumbnail, getStatusBarItem,