diff --git a/package.json b/package.json index 58917e8c..61db937b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@typescript-eslint/parser": "^8.30.1", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "^4.1.10", + "@webcomponents/scoped-custom-element-registry": "0.0.10", "conventional-changelog-conventionalcommits": "^8.0.0", "docsify-cli": "^4.4.4", "es-module-lexer": "^2.3.1", diff --git a/tests/browser/fake-frigate.browser.test.ts b/tests/browser/fake-frigate.browser.test.ts new file mode 100644 index 00000000..a4e0c846 --- /dev/null +++ b/tests/browser/fake-frigate.browser.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest'; + +import type { FrigateEvent } from '../../src/camera-manager/frigate/types'; +import type { ResolvedMedia } from '../../src/ha/types'; +import { createFrigateEvent } from '../test-utils'; +import { + createFrigateCameraDescription, + FakeFrigate, + FRIGATE_CLIENT_ID, + getTestFrigateCameraName, + type FrigateMediaType, +} from './fake-frigate'; +import type { FakeHASS } from './fake-hass'; +import { CAMERA_ENTITY, createCameraHASS } from './test-utils'; + +const CAMERA_NAME = getTestFrigateCameraName(CAMERA_ENTITY); +const OTHER_CAMERA_NAME = 'hallway'; + +// Frigate timestamps are Unix seconds. Only their order matters here. +const EARLIER = 1000; +const LATER = 2000; + +interface FrigateFixture { + hass: FakeHASS; + frigate: FakeFrigate; +} + +const createFrigate = (events: FrigateEvent[]): FrigateFixture => { + const hass = createCameraHASS([createFrigateCameraDescription()]); + const frigate = new FakeFrigate(hass); + frigate.setEvents(events); + return { hass, frigate }; +}; + +const createEvent = (id: string, event?: Partial): FrigateEvent => + createFrigateEvent({ + camera: CAMERA_NAME, + id, + start_time: EARLIER, + end_time: LATER, + ...event, + }); + +const queryEvents = async ( + hass: FakeHASS, + parameters?: Record, +): Promise => { + const answer = await hass.getHASS().callWS({ + type: 'frigate/events/get', + instance_id: FRIGATE_CLIENT_ID, + ...parameters, + }); + const events: FrigateEvent[] = JSON.parse(answer); + return events; +}; + +const getEventIDs = async ( + hass: FakeHASS, + parameters?: Record, +): Promise => (await queryEvents(hass, parameters)).map((event) => event.id); + +const createContentID = ( + eventID: string, + mediaType: FrigateMediaType, + options?: { clientID?: string; camera?: string }, +): string => + [ + 'media-source://frigate', + options?.clientID ?? FRIGATE_CLIENT_ID, + 'event', + mediaType, + options?.camera ?? CAMERA_NAME, + eventID, + ].join('/'); + +const resolveMedia = async (hass: FakeHASS, contentID: string): Promise => + await hass.getHASS().callWS({ + type: 'media_source/resolve_media', + media_content_id: contentID, + }); + +const createThumbnailPath = (eventID: string, clientID = FRIGATE_CLIENT_ID): string => + `/api/frigate/${clientID}/thumbnail/${eventID}`; + +describe('FakeFrigate', () => { + describe('requests', () => { + it('should refuse a request meant for another instance', async () => { + const { hass } = createFrigate([]); + + await expect( + hass + .getHASS() + .callWS({ type: 'frigate/events/get', instance_id: 'other-frigate' }), + ).rejects.toThrow('FakeFrigate was asked for another instance: other-frigate'); + + await expect( + hass + .getHASS() + .callWS({ type: 'frigate/ptz/info', instance_id: 'other-frigate' }), + ).rejects.toThrow('FakeFrigate was asked for another instance: other-frigate'); + }); + + it('should refuse a request carrying something it does not read', async () => { + const { hass } = createFrigate([]); + + await expect(queryEvents(hass, { camera: CAMERA_NAME })).rejects.toThrow( + 'FakeFrigate was sent unknown parameters: camera', + ); + }); + + it('should refuse a parameter of the wrong type', async () => { + const { hass } = createFrigate([]); + + await expect(queryEvents(hass, { cameras: CAMERA_NAME })).rejects.toThrow( + `FakeFrigate was sent a 'cameras' that is not a list of strings: "${CAMERA_NAME}"`, + ); + await expect(queryEvents(hass, { has_clip: 'yes' })).rejects.toThrow( + `FakeFrigate was sent a 'has_clip' that is not true or false: "yes"`, + ); + }); + }); + + describe('events', () => { + it('should return every event when nothing narrows the query', async () => { + const { hass } = createFrigate([createEvent('one'), createEvent('two')]); + + expect(await getEventIDs(hass)).toEqual(['one', 'two']); + }); + + it('should return the newest event first', async () => { + const { hass } = createFrigate([ + createEvent('older', { start_time: EARLIER }), + createEvent('newer', { start_time: LATER }), + ]); + + expect(await getEventIDs(hass)).toEqual(['newer', 'older']); + }); + + it('should return only the events for the cameras asked for', async () => { + const { hass } = createFrigate([ + createEvent('here'), + createEvent('elsewhere', { camera: OTHER_CAMERA_NAME }), + ]); + + expect(await getEventIDs(hass, { cameras: [CAMERA_NAME] })).toEqual(['here']); + }); + + it('should return only the events holding the media asked for', async () => { + const { hass } = createFrigate([ + createEvent('clip-only', { has_clip: true, has_snapshot: false }), + createEvent('snapshot-only', { has_clip: false, has_snapshot: true }), + ]); + + expect(await getEventIDs(hass, { has_clip: true })).toEqual(['clip-only']); + expect(await getEventIDs(hass, { has_snapshot: true })).toEqual(['snapshot-only']); + }); + + it('should return only the events within the period asked for', async () => { + const { hass } = createFrigate([ + createEvent('before', { start_time: 100, end_time: 200 }), + createEvent('during', { start_time: 400, end_time: 600 }), + createEvent('after', { start_time: 800, end_time: 900 }), + ]); + + expect(await getEventIDs(hass, { after: 300, before: 700 })).toEqual(['during']); + }); + + it('should return an event that was still running when the period began', async () => { + const { hass } = createFrigate([ + createEvent('running', { start_time: 100, end_time: 500 }), + ]); + + expect(await getEventIDs(hass, { after: 300 })).toEqual(['running']); + }); + + it('should return only the events for the requested label', async () => { + const { hass } = createFrigate([ + createEvent('person', { label: 'person' }), + createEvent('car', { label: 'car' }), + ]); + + expect(await getEventIDs(hass, { labels: ['car'] })).toEqual(['car']); + }); + + it('should return only the events in the requested zones', async () => { + const { hass } = createFrigate([ + createEvent('driveway', { zones: ['driveway'] }), + createEvent('garden', { zones: ['garden'] }), + ]); + + expect(await getEventIDs(hass, { zones: ['garden'] })).toEqual(['garden']); + }); + + it('should return the events carrying any of the sub labels asked for', async () => { + // Frigate keeps several sub labels on one event as a comma separated + // string, so asking for one of them has to reach into it. + const { hass } = createFrigate([ + createEvent('known', { sub_label: 'alice, bob' }), + createEvent('stranger', { sub_label: null }), + ]); + + expect(await getEventIDs(hass, { sub_labels: ['bob'] })).toEqual(['known']); + }); + + it('should return only the favorites', async () => { + const { hass } = createFrigate([ + createEvent('kept', { retain_indefinitely: true }), + createEvent('ordinary', { retain_indefinitely: false }), + ]); + + expect(await getEventIDs(hass, { favorites: true })).toEqual(['kept']); + }); + + it('should return no more events than the limit asked for', async () => { + const { hass } = createFrigate([ + createEvent('older', { start_time: EARLIER }), + createEvent('newer', { start_time: LATER }), + ]); + + expect(await getEventIDs(hass, { limit: 1 })).toEqual(['newer']); + }); + }); + + describe('media', () => { + it('should resolve a clip to a video and a snapshot to an image', async () => { + const { hass } = createFrigate([createEvent('one')]); + + expect(await resolveMedia(hass, createContentID('one', 'clips'))).toEqual({ + url: expect.stringContaining('clip.webm?event=one'), + mime_type: 'video/webm', + }); + expect(await resolveMedia(hass, createContentID('one', 'snapshots'))).toEqual({ + url: expect.stringContaining('still-red.png?event=one'), + mime_type: 'image/png', + }); + }); + + it('should refuse media for an event it does not have', async () => { + const { hass } = createFrigate([createEvent('one')]); + + await expect(resolveMedia(hass, createContentID('two', 'clips'))).rejects.toThrow( + 'FakeFrigate has no such media', + ); + }); + + it('should refuse media the event does not have', async () => { + const { hass } = createFrigate([createEvent('one', { has_clip: false })]); + + await expect(resolveMedia(hass, createContentID('one', 'clips'))).rejects.toThrow( + 'FakeFrigate has no such media', + ); + }); + + it('should refuse media asked for under the wrong camera or instance', async () => { + const { hass } = createFrigate([createEvent('one')]); + + await expect( + resolveMedia(hass, createContentID('one', 'clips', { camera: 'elsewhere' })), + ).rejects.toThrow('FakeFrigate has no such media'); + await expect( + resolveMedia(hass, createContentID('one', 'clips', { clientID: 'other' })), + ).rejects.toThrow('FakeFrigate has no such media'); + }); + + it('should honor set media URL', async () => { + const { hass, frigate } = createFrigate([createEvent('one')]); + + frigate.setMediaURL('one', 'clips', '/somewhere-else.webm'); + + expect((await resolveMedia(hass, createContentID('one', 'clips'))).url).toBe( + '/somewhere-else.webm', + ); + + // The snapshot is a separate media item and is left where it was. + expect( + (await resolveMedia(hass, createContentID('one', 'snapshots'))).url, + ).toContain('still-red.png'); + }); + }); + + describe('thumbnails', () => { + it('should serve a picture for an event', async () => { + const { hass } = createFrigate([createEvent('one')]); + + const response = await hass.getHASS().fetchWithAuth(createThumbnailPath('one')); + + expect(response.ok).toBe(true); + expect(response.headers.get('Content-Type')).toContain('image/png'); + }); + + it('should refuse a thumbnail for an event it does not have', async () => { + const { hass } = createFrigate([createEvent('one')]); + + await expect( + hass.getHASS().fetchWithAuth(createThumbnailPath('two')), + ).rejects.toThrow('FakeFrigate has no thumbnail at'); + }); + }); +}); diff --git a/tests/browser/fake-frigate.ts b/tests/browser/fake-frigate.ts new file mode 100644 index 00000000..365642e4 --- /dev/null +++ b/tests/browser/fake-frigate.ts @@ -0,0 +1,402 @@ +import { fromUnixTime } from 'date-fns'; +import type { MessageBase } from 'home-assistant-js-websocket'; + +import type { NativeFrigateEventQuery } from '../../src/camera-manager/frigate/requests'; +import type { EventSummary, FrigateEvent } from '../../src/camera-manager/frigate/types'; +import type { PartialAdvancedCameraCardConfig } from '../../src/config/types'; +import type { ResolvedMedia } from '../../src/ha/types'; +import { createFrigateEvent } from '../test-utils'; +import type { FakeHASS, WSCommandHandler } from './fake-hass'; +import { + CLIP_FIXTURE_FILENAME, + createFixtureURL, + SNAPSHOT_FIXTURE_FILENAME, +} from './fixtures'; +import { MountedCardFactory, type MountedCard } from './mounted-card'; +import { + CAMERA_ENTITY, + createCameraHASS, + createStillImageCardConfig, + type FakeCameraDescription, +} from './test-utils'; + +export const FRIGATE_CLIENT_ID = 'frigate'; +export type FrigateMediaType = 'clips' | 'snapshots'; +const FRIGATE_CONFIG_ENTRY_ID = 'frigate-config-entry'; + +/** + * Frigate's name for a test camera, which this harness keeps equal to the + * entity's object ID. + */ +export const getTestFrigateCameraName = (cameraEntity: string): string => + cameraEntity.split('.')[1]; + +/** + * A camera belonging to Frigate. + */ +export const createFrigateCameraDescription = ( + entityID: string = CAMERA_ENTITY, +): FakeCameraDescription => ({ + entityID, + entity: { state: 'idle', attributes: { client_id: FRIGATE_CLIENT_ID } }, + registry: { + platform: 'frigate', + unique_id: `${FRIGATE_CLIENT_ID}:camera:${getTestFrigateCameraName(entityID)}`, + config_entry_id: FRIGATE_CONFIG_ENTRY_ID, + }, +}); + +// Frigate timestamps are UNIX seconds. Two of them, so a test can have an event +// on each side of the other. +export const EVENT_TIME_OLDER = 1754300000; +export const EVENT_TIME_NEWER = 1754310000; + +const EVENT_DURATION_SECONDS = 10; + +export const createTestFrigateEvent = ( + id: string, + startTime: number, + event?: Partial, +): FrigateEvent => + createFrigateEvent({ + camera: getTestFrigateCameraName(CAMERA_ENTITY), + id, + start_time: startTime, + end_time: startTime + EVENT_DURATION_SECONDS, + ...event, + }); + +export interface CardWithFrigate { + card: MountedCard; + frigate: FakeFrigate; +} + +/** + * A card whose camera is Frigate's, with the given events already detected. + */ +export const mountCardWithFrigate = async ( + events: FrigateEvent[], + config?: PartialAdvancedCameraCardConfig, +): Promise => { + const hass = createCameraHASS([createFrigateCameraDescription()]); + const frigate = new FakeFrigate(hass); + frigate.setEvents(events); + + const card = await MountedCardFactory.createFromSource( + createStillImageCardConfig(config), + hass, + ); + + return { card, frigate }; +}; + +// An event's media content ID, as `getEventMediaContentID` builds it: +// media-source://frigate//event/// +const EVENT_CONTENT_ID = + /^media-source:\/\/frigate\/(?[^/]+)\/event\/(?[^/]+)\/(?[^/]+)\/(?[^/]+)$/; + +// An event's thumbnail, as `getEventThumbnailURL` asks for it: +// /api/frigate//thumbnail/ +const THUMBNAIL_PATH = + /^\/api\/frigate\/(?[^/]+)\/thumbnail\/(?[^/]+)$/; + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === 'string'); + +const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'; + +const isNumber = (value: unknown): value is number => typeof value === 'number'; + +// Read a request parameter, refusing a value of the wrong type. +const readParameter = ( + message: MessageBase, + name: string, + isExpected: (value: unknown) => value is T, + expected: string, +): T | undefined => { + const value: unknown = message[name]; + if (value === undefined) { + return undefined; + } + if (!isExpected(value)) { + throw new Error( + `FakeFrigate was sent a '${name}' that is not ${expected}: ` + + JSON.stringify(value), + ); + } + return value; +}; + +const EVERY_REQUEST_PARAMETERS = ['type']; + +// Refuse a request carrying a parameter this Frigate does not recognize. +const requireKnownParameters = (message: MessageBase, parameters: string[]): void => { + const known = [...EVERY_REQUEST_PARAMETERS, ...parameters]; + const unknown = Object.keys(message).filter((name) => !known.includes(name)); + + if (unknown.length) { + throw new Error(`FakeFrigate was sent unknown parameters: ${unknown}`); + } +}; + +// Frigate keeps every sub label an event has in one comma separated string. +const getSubLabels = (event: FrigateEvent): string[] => + event.sub_label?.split(',').map((subLabel) => subLabel.trim()) ?? []; + +// The day an event falls on. Always UTC, the only time zone a `FakeHASS` reports, +// so `formatDate` cannot be used: it renders in local time. +const getEventDay = (event: FrigateEvent): string => + fromUnixTime(event.start_time).toISOString().slice(0, 'YYYY-MM-DD'.length); + +interface FrigateMediaReference { + clientID: string; + mediaType: FrigateMediaType; + camera: string; + eventID: string; +} + +// The event media a request is asking for, read out of its media content ID. +const parseContentID = (contentID: string): FrigateMediaReference | null => { + const groups = EVENT_CONTENT_ID.exec(contentID)?.groups; + if (!groups) { + return null; + } + + const mediaType = groups['mediaType']; + if (mediaType !== 'clips' && mediaType !== 'snapshots') { + return null; + } + + return { + clientID: groups['clientID'], + mediaType, + camera: groups['camera'], + eventID: groups['eventID'], + }; +}; + +// The instance is separate from the actual query. +type EventQuery = Omit; + +// Read the whole query before applying any of it. A request carrying nonsense is +// then refused even when no event would have matched anyway. +const readEventQuery = (message: MessageBase): EventQuery => { + const getList = (name: string): string[] | undefined => + readParameter(message, name, isStringArray, 'a list of strings'); + const getBoolean = (name: string): boolean | undefined => + readParameter(message, name, isBoolean, 'true or false'); + const getCount = (name: string): number | undefined => + readParameter(message, name, isNumber, 'a number'); + + return { + cameras: getList('cameras'), + labels: getList('labels'), + sub_labels: getList('sub_labels'), + zones: getList('zones'), + after: getCount('after'), + before: getCount('before'), + favorites: getBoolean('favorites'), + has_clip: getBoolean('has_clip'), + has_snapshot: getBoolean('has_snapshot'), + limit: getCount('limit'), + }; +}; + +// Whether an event is one the request asked for. +const matchesEventQuery = (event: FrigateEvent, query: EventQuery): boolean => + (!query.cameras || query.cameras.includes(event.camera)) && + (!query.labels || query.labels.includes(event.label)) && + (!query.sub_labels || + getSubLabels(event).some((subLabel) => query.sub_labels?.includes(subLabel))) && + (!query.zones || query.zones.some((zone) => event.zones.includes(zone))) && + // An event that began before the period and was still running when it started + // counts as falling within it. + (query.after === undefined || (event.end_time ?? Infinity) >= query.after) && + (query.before === undefined || event.start_time <= query.before) && + (!query.favorites || !!event.retain_indefinitely) && + (!query.has_clip || event.has_clip) && + (!query.has_snapshot || event.has_snapshot); + +/** + * A Frigate instance behind a `FakeHASS`. Holds the events a test wants a camera + * to have detected, and answers what the card asks about them. + * + * Any missing functionality returns an error. + */ +export class FakeFrigate { + private _events: FrigateEvent[] = []; + private _mediaURLs = new Map(); + + constructor(hass: FakeHASS) { + hass.registerCommand( + 'frigate/events/get', + this._answerAsFrigate( + [ + 'after', + 'before', + 'cameras', + 'favorites', + 'has_clip', + 'has_snapshot', + 'labels', + 'limit', + 'sub_labels', + 'zones', + ], + (message) => this._queryEvents(message), + ), + ); + hass.registerCommand( + 'frigate/events/summary', + this._answerAsFrigate(['timezone'], () => this._summariseEvents()), + ); + + // This Frigate keeps no recordings and cannot be given any. The media filter + // and the viewer's seek still ask, and an unanswered request is an error. + hass.registerCommand( + 'frigate/recordings/summary', + this._answerAsFrigate(['camera', 'timezone'], () => []), + ); + hass.registerCommand( + 'frigate/recordings/get', + this._answerAsFrigate(['after', 'before', 'camera'], () => []), + ); + + // Fake Frigate has no review items and cannot be given any. The live view + // asks for them on startup, so leaving this unanswered would raise a media + // query issue on every card. + hass.registerCommand( + 'frigate/reviews/get', + this._answerAsFrigate( + [ + 'after', + 'before', + 'cameras', + 'labels', + 'limit', + 'reviewed', + 'severity', + 'zones', + ], + () => [], + ), + ); + hass.registerCommand( + 'frigate/ptz/info', + this._answerAsFrigate(['camera'], () => ({})), + ); + hass.registerPath(THUMBNAIL_PATH, (path) => this._serveThumbnail(path)); + + hass.registerMediaSource(EVENT_CONTENT_ID, (contentID) => + this._resolveMedia(contentID), + ); + } + + /** + * The events this Frigate instance has. Held newest first, as Frigate returns them. + */ + public setEvents(events: FrigateEvent[]): void { + this._events = [...events].sort((a, b) => b.start_time - a.start_time); + } + + /** + * Set the URL for a media item. + */ + public setMediaURL(eventID: string, mediaType: FrigateMediaType, url: string): void { + this._mediaURLs.set(this._getMediaKey(eventID, mediaType), url); + } + + // Answer a command addressed to this instance, refusing one meant for + // another. Frigate answers with JSON. + private _answerAsFrigate( + parameters: string[], + handler: (message: MessageBase) => unknown, + ): WSCommandHandler { + return (message: MessageBase): string => { + const instanceID: unknown = message['instance_id']; + if (instanceID !== FRIGATE_CLIENT_ID) { + throw new Error( + `FakeFrigate was asked for another instance: ${String(instanceID)}`, + ); + } + requireKnownParameters(message, ['instance_id', ...parameters]); + + return JSON.stringify(handler(message)); + }; + } + + private _getMediaKey(eventID: string, mediaType: FrigateMediaType): string { + return `${eventID}/${mediaType}`; + } + + private _getEvent(eventID: string): FrigateEvent | null { + return this._events.find((event) => event.id === eventID) ?? null; + } + + private _queryEvents(message: MessageBase): FrigateEvent[] { + const query = readEventQuery(message); + const matching = this._events.filter((event) => matchesEventQuery(event, query)); + + return query.limit === undefined ? matching : matching.slice(0, query.limit); + } + + // What the media filter offers to filter by: every camera, day, label and zone + // combination the events cover. + private _summariseEvents(): EventSummary { + const summaries = new Map(); + + for (const event of this._events) { + const summary = { + camera: event.camera, + day: getEventDay(event), + label: event.label, + sub_label: event.sub_label, + zones: event.zones, + }; + summaries.set(JSON.stringify(summary), summary); + } + + return [...summaries.values()]; + } + + // Where an event's media is served from. The URL names the event, so a test can + // tell which media is on screen without looking at the picture. + private _resolveMedia(contentID: string): ResolvedMedia { + const media = parseContentID(contentID); + const event = media ? this._getEvent(media.eventID) : null; + const isClip = media?.mediaType === 'clips'; + + if ( + !media || + !event || + media.clientID !== FRIGATE_CLIENT_ID || + media.camera !== event.camera || + !(isClip ? event.has_clip : event.has_snapshot) + ) { + throw new Error(`FakeFrigate has no such media: ${contentID}`); + } + + const filename = isClip ? CLIP_FIXTURE_FILENAME : SNAPSHOT_FIXTURE_FILENAME; + + return { + url: + this._mediaURLs.get(this._getMediaKey(media.eventID, media.mediaType)) ?? + `${createFixtureURL(filename)}?event=${media.eventID}`, + mime_type: isClip ? 'video/webm' : 'image/png', + }; + } + + private async _serveThumbnail(path: string): Promise { + const groups = THUMBNAIL_PATH.exec(path)?.groups; + + if ( + !groups || + groups['clientID'] !== FRIGATE_CLIENT_ID || + !this._getEvent(groups['eventID']) + ) { + throw new Error(`FakeFrigate has no thumbnail at: ${path}`); + } + + return await fetch(createFixtureURL(SNAPSHOT_FIXTURE_FILENAME)); + } +} diff --git a/tests/browser/fake-hass.test.ts b/tests/browser/fake-hass.test.ts index 2bad579b..a0c18955 100644 --- a/tests/browser/fake-hass.test.ts +++ b/tests/browser/fake-hass.test.ts @@ -148,6 +148,70 @@ describe('FakeHASS', () => { }); }); + describe('authenticated fetches', () => { + it('should answer a registered path', async () => { + const hass = createFakeHASS(); + hass.registerPath(/^\/api\/thumbnail\/.+$/, (path) => new Response(path)); + + const response = await hass.getHASS().fetchWithAuth('/api/thumbnail/1'); + + await expect(response.text()).resolves.toBe('/api/thumbnail/1'); + }); + + it('should reject an unregistered path', async () => { + const hass = createFakeHASS(); + + await expect(hass.getHASS().fetchWithAuth('/api/thumbnail/1')).rejects.toThrow( + 'FakeHASS received a request for an unregistered path: /api/thumbnail/1', + ); + }); + }); + + describe('media sources', () => { + const resolveMedia = async (hass: FakeHASS, contentID: unknown): Promise => + await hass + .getHASS() + .callWS({ type: 'media_source/resolve_media', media_content_id: contentID }); + + it('should resolve a content ID with the source that claims it', async () => { + const hass = createFakeHASS(); + hass.registerMediaSource(/^media-source:\/\/other\//, () => ({ + url: '/other.mp4', + mime_type: 'video/mp4', + })); + hass.registerMediaSource(/^media-source:\/\/mine\//, (contentID) => ({ + url: `/mine.mp4?id=${contentID}`, + mime_type: 'video/mp4', + })); + + // Demonstrate that two sources coexist. + expect(await resolveMedia(hass, 'media-source://mine/1')).toEqual({ + url: '/mine.mp4?id=media-source://mine/1', + mime_type: 'video/mp4', + }); + expect(await resolveMedia(hass, 'media-source://other/1')).toEqual({ + url: '/other.mp4', + mime_type: 'video/mp4', + }); + }); + + it('should reject a content ID no source claims', async () => { + const hass = createFakeHASS(); + + await expect(resolveMedia(hass, 'media-source://mine/1')).rejects.toThrow( + 'FakeHASS has no media source for: media-source://mine/1', + ); + }); + + it('should reject a request carrying no content ID', async () => { + const hass = createFakeHASS(); + + await expect(resolveMedia(hass, undefined)).rejects.toThrow( + 'FakeHASS was asked to resolve media without a content ID', + ); + }); + }); + describe('unimplemented methods', () => { it('should throw rather than quietly do nothing', () => { const hass = createFakeHASS().getHASS(); diff --git a/tests/browser/fake-hass.ts b/tests/browser/fake-hass.ts index 9133f3dc..c2e8ee84 100644 --- a/tests/browser/fake-hass.ts +++ b/tests/browser/fake-hass.ts @@ -9,13 +9,22 @@ import { import { mock } from 'vitest-mock-extended'; import type { Entity } from '../../src/ha/registry/entity/types'; -import type { HomeAssistant } from '../../src/ha/types'; +import type { HomeAssistant, ResolvedMedia } from '../../src/ha/types'; import { createRegistryEntity, createStateEntity } from '../test-utils'; // A WebSocket command handler. Returning a rejected promise models a command // Home Assistant refuses; throwing models a malformed request. export type WSCommandHandler = (message: MessageBase) => Promise | unknown; +// A handler for an authenticated HTTP request. +export type PathHandler = (path: string) => Promise | Response; + +// A handler that turns a media content ID into something playable (i.e. HA +// media source). +export type MediaSourceHandler = ( + contentID: string, +) => Promise | ResolvedMedia; + export interface FakeEntityOptions { state?: string; attributes?: Record; @@ -100,6 +109,8 @@ export class FakeHASS { private _language: string; private _isAdmin: boolean; private _handlers = new Map(); + private _pathHandlers: { pattern: RegExp; handler: PathHandler }[] = []; + private _mediaSources: { pattern: RegExp; handler: MediaSourceHandler }[] = []; private _commandLog: MessageBase[] = []; private _openEventSubscriptions = 0; @@ -130,6 +141,22 @@ export class FakeHASS { this._handlers.set(type, handler); } + /** + * Register a handler for the paths an authenticated fetch may ask for. The + * first pattern registered that matches answers. + */ + public registerPath(pattern: RegExp, handler: PathHandler): void { + this._pathHandlers.push({ pattern, handler }); + } + + /** + * Register a handler for the media content IDs an integration can resolve. The + * first pattern registered that matches answers. + */ + public registerMediaSource(pattern: RegExp, handler: MediaSourceHandler): void { + this._mediaSources.push({ pattern, handler }); + } + /** * Number of event subscriptions not yet released. */ @@ -240,12 +267,30 @@ export class FakeHASS { ...this._registry.values(), ]); this.registerCommand('lovelace/resources', () => []); + this.registerCommand('media_source/resolve_media', (message) => + this._resolveMedia(message), + ); // Not a card command: `ha-nunjucks` fetches the label registry once when a // template first renders. this.registerCommand('config/label_registry/list', () => []); } + private async _resolveMedia(message: MessageBase): Promise { + const contentID: unknown = message['media_content_id']; + if (typeof contentID !== 'string') { + throw new Error( + `FakeHASS was asked to resolve media without a content ID: ${JSON.stringify(contentID)}`, + ); + } + + const match = this._mediaSources.find(({ pattern }) => pattern.test(contentID)); + if (!match) { + throw new Error(`FakeHASS has no media source for: ${contentID}`); + } + return await match.handler(contentID); + } + private async _callWS(message: MessageBase): Promise { this._commandLog.push(message); const handler = this._handlers.get(message.type); @@ -261,6 +306,18 @@ export class FakeHASS { return (await handler(message)) as unknown as T; } + private _hassUrl(path?: string): string { + return new URL(path ?? '/', window.location.href).href; + } + + private async _fetchWithAuth(path: string): Promise { + const match = this._pathHandlers.find(({ pattern }) => pattern.test(path)); + if (!match) { + throw new Error(`FakeHASS received a request for an unregistered path: ${path}`); + } + return await match.handler(path); + } + private _unsupported(name: string): () => never { return () => { throw new Error(`FakeHASS does not implement ${name}`); @@ -300,15 +357,15 @@ export class FakeHASS { }, callWS: (message) => this._callWS(message), - hassUrl: (path?: string) => new URL(path ?? '/', window.location.href).href, + hassUrl: (path?: string) => this._hassUrl(path), localize: (key: string) => key, + fetchWithAuth: (path: string) => this._fetchWithAuth(path), // Everything the card can call has to either work or fail loudly. A // method that quietly returns nothing would let a card start depending on // it without any test noticing. callService: this._unsupported('callService'), callApi: this._unsupported('callApi'), - fetchWithAuth: this._unsupported('fetchWithAuth'), sendWS: this._unsupported('sendWS'), }; } diff --git a/tests/browser/fixtures.ts b/tests/browser/fixtures.ts new file mode 100644 index 00000000..b3ede94c --- /dev/null +++ b/tests/browser/fixtures.ts @@ -0,0 +1,14 @@ +// The media the browser tests use, served by the Vite dev server from where it +// sits in the tree. Two consumers build URLs to it and a third serves copies of +// it (`test-media.ts`), so the path and the names are kept in one place. +const FIXTURES_PATH = '/tests/browser/fixtures'; + +// A still red image, standing in for anything a camera hands over as a picture: +// a snapshot, or a live view drawn from stills. +export const SNAPSHOT_FIXTURE_FILENAME = 'still-red.png'; + +// Ten seconds of red, standing in for an event's clip. See fixtures/README.md . +export const CLIP_FIXTURE_FILENAME = 'clip.webm'; + +export const createFixtureURL = (filename: string): string => + `${FIXTURES_PATH}/${filename}`; diff --git a/tests/browser/fixtures/README.md b/tests/browser/fixtures/README.md new file mode 100644 index 00000000..68b4c7f3 --- /dev/null +++ b/tests/browser/fixtures/README.md @@ -0,0 +1,36 @@ +# Browser test fixtures + +The media the browser tests serve to the card, at the paths and names built by +`tests/browser/fixtures.ts`. Most requests reach them directly from the Vite dev +server. A test that needs a camera to misbehave gets copies from +`tests/browser/test-media.ts` instead. + +## `still-red.png` + +A 320x180 red image, 16:9. Everything the card draws as a still image: a +snapshot in the viewer, or a live view built from stills. + +## `clip.webm` + +Ten seconds of red at 64x48, VP8. Ten seconds so a test can watch it play +without it ending under the assertion. + +WebM rather than the MP4 (what a real camera integration likely serves), because +nothing available offline encodes H.264: Playwright's bundled `ffmpeg` has +libvpx only. This does not change what is under test -- every non-HLS video +takes the same branch. + +Rebuild it with ImageMagick and the `ffmpeg` Playwright installs alongside its +browsers: + +```sh +convert tests/browser/fixtures/still-red.png -resize 64x48! /tmp/frame.jpg + +for i in $(seq 100); +do + cat /tmp/frame.jpg; +done > /tmp/frames.mjpeg + +~/.cache/ms-playwright/ffmpeg-*/ffmpeg-linux -f image2pipe -vcodec mjpeg -r 10 \ + -i /tmp/frames.mjpeg -c:v libvpx -b:v 50k tests/browser/fixtures/clip.webm +``` diff --git a/tests/browser/fixtures/clip.webm b/tests/browser/fixtures/clip.webm new file mode 100644 index 00000000..8bac6f99 Binary files /dev/null and b/tests/browser/fixtures/clip.webm differ diff --git a/tests/browser/scoped-custom-element-registry.ts b/tests/browser/scoped-custom-element-registry.ts new file mode 100644 index 00000000..d42526d0 --- /dev/null +++ b/tests/browser/scoped-custom-element-registry.ts @@ -0,0 +1,11 @@ +// The card renders a few elements that give their shadow root a registry of its +// own (`ScopedRegistryHost` from `@lit-labs/scoped-registry-mixin`, used by the +// media filter and the select). That is not something a browser can do +// natively: it needs this polyfill, which Home Assistant loads for the whole +// frontend. Without it, rendering one of those elements throws "importNode is +// not a function" as Lit tries to clone a template into a registry that has no +// document behind it. +// +// It replaces `customElements` and `attachShadow`, so it has to be the first +// thing the page runs -- hence its own setup file, ahead of every other. +import '@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min'; diff --git a/tests/browser/test-media.ts b/tests/browser/test-media.ts index 3dd05528..5363685d 100644 --- a/tests/browser/test-media.ts +++ b/tests/browser/test-media.ts @@ -2,8 +2,13 @@ import { delay, http, HttpResponse } from 'msw'; import { setupWorker } from 'msw/browser'; import { beforeAll } from 'vitest'; +import { createFixtureURL, SNAPSHOT_FIXTURE_FILENAME } from './fixtures'; + const HTTP_OK = 200; +// Where this worker answers. +const TEST_MEDIA_PATH = '/test-media'; + // Requests answered per token, so `responses` can be read as a sequence. One // page runs one test file, so nothing here is shared with another file. const requestCounts = new Map(); @@ -12,7 +17,32 @@ const requestCounts = new Map(); // the worker can answer cannot be built without it. let inUse = false; -export const isTestMediaInUse = (): boolean => inUse; +/** + * See the worker below for what `responses` and `repeat` ask it to do. + */ +export const createTestMediaURL = ( + responses: number[], + repeat = false, + filename: string = SNAPSHOT_FIXTURE_FILENAME, +): string => { + if (!inUse) { + throw new Error( + 'Media that misbehaves must be served in a file using useTestMedia().', + ); + } + + return ( + // TEST_MEDIA_PATH is intercepted by MSW to allow off-wire testing -- + // without the potential for consuming connections which the browser might + // need for loading JS itself. + `${TEST_MEDIA_PATH}/${filename}?` + + new URLSearchParams({ + token: crypto.randomUUID(), + responses: responses.join(','), + repeat: String(repeat), + }).toString() + ); +}; /** * Serves a fixture at `/test-media/`, behaving as the query asks: @@ -21,9 +51,9 @@ export const isTestMediaInUse = (): boolean => inUse; * behaviour does not depend on what ran before it. * responses The status to answer each request with, in order: `200` serves * the file and anything else is sent as an empty error. - * repeat What to do once the `responses` list is exhausted: - * answer every request after it as the last one was, or never - * answer again (i.e. camera going quiet). + * repeat What to do once the `responses` list is exhausted: answer every + * request after it as the last one was, or never answer again + * (i.e. camera going quiet). * * Answered from within the page rather than by a server, because a request the * page is still waiting on holds one of the handful of connections a browser @@ -37,7 +67,7 @@ export const isTestMediaInUse = (): boolean => inUse; * it would not. */ const worker = setupWorker( - http.get('/test-media/:file', async ({ request, params }) => { + http.get(`${TEST_MEDIA_PATH}/:file`, async ({ request, params }) => { const url = new URL(request.url); const token = url.searchParams.get('token'); if (!token) { @@ -64,7 +94,7 @@ const worker = setupWorker( // The fixture itself is served by the dev server. A name is sent rather // than a path, so nothing outside the fixtures themselves is reachable. - const fixture = await fetch(`/tests/browser/fixtures/${String(params.file)}`); + const fixture = await fetch(createFixtureURL(String(params.file))); return fixture.ok ? new HttpResponse(await fixture.arrayBuffer(), { diff --git a/tests/browser/test-utils.ts b/tests/browser/test-utils.ts index be1ad92a..da6a8ec2 100644 --- a/tests/browser/test-utils.ts +++ b/tests/browser/test-utils.ts @@ -1,19 +1,24 @@ import { userEvent } from 'vitest/browser'; -import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; +import type { + PartialAdvancedCameraCardConfig, + RawAdvancedCameraCardConfig, +} from '../../src/config/types'; +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 { FakeHASS, type FakeEntityOptions } from './fake-hass'; -import { isTestMediaInUse } from './test-media'; +import { createFixtureURL, SNAPSHOT_FIXTURE_FILENAME } from './fixtures'; +import type { MountedCard } from './mounted-card'; +import { createTestMediaURL } from './test-media'; -export const STILL_CAMERA_ENTITY = 'camera.office'; - -const STILL_FIXTURE_FILENAME = 'still-red.png'; +export const CAMERA_ENTITY = 'camera.office'; // A same-origin still red image, served by the Vite dev server. The same image // is handed on by the worker in test-media.ts, which can be asked to misbehave // in useful ways. -const STILL_FIXTURE_URL = `/tests/browser/fixtures/${STILL_FIXTURE_FILENAME}`; +const STILL_FIXTURE_URL = createFixtureURL(SNAPSHOT_FIXTURE_FILENAME); /** * A card showing one still image and nothing else: no stream, no transport and @@ -24,7 +29,7 @@ const STILL_FIXTURE_URL = `/tests/browser/fixtures/${STILL_FIXTURE_FILENAME}`; * configuration error instead of the image. */ export const createStillImageCameraConfig = ( - cameraEntity: string = STILL_CAMERA_ENTITY, + cameraEntity: string = CAMERA_ENTITY, url: string = STILL_FIXTURE_URL, ): RawAdvancedCameraCardConfig => ({ camera_entity: cameraEntity, @@ -39,62 +44,53 @@ export const createStillImageCameraConfig = ( const HTTP_NOT_FOUND = 404; const HTTP_OK = 200; -/** - * A media URL answered with the given statuses in order. Once they run out - * every request after them is answered as the last one was, or, if the camera - * is meant to go quiet, never answered at all. - * - * Every URL carries its own counter, since one worker serves every test in a - * file and a shared counter would make a test depend on what ran before it. - */ -const createMediaURL = (responses: number[], repeat = false): string => { - if (!isTestMediaInUse()) { - throw new Error( - 'Media that misbehaves must be served in a file using useTestMedia().', - ); - } - - return ( - `/test-media/${STILL_FIXTURE_FILENAME}?` + - new URLSearchParams({ - token: crypto.randomUUID(), - responses: responses.join(','), - repeat: String(repeat), - }).toString() - ); -}; - /** * 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): string => - createMediaURL([...Array(failures).fill(HTTP_NOT_FOUND), HTTP_OK], true); +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 => - createMediaURL([HTTP_NOT_FOUND], true); + 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 => createMediaURL([]); +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 = (): string => createMediaURL([HTTP_OK]); +export const createStallingMediaURL = (filename?: string): string => + createTestMediaURL([HTTP_OK], false, filename); + +export interface FakeCameraDescription { + entityID: string; + entity: FakeEntityOptions; + registry: Partial; +} + +const createGenericCameraDescription = ( + entityID: string = CAMERA_ENTITY, +): FakeCameraDescription => ({ + entityID, + entity: { state: 'idle' }, + registry: {}, +}); export interface CameraHASSOptions { - // Camera entities beyond the default one. - cameras?: string[]; - - // Anything else the card should be able to see, as entity ID to state. + // Anything that is not a camera the card should be able to see, such as a + // motion sensor. These get a state and no entity registry entry. entities?: Record; // The language Home Assistant is set to for translation tests. @@ -105,22 +101,48 @@ export interface CameraHASSOptions { * A Home Assistant holding the cameras a card is about to be given, which is * the minimum any browser test needs before it can mount anything. */ -export const createCameraHASS = (options?: CameraHASSOptions): FakeHASS => { - const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])]; - - return new FakeHASS({ +export const createCameraHASS = ( + cameras: FakeCameraDescription[], + options?: CameraHASSOptions, +): FakeHASS => + new FakeHASS({ entities: { - ...Object.fromEntries(cameras.map((camera) => [camera, { state: 'idle' }])), + ...Object.fromEntries(cameras.map((camera) => [camera.entityID, camera.entity])), ...options?.entities, }, - registry: Object.fromEntries(cameras.map((camera) => [camera, {}])), + registry: Object.fromEntries( + cameras.map((camera) => [camera.entityID, camera.registry]), + ), ...(options?.language && { language: options.language }), }); + +export interface GenericCameraHASSOptions extends CameraHASSOptions { + // Camera entities beyond `CAMERA_ENTITY`, which is always present. Each gets + // a state and an entity registry entry, which is what the card reads to + // resolve a camera and choose its engine. + cameras?: string[]; +} + +/** + * A Home Assistant whose cameras all belong to no named integration, for a test + * that is about the card rather than about where its media comes from. + */ +export const createGenericCameraHASS = ( + options?: GenericCameraHASSOptions, +): FakeHASS => { + const { cameras, ...hassOptions } = options ?? {}; + + return createCameraHASS( + [CAMERA_ENTITY, ...(cameras ?? [])].map((camera) => + createGenericCameraDescription(camera), + ), + hassOptions, + ); }; export const createStillImageCardConfig = ( - overrides?: Partial, -): RawAdvancedCameraCardConfig => ({ + overrides?: PartialAdvancedCameraCardConfig, +): PartialAdvancedCameraCardConfig => ({ type: 'custom:advanced-camera-card', cameras: [createStillImageCameraConfig()], @@ -224,6 +246,149 @@ export const isLiveMediaShowing = (root: ParentNode): boolean => (provider) => !!deepQuery(provider, MEDIA_SELECTOR), ); +/** + * Wait until the card has been told an element is on screen. + * + * Parts of the card only begin work once an element becomes visible, being + * notified by an `IntersectionObserver`. A test cannot reach those observers, + * but it can make one of its own: a document notifies its observers in the + * order they were created, so one created after the card's is strictly called + * after them. As such, by the time this function reports the element, the + * card's intersection observer callback has already run and whatever it started + * is deterministically under way. + * + * Name the element the card is itself watching, e.g. + * `advanced-camera-card-live-provider` for liveness detection. It is waited for + * rather than taken as an element, since the card makes its observer when the + * element connects: observing beforehand would make this the earlier of the two + * and run the ordering above the other way. + */ +export const waitUntilObservedVisible = async ( + card: MountedCard, + selector: string, +): Promise => { + const element = await card.waitForSelector(selector); + + await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(element); + }); +}; + +/** + * Where a media element is being served from. + */ +const getMediaURL = (media: Element): string | null => { + if (media instanceof HTMLImageElement || media instanceof HTMLMediaElement) { + return media.currentSrc || media.getAttribute('src') || null; + } + return null; +}; + +/** + * Every media the media viewer has loaded, in the order it rendered them. The + * viewer holds a provider per media item and each loads once it has been on + * screen, so this is one entry per media a test has visited. + */ +export const getMediaViewerMediaURLs = (root: ParentNode): string[] => + deepQueryAll(root, 'advanced-camera-card-viewer-provider') + .map((provider) => deepQuery(provider, MEDIA_SELECTOR)) + .map((media) => (media ? getMediaURL(media) : null)) + .filter((url) => url !== null); + +/** + * The thumbnails on screen. + */ +export const getThumbnails = (root: ParentNode): HTMLElement[] => + deepQueryAll(root, 'advanced-camera-card-thumbnail'); + +export const getSelectedThumbnail = (root: ParentNode): HTMLElement | null => + deepQuery(root, 'advanced-camera-card-thumbnail.slide-selected'); + +export const clickThumbnail = async (root: ParentNode, index: number): Promise => { + const thumbnail = getThumbnails(root)[index]; + if (!thumbnail) { + throw new Error(`There is no thumbnail at index ${index} to click`); + } + await clickElement(thumbnail); +}; + +export const waitForThumbnails = async ( + card: MountedCard, + count: number, +): Promise => { + await card.waitForRender( + () => (getThumbnails(card.card).length >= count ? true : null), + `${count} thumbnail(s)`, + ); +}; + +/** + * Wait until the media viewer has loaded a media whose URL contains the given + * text, which is how a test waits for the media it asked for to arrive. + */ +export const waitForMediaViewerMedia = async ( + card: MountedCard, + url: string, +): Promise => { + await card.waitForRender( + () => + getMediaViewerMediaURLs(card.card).some((shown) => shown.includes(url)) || null, + `the media viewer showing ${url}`, + ); +}; + +/** + * Move the media viewer to the next or previous media. + * + * A next/previous control has no size of its own: what is drawn and positioned + * is within it, so that is what a real pointer can reach. + */ +export const clickNextPreviousMedia = async ( + root: ParentNode, + side: 'left' | 'right', +): Promise => { + const control = deepQuery( + root, + `advanced-camera-card-next-previous-control[slot="${side}"]`, + ); + const clickable = control ? deepQuery(control, '.controls') : null; + if (!clickable) { + throw new Error(`The media viewer is showing no ${side} control`); + } + await clickElement(clickable); +}; + +// Everything the status bar is displaying gets this class, whether it is a +// string, an icon or an image. +const STATUS_BAR_ITEM_SELECTOR = '.item'; + +const getStatusBarItems = (root: ParentNode): Element[] => [ + ...(deepQuery(root, 'advanced-camera-card-status-bar')?.shadowRoot?.querySelectorAll( + STATUS_BAR_ITEM_SELECTOR, + ) ?? []), +]; + +/** + * What the status bar is displaying, one entry per item, in the order shown. + * Items with no text of their own (an icon, an image) are omitted. + */ +export const getStatusBarStrings = (root: ParentNode): string[] => + getStatusBarItems(root) + .map((item) => (item.textContent ?? '').trim()) + .filter(isTruthy); + +/** + * Get a status bar item by the title it carries. + */ +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. diff --git a/tests/camera-manager/manager.browser.test.ts b/tests/camera-manager/manager.browser.test.ts index 5d4c051b..f20d2253 100644 --- a/tests/camera-manager/manager.browser.test.ts +++ b/tests/camera-manager/manager.browser.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { - createCameraHASS, + createGenericCameraHASS, createStillImageCameraConfig, createStillImageCardConfig, getBlockNotificationText, @@ -43,7 +43,7 @@ describe('CameraManager', () => { ], view: { issues: { retry_seconds: 0 } }, }), - createCameraHASS({ + createGenericCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY], }), ); @@ -64,7 +64,7 @@ describe('CameraManager', () => { createStillImageCardConfig({ cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)], }), - createCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }), + createGenericCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }), ); await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1)); diff --git a/tests/card-controller/automations-manager.browser.test.ts b/tests/card-controller/automations-manager.browser.test.ts index defffaf2..070da35f 100644 --- a/tests/card-controller/automations-manager.browser.test.ts +++ b/tests/card-controller/automations-manager.browser.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; -import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils'; +import { + createGenericCameraHASS, + createStillImageCardConfig, +} from '../browser/test-utils'; const TRIGGER_ENTITY = 'input_boolean.zoom'; const mount = async (): Promise => { - const hass = createCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } }); + const hass = createGenericCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } }); return await MountedCardFactory.createFromSource( createStillImageCardConfig({ automations: [ diff --git a/tests/card-controller/card-element-manager.browser.test.ts b/tests/card-controller/card-element-manager.browser.test.ts index 2babb9d0..6babbbf5 100644 --- a/tests/card-controller/card-element-manager.browser.test.ts +++ b/tests/card-controller/card-element-manager.browser.test.ts @@ -4,7 +4,7 @@ import { createLogAction } from '../../src/utils/action'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, - createCameraHASS, + createGenericCameraHASS, createInitializedAutomation, createStillImageCardConfig, deepQueryAll, @@ -28,7 +28,7 @@ const mountCard = async (): Promise => { }, ], }), - createCameraHASS(), + createGenericCameraHASS(), ); await card.events.waitForFirst('advanced-camera-card:media:loaded'); diff --git a/tests/card-controller/initialization/session-manager.browser.test.ts b/tests/card-controller/initialization/session-manager.browser.test.ts index 78511053..39faddbe 100644 --- a/tests/card-controller/initialization/session-manager.browser.test.ts +++ b/tests/card-controller/initialization/session-manager.browser.test.ts @@ -4,7 +4,7 @@ import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, - createCameraHASS, + createGenericCameraHASS, createInitializedAutomation, createStillImageCameraConfig, createStillImageCardConfig, @@ -26,7 +26,7 @@ const mount = async ( ): Promise => await MountedCardFactory.createFromSource( createConfig(overrides), - createCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }), + createGenericCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }), ); // The cameras the card has actually loaded media for, in order. Media that diff --git a/tests/card-controller/issues/issues/initialization.browser.test.ts b/tests/card-controller/issues/issues/initialization.browser.test.ts index ea9a8586..0674b7ab 100644 --- a/tests/card-controller/issues/issues/initialization.browser.test.ts +++ b/tests/card-controller/issues/issues/initialization.browser.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, - createCameraHASS, + createGenericCameraHASS, createInitializedAutomation, createStillImageCameraConfig, createStillImageCardConfig, @@ -40,7 +40,7 @@ const mountBrokenCard = async (): Promise => view: { issues: { retry_seconds: 0 } }, automations: [createInitializedAutomation()], }), - createCameraHASS(), + createGenericCameraHASS(), ); describe('InitializationIssue', () => { 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 f5a95aa0..03c2a9d3 100644 --- a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts @@ -12,8 +12,9 @@ import { } from '../../../browser/mounted-card'; import { useTestMedia } from '../../../browser/test-media'; import { - createCameraHASS, + CAMERA_ENTITY, createFailingMediaURL, + createGenericCameraHASS, createStallingMediaURL, createStillImageCameraConfig, createStillImageCardConfig, @@ -22,9 +23,11 @@ import { deepQuery, deepQueryAll, getBlockNotificationText, + getStatusBarItem, isLiveMediaShowing, - STILL_CAMERA_ENTITY, + waitUntilObservedVisible, type CameraHASSOptions, + type GenericCameraHASSOptions, } from '../../../browser/test-utils'; const SECOND_CAMERA_ENTITY = 'camera.hallway'; @@ -38,12 +41,15 @@ const MEDIA_ISSUE_TITLE = 'Media unavailable'; const IRIS_CONTROL = 'Iris / Default View / Unhide menu'; const findIssue = (card: MountedCard): Element | null => - deepQuery(card.card, 'advanced-camera-card-status-bar')?.shadowRoot?.querySelector( - `[title="${MEDIA_ISSUE_TITLE}"]`, - ) ?? null; + getStatusBarItem(card.card, MEDIA_ISSUE_TITLE); const isIssueReported = (card: MountedCard): boolean => !!findIssue(card); +const getIssueReason = (detail: unknown): string | null => + detail && typeof detail === 'object' && 'reason' in detail + ? String(detail.reason) + : null; + // Resolve once `image` holds a picture that actually loaded. A failed load also // marks the element complete, so the decoded size is what separates the two. const waitForImageLoaded = async (image: HTMLImageElement): Promise => @@ -69,7 +75,7 @@ const waitForIssueCleared = async (card: MountedCard): Promise => { ); }; -interface MountCardOptions extends MountOptions, CameraHASSOptions {} +interface MountCardOptions extends MountOptions, GenericCameraHASSOptions {} /** * Every test here needs the status bar rendered, since that is where an issue @@ -83,7 +89,7 @@ const mountCard = async ( return await MountedCardFactory.createFromSource( createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }), - createCameraHASS({ cameras, entities, language }), + createGenericCameraHASS({ cameras, entities, language }), mountOptions, ); }; @@ -159,7 +165,7 @@ describe('MediaUnavailableIssue', () => { it('should wait out the grace period before reporting an unavailable camera', async () => { const card = await mountCardSingleCamera(); - card.setEntityState(STILL_CAMERA_ENTITY, 'unavailable'); + card.setEntityState(CAMERA_ENTITY, 'unavailable'); // Just short of the grace period: reporting here would alarm on a camera // that is about to come back. @@ -173,10 +179,10 @@ describe('MediaUnavailableIssue', () => { it('should never report a camera that recovers within the grace period', async () => { const card = await mountCardSingleCamera(); - card.setEntityState(STILL_CAMERA_ENTITY, 'unavailable'); + card.setEntityState(CAMERA_ENTITY, 'unavailable'); await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS - 1); - card.setEntityState(STILL_CAMERA_ENTITY, 'idle'); + card.setEntityState(CAMERA_ENTITY, 'idle'); // Well past the point the issue report would have appeared had the blip not // ended. Nothing should ever have been shown. @@ -357,9 +363,7 @@ describe('MediaUnavailableIssue', () => { it('should report a camera whose media fails to load', async () => { const card = await mountCard({ - cameras: [ - createStillImageCameraConfig(STILL_CAMERA_ENTITY, createFailingMediaURL()), - ], + cameras: [createStillImageCameraConfig(CAMERA_ENTITY, createFailingMediaURL())], }); // The entity is present and healthy, so the failed fetch is the only thing @@ -369,16 +373,13 @@ describe('MediaUnavailableIssue', () => { await waitForIssueReported(card); expect(getBlockNotificationText(card.card)).toContain('Could not load image'); - expect(getBlockNotificationText(card.card)).toContain(STILL_CAMERA_ENTITY); + expect(getBlockNotificationText(card.card)).toContain(CAMERA_ENTITY); }); it('should clear the issue report once the camera delivers media again', async () => { const card = await mountCard({ cameras: [ - createStillImageCameraConfig( - STILL_CAMERA_ENTITY, - createTemporarilyFailingMediaURL(1), - ), + createStillImageCameraConfig(CAMERA_ENTITY, createTemporarilyFailingMediaURL(1)), ], }); @@ -400,9 +401,7 @@ describe('MediaUnavailableIssue', () => { it('should wait out the loading timeout before reporting a slow camera', async () => { const card = await mountCard({ - cameras: [ - createStillImageCameraConfig(STILL_CAMERA_ENTITY, createUnansweredMediaURL()), - ], + cameras: [createStillImageCameraConfig(CAMERA_ENTITY, createUnansweredMediaURL())], }); // Nothing is waited on until there is a player asking for media, so let one @@ -424,7 +423,7 @@ describe('MediaUnavailableIssue', () => { it('should keep retrying a camera that is still broken', async () => { // Must use the real clock: fake time moves the card's timers instantly, so // a request would never get a chance to answer between one retry and the - // next. + // next, and the card would call the attempt slow instead of failed. vi.useRealTimers(); const card = await mountCard({ @@ -437,10 +436,7 @@ describe('MediaUnavailableIssue', () => { // the test runtime. view: { issues: { retry_seconds: 0.1 } }, cameras: [ - createStillImageCameraConfig( - STILL_CAMERA_ENTITY, - createTemporarilyFailingMediaURL(2), - ), + createStillImageCameraConfig(CAMERA_ENTITY, createTemporarilyFailingMediaURL(2)), ], }); @@ -449,15 +445,20 @@ describe('MediaUnavailableIssue', () => { await card.events.waitForCount('advanced-camera-card:issue:trigger', 2); await waitForIssueReported(card); - // The third attempt is served. + // The third attempt is served. Clearing the issue is a render that follows + // the media arriving rather than accompanying it, so it is waited for. await card.events.waitForFirst('advanced-camera-card:media:loaded'); + await waitForIssueCleared(card); - expect(isIssueReported(card)).toBe(false); expect(isLiveMediaShowing(card.card)).toBe(true); // Exactly the two attempts that failed, so the retry ran once rather than // spinning until something happened to work. - expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(2); + expect( + card.events + .getEntries('advanced-camera-card:issue:trigger') + .map((entry) => getIssueReason(entry.detail)), + ).toEqual(['not_loading', 'not_loading']); }); it('should re-attempt when the retry control is used', async () => { @@ -465,10 +466,7 @@ describe('MediaUnavailableIssue', () => { // Automatic retries switched off. view: { issues: { retry_seconds: 0 } }, cameras: [ - createStillImageCameraConfig( - STILL_CAMERA_ENTITY, - createTemporarilyFailingMediaURL(1), - ), + createStillImageCameraConfig(CAMERA_ENTITY, createTemporarilyFailingMediaURL(1)), ], }); @@ -489,9 +487,7 @@ describe('MediaUnavailableIssue', () => { it('should not report while a non-media view is showing', async () => { const card = await mountCard({ menu: { style: 'outside' }, - cameras: [ - createStillImageCameraConfig(STILL_CAMERA_ENTITY, createFailingMediaURL()), - ], + cameras: [createStillImageCameraConfig(CAMERA_ENTITY, createFailingMediaURL())], }); await card.events.waitForFirst('advanced-camera-card:issue:trigger'); @@ -517,15 +513,10 @@ describe('MediaUnavailableIssue', () => { it('should report media that stalls after it has loaded', async () => { const refreshSeconds = 2; - // Double the window the watchdog itself is using. The second half is slack: - // the watchdog begins watching in real time, so the loop below can step the - // clock a few times before that window has even begun. - const reportSecondsAllowed = (refreshSeconds + FRAME_STALL_SECONDS) * 2; - const card = await mountCard({ cameras: [ { - camera_entity: STILL_CAMERA_ENTITY, + camera_entity: CAMERA_ENTITY, live_provider: 'image', image: { mode: 'url', @@ -543,22 +534,16 @@ describe('MediaUnavailableIssue', () => { await card.events.waitForFirst('advanced-camera-card:media:loaded'); expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(0); - // Advance a second at a time rather than in one jump. The watchdog only - // starts counting once the player has begun watching for images, which - // happens in real time. A jump would spend the whole allowance before that - // point, and the counting would then start from the end of it: no stall - // would ever be reported and this test would fail. Stepping lets the player - // begin watching, after which the clock moves through a window that is - // actually being counted. - for ( - let second = 0; - second < reportSecondsAllowed && !isIssueReported(card); - second++ - ) { - await card.advanceSeconds(1); - } + // The card only starts its stall timer once the player has both loaded and + // come on screen. The load is waited for above, so waiting for the provider + // to be seen means the timer is running and the clock can be jumped through + // it. + await waitUntilObservedVisible(card, 'advanced-camera-card-live-provider'); - expect(isIssueReported(card)).toBe(true); + // A refreshing picture is allowed a whole refresh interval on top of the + // standard window, so that one slow fetch is not called a stall. + await card.advanceSeconds(refreshSeconds + FRAME_STALL_SECONDS); + await waitForIssueReported(card); // Stalled rather than failed: the picture on screen is real but frozen, and // saying so is the difference between "this is old" and "this is broken". @@ -575,15 +560,15 @@ describe('MediaUnavailableIssue', () => { status_bar: { style: 'outside' }, cameras: [ { - camera_entity: STILL_CAMERA_ENTITY, + camera_entity: CAMERA_ENTITY, live_provider: 'image', image: { mode: 'camera' }, }, ], }), - createCameraHASS({ + createGenericCameraHASS({ entities: { - [STILL_CAMERA_ENTITY]: { + [CAMERA_ENTITY]: { state: 'idle', attributes: { entity_picture: createFailingMediaURL() }, }, @@ -608,7 +593,7 @@ describe('MediaUnavailableIssue', () => { // A provider given nothing to play. It reports that it failed without // saying why, which is what a playback error is: every other reason // here is one the card was able to name. - cameras: [{ camera_entity: STILL_CAMERA_ENTITY, live_provider: 'go2rtc' }], + cameras: [{ camera_entity: CAMERA_ENTITY, live_provider: 'go2rtc' }], }); await card.events.waitForFirst('advanced-camera-card:issue:trigger'); diff --git a/tests/card-controller/keyboard-state-manager.browser.test.ts b/tests/card-controller/keyboard-state-manager.browser.test.ts index 0f00ac42..51036e0f 100644 --- a/tests/card-controller/keyboard-state-manager.browser.test.ts +++ b/tests/card-controller/keyboard-state-manager.browser.test.ts @@ -10,7 +10,7 @@ import { import { CARD_INITIALIZED_MESSAGE, clickElement, - createCameraHASS, + createGenericCameraHASS, createInitializedAutomation, createStillImageCardConfig, dispatchPointerDown, @@ -106,7 +106,7 @@ const mountCard = async (options?: MountCardOptions): Promise => { }, ], }), - createCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }), + createGenericCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }), mountOptions, ); diff --git a/tests/card-controller/style-manager.browser.test.ts b/tests/card-controller/style-manager.browser.test.ts index d16b6ddc..785e9f84 100644 --- a/tests/card-controller/style-manager.browser.test.ts +++ b/tests/card-controller/style-manager.browser.test.ts @@ -1,17 +1,21 @@ import { describe, expect, it } from 'vitest'; +import type { ThemeName } from '../../src/config/schema/view'; import { MountedCardFactory } from '../browser/mounted-card'; -import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils'; +import { + createGenericCameraHASS, + createStillImageCardConfig, +} from '../browser/test-utils'; // A colour the dark theme sets and the card carries no other way. Reading it // back proves the stylesheet reached the card rather than merely compiling. // See src/scss/themes/dark.scss . const DARK_PRIMARY_BACKGROUND = '#111111'; -const mountThemed = async (themes: string[]) => +const mountThemed = async (themes: ThemeName[]) => await MountedCardFactory.createFromSource( createStillImageCardConfig({ view: { theme: { themes } } }), - createCameraHASS(), + createGenericCameraHASS(), ); describe('themes', () => { diff --git a/tests/components/gallery/gallery.browser.test.ts b/tests/components/gallery/gallery.browser.test.ts new file mode 100644 index 00000000..becc806c --- /dev/null +++ b/tests/components/gallery/gallery.browser.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import type { FrigateEvent } from '../../../src/camera-manager/frigate/types'; +import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types'; +import { + createTestFrigateEvent, + EVENT_TIME_NEWER, + EVENT_TIME_OLDER, + mountCardWithFrigate, +} from '../../browser/fake-frigate'; +import type { MountedCard } from '../../browser/mounted-card'; +import { + clickThumbnail, + deepQuery, + getBlockNotificationText, + getMediaViewerMediaURLs, + getThumbnails, + waitForThumbnails, +} from '../../browser/test-utils'; + +const NO_MEDIA_TEXT = 'No media to display'; + +const mountCard = async ( + events: FrigateEvent[], + config?: PartialAdvancedCameraCardConfig, +): Promise => + (await mountCardWithFrigate(events, { view: { default: 'clips' }, ...config })).card; + +describe('AdvancedCameraCardGallery', () => { + it('should show a thumbnail for every event the camera detected', async () => { + const card = await mountCard([ + createTestFrigateEvent('older', EVENT_TIME_OLDER), + createTestFrigateEvent('newer', EVENT_TIME_NEWER), + ]); + + await waitForThumbnails(card, 2); + + expect(getThumbnails(card.card)).toHaveLength(2); + }); + + it('should show the picture Frigate has of each event', async () => { + const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)]); + + await waitForThumbnails(card, 1); + const thumbnail = getThumbnails(card.card)[0]; + + // A thumbnail that never arrives is drawn as an icon and nothing else says + // so, so counting thumbnails says nothing about whether there is a picture + // in them. The card fetches one with the user's credentials and embeds what + // comes back, which is why the result is a data URL rather than the path + // asked for. + const image = await card.waitForRender( + () => deepQuery(thumbnail, 'img'), + 'the thumbnail picture', + ); + + expect(image.src).toMatch(/^data:image\/png/); + }); + + it('should say there is nothing to view when the camera has no events', async () => { + const card = await mountCard([]); + + // The element renders before its text, so waiting for the element alone can + // read it while it is still empty. + await card.waitForRender( + () => getBlockNotificationText(card.card).includes(NO_MEDIA_TEXT) || null, + `the "${NO_MEDIA_TEXT}" notification`, + ); + + expect(getThumbnails(card.card)).toHaveLength(0); + }); + + it('should open the clips gallery from the live view', async () => { + const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)], { + view: { default: 'live' }, + + // The clips button is hidden by default. + menu: { style: 'outside', buttons: { clips: { enabled: true } } }, + }); + + await card.events.waitForFirst('advanced-camera-card:media:loaded'); + + await card.clickControl('Clips gallery'); + await card.waitForSelector('advanced-camera-card-gallery'); + + await waitForThumbnails(card, 1); + expect(getThumbnails(card.card)).toHaveLength(1); + }); + + it('should open the viewer on the media that was clicked', async () => { + const card = await mountCard([ + createTestFrigateEvent('older', EVENT_TIME_OLDER), + createTestFrigateEvent('newer', EVENT_TIME_NEWER), + ]); + + await waitForThumbnails(card, 2); + + // The newest event is shown first, so index 1 is the older of the two. + await clickThumbnail(card.card, 1); + await card.events.waitForFirst('advanced-camera-card:media:loaded'); + + await card.waitForSelector('advanced-camera-card-viewer-carousel'); + + expect(getMediaViewerMediaURLs(card.card)).toEqual([ + expect.stringContaining('clip.webm?event=older'), + ]); + }); + + it('should show the media filter', async () => { + const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)]); + + await card.waitForSelector('advanced-camera-card-media-filter'); + + expect(deepQuery(card.card, 'advanced-camera-card-media-filter')).not.toBeNull(); + }); + + it('should not show the media filter when its mode is none', async () => { + const card = await mountCard([createTestFrigateEvent('newer', EVENT_TIME_NEWER)], { + media_gallery: { controls: { filter: { mode: 'none' } } }, + }); + + await waitForThumbnails(card, 1); + + // The gallery still renders, so a missing filter is not a missing gallery. + expect(deepQuery(card.card, 'advanced-camera-card-gallery')).not.toBeNull(); + expect(deepQuery(card.card, 'advanced-camera-card-media-filter')).toBeNull(); + }); +}); diff --git a/tests/components/image-updating-player.browser.test.ts b/tests/components/image-updating-player.browser.test.ts index fd5d834c..9a79d452 100644 --- a/tests/components/image-updating-player.browser.test.ts +++ b/tests/components/image-updating-player.browser.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest'; import type { MediaLoadedInfoEventDetail } from '../../src/types'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { - createCameraHASS, + CAMERA_ENTITY, + createGenericCameraHASS, createStillImageCardConfig, isMediaLoadedInfoEventDetail, - STILL_CAMERA_ENTITY, } from '../browser/test-utils'; const UNRELATED_ENTITY = 'input_boolean.unrelated'; @@ -22,7 +22,7 @@ interface RenderedElement extends Element { } const mount = async (): Promise => { - const hass = createCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } }); + const hass = createGenericCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } }); return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass); }; @@ -54,7 +54,7 @@ describe('AdvancedCameraCardImageUpdatingPlayer', () => { const loads = getMediaLoadedInfos(card); expect(loads).toHaveLength(1); - expect(loads[0].info.targetID).toBe(STILL_CAMERA_ENTITY); + expect(loads[0].info.targetID).toBe(CAMERA_ENTITY); }); it('should announce the size of the media itself', async () => { diff --git a/tests/components/viewer/carousel.browser.test.ts b/tests/components/viewer/carousel.browser.test.ts new file mode 100644 index 00000000..f18f1029 --- /dev/null +++ b/tests/components/viewer/carousel.browser.test.ts @@ -0,0 +1,197 @@ +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 { + createTestFrigateEvent, + EVENT_TIME_NEWER, + EVENT_TIME_OLDER, + mountCardWithFrigate, +} from '../../browser/fake-frigate'; +import type { MountedCard } from '../../browser/mounted-card'; +import { useTestMedia } from '../../browser/test-media'; +import { + clickElement, + clickNextPreviousMedia, + clickThumbnail, + createFailingMediaURL, + deepQuery, + getMediaViewerMediaURLs, + getSelectedThumbnail, + getStatusBarItem, + getStatusBarStrings, + getThumbnails, + waitForMediaViewerMedia, + waitForThumbnails, +} from '../../browser/test-utils'; + +const EVENTS = [ + createTestFrigateEvent('older', EVENT_TIME_OLDER), + createTestFrigateEvent('newer', EVENT_TIME_NEWER), +]; + +// What the status bar calls a media failure, which is where the viewer shows +// the issue: the media it cannot show is still on screen behind the carousel. +const MEDIA_ISSUE_TITLE = 'Media unavailable'; + +// The thumbnail carousel sits in a drawer by default, which is harder to click. +const CONFIG_THUMBNAILS_BELOW: PartialAdvancedCameraCardConfig = { + media_viewer: { controls: { thumbnails: { mode: 'below' } } }, +}; + +interface ViewerOptions { + config?: PartialAdvancedCameraCardConfig; + + // Which gallery thumbnail to open the viewer on. The newest event is first. + thumbnail?: number; +} + +const mountViewer = async ( + events = EVENTS, + options?: ViewerOptions, +): Promise => { + const { card } = await mountCardWithFrigate(events, { + view: { default: 'clips' }, + ...options?.config, + }); + + await waitForThumbnails(card, events.length); + await clickThumbnail(card.card, options?.thumbnail ?? 0); + await card.events.waitForFirst('advanced-camera-card:media:loaded'); + + return card; +}; + +// One test here has a clip that never loads, which is served from within the +// page rather than by the dev server. +useTestMedia(); + +describe('AdvancedCameraCardViewerCarousel', () => { + it('should play the clip that was opened', async () => { + const card = await mountViewer(); + + expect(getMediaViewerMediaURLs(card.card)).toEqual([ + expect.stringContaining('clip.webm?event=newer'), + ]); + expect(deepQuery(card.card, 'advanced-camera-card-video-player')).not.toBeNull(); + }); + + it('should show a snapshot as a still image', async () => { + const card = await mountViewer(EVENTS, { + config: { view: { default: 'snapshots' } }, + }); + + expect(getMediaViewerMediaURLs(card.card)).toEqual([ + expect.stringContaining('still-red.png?event=newer'), + ]); + expect(deepQuery(card.card, 'advanced-camera-card-image-player')).not.toBeNull(); + expect(deepQuery(card.card, 'advanced-camera-card-video-player')).toBeNull(); + }); + + it('should show the next media when the next control is used', async () => { + // Opened on the older event, so there is a newer one to move on to. + const card = await mountViewer(EVENTS, { thumbnail: 1 }); + + await clickNextPreviousMedia(card.card, 'right'); + + await waitForMediaViewerMedia(card, 'clip.webm?event=newer'); + expect(getSelectedThumbnail(card.card)).toBe(getThumbnails(card.card)[1]); + }); + + it('should show the media selected in the thumbnail carousel', async () => { + const card = await mountViewer(EVENTS, { config: CONFIG_THUMBNAILS_BELOW }); + + // The viewer runs oldest first (gallery is the opposite). This is the oldest. + await clickThumbnail(card.card, 0); + + await waitForMediaViewerMedia(card, 'clip.webm?event=older'); + expect(getSelectedThumbnail(card.card)).toBe(getThumbnails(card.card)[0]); + }); + + it('should name the media being viewed in the status bar', async () => { + const card = await mountViewer( + [ + createTestFrigateEvent('older', EVENT_TIME_OLDER, { label: 'car' }), + createTestFrigateEvent('newer', EVENT_TIME_NEWER, { label: 'person' }), + ], + { config: { status_bar: { style: 'outside' }, ...CONFIG_THUMBNAILS_BELOW } }, + ); + + expect(getStatusBarStrings(card.card).join(' ')).toContain('Person'); + + await clickThumbnail(card.card, 0); + await waitForMediaViewerMedia(card, 'clip.webm?event=older'); + + expect(getStatusBarStrings(card.card).join(' ')).toContain('Car'); + }); + + it('should play the clip when a snapshot is clicked', async () => { + const card = await mountViewer(EVENTS, { + config: { view: { default: 'snapshots' } }, + }); + + const snapshot = deepQuery(card.card, 'advanced-camera-card-image-player'); + assert(snapshot); + + await clickElement(snapshot); + + await waitForMediaViewerMedia(card, 'clip.webm?event=newer'); + }); + + it('should pause the media that is moved away from', async () => { + // Opened on the older event, so there is a newer one to move on to. + const card = await mountViewer(EVENTS, { thumbnail: 1 }); + + // `auto_play` covers the selected media, so the clip starts on its own. + await card.events.waitForFirst('advanced-camera-card:media:play'); + + await clickNextPreviousMedia(card.card, 'right'); + + // Leaving a clip playing behind the one on screen would have two videos + // running at once, and the user hearing the one they cannot see. + await card.events.waitForFirst('advanced-camera-card:media:pause'); + }); + + it('should report media that cannot be loaded', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + + const { card, frigate } = await mountCardWithFrigate(EVENTS, { + view: { default: 'clips' }, + status_bar: { style: 'outside' }, + }); + frigate.setMediaURL('newer', 'clips', createFailingMediaURL()); + + await waitForThumbnails(card, EVENTS.length); + await clickThumbnail(card.card, 0); + + // The card starts its load timer when the player is rendered, so waiting for + // the player means the timer is running and the clock can be jumped. + await card.waitForSelector('video'); + + // A clip that refuses to load says nothing of its own, so the card has only + // silence to go on and triggers an issue once it has waited long enough. + await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS); + + await card.waitForRender( + () => getStatusBarItem(card.card, MEDIA_ISSUE_TITLE), + `the ${MEDIA_ISSUE_TITLE} issue being reported`, + ); + }); + + it('should return to the gallery from the viewer', async () => { + const card = await mountViewer(EVENTS, { + config: { + // The clips button is hidden by default. + menu: { style: 'outside', buttons: { clips: { enabled: true } } }, + }, + }); + + await card.clickControl('Clips gallery'); + + await card.waitForSelector('advanced-camera-card-gallery'); + expect(deepQuery(card.card, 'advanced-camera-card-viewer-carousel')).toBeNull(); + }); +}); diff --git a/tests/dist/dist.browser.test.ts b/tests/dist/dist.browser.test.ts index a3274c44..b4667803 100644 --- a/tests/dist/dist.browser.test.ts +++ b/tests/dist/dist.browser.test.ts @@ -11,7 +11,7 @@ import { type MountOptions, } from '../browser/mounted-card'; import { - createCameraHASS, + createGenericCameraHASS, createStillImageCardConfig, isLiveMediaShowing, } from '../browser/test-utils'; @@ -99,7 +99,7 @@ class BuildMountedCardFactory extends MountedCardFactory { * rather than from `src/`. */ const mountBuiltCard = async ( - hass: FakeHASS = createCameraHASS(), + hass: FakeHASS = createGenericCameraHASS(), ): Promise => await BuildMountedCardFactory.createFromBuild( `/${PUBLIC_ENTRY}?hacstag=${HACSTAG}`, @@ -243,7 +243,7 @@ describe('the built card', () => { // Clear resource timings to only measure the impact of mounting the card. performance.clearResourceTimings(); - const mounted = await mountBuiltCard(createCameraHASS({ language: 'de' })); + const mounted = await mountBuiltCard(createGenericCameraHASS({ language: 'de' })); await mounted.events.waitForFirst('advanced-camera-card:media:loaded'); expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 4c80263b..47e7dc3c 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -67,13 +67,23 @@ export default defineConfig({ name: 'browser', include: ['tests/**/*.browser.test.ts'], + // One file at a time. Files otherwise share a browser, in which only one of + // them can hold focus, so a test driving the keyboard loses it to whichever + // sibling clicks next. Measured no slower than running in parallel. + fileParallelism: false, + // The tests under `tests/dist` need a card build to exist (in dist/), which // this suite does not require. They have their own config, see // vitest.dist.config.ts . exclude: ['tests/dist/**'], - // Cosmetic: Style the pages as HA does for screenshots. - setupFiles: ['./tests/browser/style.ts'], + setupFiles: [ + // Must run before anything defines an element. + './tests/browser/scoped-custom-element-registry.ts', + + // Cosmetic: Style the pages as HA does for screenshots. + './tests/browser/style.ts', + ], // A failing test leaves a screenshot and any attachments behind. Both // default to somewhere else -- a `__screenshots__` directory next to the @@ -85,6 +95,11 @@ export default defineConfig({ // past the default of 300ms. slowTestThreshold: 10000, + // How long a test may take before it is called a hang. Mounting a real card + // in three browsers at once is expensive, and a machine running something + // else alongside can push a healthy test past the default of 5 seconds. + testTimeout: 30000, + server: { deps: { // These dependencies import without extensions. diff --git a/yarn.lock b/yarn.lock index 24f29950..7814194a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2465,6 +2465,13 @@ __metadata: languageName: node linkType: hard +"@webcomponents/scoped-custom-element-registry@npm:0.0.10": + version: 0.0.10 + resolution: "@webcomponents/scoped-custom-element-registry@npm:0.0.10" + checksum: 10c0/97fdb174f6795b20a3f6e8af9018b6c3e48c6a2bf40b87ef606d9b4616df32b0dc67a085f8ca1fda0e4327b3237ae76626bf1bbea3cfea0e03ce743f915a39cc + languageName: node + linkType: hard + "a-sync-waterfall@npm:^1.0.0": version: 1.0.1 resolution: "a-sync-waterfall@npm:1.0.1" @@ -2552,6 +2559,7 @@ __metadata: "@use-gesture/vanilla": "npm:^10.3.1" "@vitest/browser-playwright": "npm:4.1.10" "@vitest/coverage-v8": "npm:^4.1.10" + "@webcomponents/scoped-custom-element-registry": "npm:0.0.10" any-date-parser: "npm:^2.2.0" component-emitter: "npm:^1.3.1" compute-scroll-into-view: "npm:^3.1.1"