test: Add browser tests for the media viewing gallery and viewer (#2661)

This commit is contained in:
Dermot Duffy
2026-08-07 16:08:56 -07:00
committed by GitHub
parent 567690831c
commit 673d8fe133
25 changed files with 1563 additions and 144 deletions
+299
View File
@@ -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>): FrigateEvent =>
createFrigateEvent({
camera: CAMERA_NAME,
id,
start_time: EARLIER,
end_time: LATER,
...event,
});
const queryEvents = async (
hass: FakeHASS,
parameters?: Record<string, unknown>,
): Promise<FrigateEvent[]> => {
const answer = await hass.getHASS().callWS<string>({
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<string, unknown>,
): Promise<string[]> => (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<ResolvedMedia> =>
await hass.getHASS().callWS<ResolvedMedia>({
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');
});
});
});
+402
View File
@@ -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>,
): 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<CardWithFrigate> => {
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/<client>/event/<clips|snapshots>/<camera>/<id>
const EVENT_CONTENT_ID =
/^media-source:\/\/frigate\/(?<clientID>[^/]+)\/event\/(?<mediaType>[^/]+)\/(?<camera>[^/]+)\/(?<eventID>[^/]+)$/;
// An event's thumbnail, as `getEventThumbnailURL` asks for it:
// /api/frigate/<client>/thumbnail/<id>
const THUMBNAIL_PATH =
/^\/api\/frigate\/(?<clientID>[^/]+)\/thumbnail\/(?<eventID>[^/]+)$/;
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 = <T>(
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<NativeFrigateEventQuery, 'instance_id'>;
// 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<string, string>();
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<string, EventSummary[number]>();
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<Response> {
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));
}
}
+64
View File
@@ -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<unknown> =>
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();
+60 -3
View File
@@ -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> | unknown;
// A handler for an authenticated HTTP request.
export type PathHandler = (path: string) => Promise<Response> | Response;
// A handler that turns a media content ID into something playable (i.e. HA
// media source).
export type MediaSourceHandler = (
contentID: string,
) => Promise<ResolvedMedia> | ResolvedMedia;
export interface FakeEntityOptions {
state?: string;
attributes?: Record<string, unknown>;
@@ -100,6 +109,8 @@ export class FakeHASS {
private _language: string;
private _isAdmin: boolean;
private _handlers = new Map<string, WSCommandHandler>();
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<ResolvedMedia> {
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<T>(message: MessageBase): Promise<T> {
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<Response> {
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'),
};
}
+14
View File
@@ -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}`;
+36
View File
@@ -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
```
Binary file not shown.
@@ -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';
+36 -6
View File
@@ -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<string, number>();
@@ -12,7 +17,32 @@ const requestCounts = new Map<string, number>();
// 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/<file>`, 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(), {
+214 -49
View File
@@ -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<Entity>;
}
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<string, FakeEntityOptions | string>;
// 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>,
): 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<void> => {
const element = await card.waitForSelector(selector);
await new Promise<void>((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<HTMLElement>(root, 'advanced-camera-card-thumbnail');
export const getSelectedThumbnail = (root: ParentNode): HTMLElement | null =>
deepQuery<HTMLElement>(root, 'advanced-camera-card-thumbnail.slide-selected');
export const clickThumbnail = async (root: ParentNode, index: number): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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.