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
+1
View File
@@ -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",
+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.
+3 -3
View File
@@ -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));
@@ -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<MountedCard> => {
const hass = createCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
const hass = createGenericCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource(
createStillImageCardConfig({
automations: [
@@ -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<MountedCard> => {
},
],
}),
createCameraHASS(),
createGenericCameraHASS(),
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
@@ -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<MountedCard> =>
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
@@ -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<MountedCard> =>
view: { issues: { retry_seconds: 0 } },
automations: [createInitializedAutomation()],
}),
createCameraHASS(),
createGenericCameraHASS(),
);
describe('InitializationIssue', () => {
@@ -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<void> =>
@@ -69,7 +75,7 @@ const waitForIssueCleared = async (card: MountedCard): Promise<void> => {
);
};
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');
@@ -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<MountedCard> => {
},
],
}),
createCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
createGenericCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
mountOptions,
);
@@ -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', () => {
@@ -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<MountedCard> =>
(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<HTMLImageElement>(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();
});
});
@@ -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<MountedCard> => {
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 () => {
@@ -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<MountedCard> => {
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();
});
});
+3 -3
View File
@@ -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<MountedCard> =>
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-/)]);
+17 -2
View File
@@ -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.
+8
View File
@@ -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"