test: Add browser tests for the media viewing gallery and viewer (#2661)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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';
|
||||
@@ -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
@@ -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,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();
|
||||
});
|
||||
});
|
||||
Vendored
+3
-3
@@ -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-/)]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user