test: Also run firefox & webkit automated tests (#2651)

This commit is contained in:
Dermot Duffy
2026-08-03 16:48:30 -07:00
committed by GitHub
parent 4a7c1b62b0
commit 18ea725564
12 changed files with 1132 additions and 162 deletions
+142 -18
View File
@@ -5,12 +5,107 @@ import type { AdvancedCameraCard } from '../../src/card';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
import { defineHAElementStubs } from './ha-element-stubs';
import { clickElement, deepQuery, deepQueryAll } from './test-utils';
import { clickElement, deepQuery, deepQueryAll, getAllShadowRoots } from './test-utils';
// Home Assistant's masonry columns are `max-width: 500px`, so this is the width
// a card usually gets. The card derives height from the media it is showing.
const DEFAULT_CONTAINER_WIDTH = '500px';
// Everything a rendered element can arrive as: drawn, moved, retitled or
// relabelled.
const RENDER_MUTATIONS = {
attributes: true,
characterData: true,
childList: true,
subtree: true,
};
/**
* Provides debug information when a test ends (e.g. timeout), by reporting
* expected things that didn't happen. Helps narrow down a hanging test to the
* precise unmet expectation.
*/
const reportIfNeverHappens = (
description: string,
cleanUp?: () => void,
): (() => void) => {
let happened = false;
onTestFinished(() => {
cleanUp?.();
if (!happened) {
throw new Error(`Never happened: ${description}`);
}
});
return () => {
happened = true;
};
};
/**
* Wait for something the card draws.
*
* A `MutationObserver` reports a change when it happens and has no clock of its
* own to run (so no clash with fake vs real timers used elsewhere in the test).
* Alternatives on offer (e.g. `vi.waitFor`, `expect.element`) poll a timer,
* which under a fake clock is the card's timer so each each poll advances the
* card's own test clock by the interval between polls.
*
* If the predicate is never "found", the test will fail on the Vitest timeout,
* which names only the test. `description` is reported alongside it, to say
* which wait it was that never finished.
*
* Known limitation: the browser reports changes within a root being watched,
* never the creation of a root itself. A new root is picked up because whatever
* created it also changed a watched root; one created with nothing else
* changing around it would be missed until the timeout. Everything the card
* draws is a LIT element, which creates its root as the element is added to the
* page, so the root above it always changes at the same moment and nothing is
* missed.
*/
const waitForRender = async <T>(
root: Element,
find: () => T | null,
description: string,
): Promise<T> => {
const observers: MutationObserver[] = [];
const observed = new Set<Node>();
const stopObserving = (): void =>
observers.forEach((observer) => observer.disconnect());
const happened = reportIfNeverHappens(description, stopObserving);
try {
return await new Promise<T>((resolve) => {
const check = (): void => {
// Watch all shadow roots we're not already watching.
for (const node of [root, ...getAllShadowRoots(root)]) {
if (!observed.has(node)) {
observed.add(node);
const observer = new MutationObserver(check);
observer.observe(node, RENDER_MUTATIONS);
observers.push(observer);
}
}
// Attempt to find.
const match = find();
if (match) {
happened();
resolve(match);
}
};
check();
});
} finally {
stopObserving();
}
};
// The card events worth recording by default. There is no way to listen for a
// prefix, so every name a ledger reports has to be named somewhere; this is the
// set that describes what the card is doing rather than what an editor control
@@ -141,8 +236,19 @@ class EventLedger {
return recorded[count - 1];
}
const happened = reportIfNeverHappens(`${type} firing ${count} time(s)`);
return await new Promise<EventEntry>((resolve) => {
this._waiting.set(type, [...(this._waiting.get(type) ?? []), { count, resolve }]);
this._waiting.set(type, [
...(this._waiting.get(type) ?? []),
{
count,
resolve: (entry: EventEntry): void => {
happened();
resolve(entry);
},
},
]);
});
}
@@ -214,8 +320,18 @@ class ConsoleLedger {
return;
}
const happened = reportIfNeverHappens(
`${waiter.level} being written ${waiter.count} time(s): ${message.source}`,
);
return await new Promise<void>((resolve) => {
this._waiting.push({ ...waiter, resolve });
this._waiting.push({
...waiter,
resolve: (): void => {
happened();
resolve();
},
});
});
}
@@ -432,13 +548,21 @@ export class MountedCard {
public async waitForSelector<T extends Element = Element>(
selector: string,
): Promise<T> {
return await vi.waitFor(() => {
const found = deepQuery<T>(this.card, selector);
if (!found) {
throw new Error(`No element matched: ${selector}`);
}
return found;
});
return await this.waitForRender(
() => deepQuery<T>(this.card, selector),
`an element matching ${selector}`,
);
}
/**
* Wait for something the card renders that a selector cannot describe. Use
* instead of `vi.waitFor` which interferes with fake `card` time.
*
* `description` names what is being waited for (so it can be displayed if not
* found, for debugging purposes).
*/
public async waitForRender<T>(find: () => T | null, description: string): Promise<T> {
return await waitForRender(this.card, find, description);
}
/**
@@ -461,9 +585,12 @@ export class MountedCard {
public async holdControl(name: string): Promise<void> {
const control = await this._findControl(name);
control.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
// Composed as well as bubbling: a real press crosses the shadow boundaries
// between a control and whatever is listening above it.
const press = { bubbles: true, composed: true };
control.dispatchEvent(new MouseEvent('mousedown', press));
await vi.advanceTimersByTimeAsync(ACTION_HANDLER_HOLD_SECONDS * 1000);
control.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
control.dispatchEvent(new MouseEvent('mouseup', press));
// The card takes the click, not the mouseup, as the end of a press. A real
// pointer sends both, in this order.
@@ -471,15 +598,12 @@ export class MountedCard {
}
private async _findControl(name: string): Promise<HTMLElement> {
return await vi.waitFor(() => {
return await this.waitForRender(() => {
const found = deepQueryAll(this.card, '*').find(
(element) => getControlName(element) === name,
);
if (!(found instanceof HTMLElement)) {
throw new Error(`Could not find control named: ${name}`);
}
return found;
});
return found instanceof HTMLElement ? found : null;
}, `a control named ${name}`);
}
public destroy(): void {
+26
View File
@@ -0,0 +1,26 @@
# Browser test public directory
## Why this directory is here
Served at the root of the page the browser tests run in, by way of `publicDir`
in `vitest.browser.config.ts`. Named rather than left at its default of
`public/` in the project root, which is the directory a Vite build copies into
its output: nothing a test needs belongs in a released card.
## Why `mockServiceWorker.js` must not be touched
It is Mock Service Worker's own script, copied here by `msw init`. Do not edit
it and do not rename it: `msw.workerDirectory` in `package.json` records this
path, and installing dependencies copies the script here again from whichever
version of `msw` is installed. That is what stops it going stale against a
version bump, and it also means any local change is lost.
## Why the tests serve media this way
What the tests do with it is in `tests/browser/test-media.ts`: a camera that
fails or goes quiet is answered from within the page, so a request nobody will
ever answer costs no browser connection. That matters because a browser allows
only a handful of connections to one host, and everything the page asks for
afterwards queues behind the ones a test is deliberately holding open. The card
imports some of its own code only when it is needed, so what starves is not
just the next picture but the next module.
+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+93
View File
@@ -0,0 +1,93 @@
import { delay, http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';
import { beforeAll } from 'vitest';
const HTTP_OK = 200;
// 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>();
// Whether this page's tests asked for the worker. Recorded so that a URL only
// the worker can answer cannot be built without it.
let inUse = false;
export const isTestMediaInUse = (): boolean => inUse;
/**
* Serves a fixture at `/test-media/<file>`, behaving as the query asks:
*
* token Which counter the request belongs to, so that a test's
* 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).
*
* 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
* allows to a host -- and a camera that has gone quiet is exactly a request
* nobody will ever answer. Held here, it costs nothing: the browser never puts
* it on the wire.
*
* Nothing here waits for a set time. Tests run on a fake clock while requests
* are served in real time, so a response that is merely slow is a race: run the
* suite on a loaded machine and it arrives in the middle of a test that assumed
* it would not.
*/
const worker = setupWorker(
http.get('/test-media/:file', async ({ request, params }) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
return new HttpResponse(null, { status: 404 });
}
const responses = (url.searchParams.get('responses') ?? '')
.split(',')
.filter((status) => status !== '')
.map(Number);
const answered = requestCounts.get(token) ?? 0;
requestCounts.set(token, answered + 1);
const isPastEnd = answered >= responses.length;
if (isPastEnd && url.searchParams.get('repeat') !== 'true') {
await delay('infinite');
}
const status = responses[isPastEnd ? responses.length - 1 : answered];
if (status !== HTTP_OK) {
return new HttpResponse(null, { status });
}
// 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)}`);
return fixture.ok
? new HttpResponse(await fixture.arrayBuffer(), {
headers: {
'Content-Type': fixture.headers.get('Content-Type') ?? 'image/png',
},
})
: new HttpResponse(null, { status: fixture.status });
}),
);
/**
* Serve the misbehaving media this page's tests ask for.
*
* Called by the files that need it rather than for every page: the worker sees
* every request the page makes, passing on the ones it does not answer, and a
* page that has no camera to misbehave gains nothing for that cost.
*/
export const useTestMedia = (): void => {
beforeAll(async () => {
// Everything the page loads for itself -- modules, styles, the fixture
// above -- is left alone.
await worker.start({ onUnhandledRequest: 'bypass', quiet: true });
inUse = true;
});
};
+35 -16
View File
@@ -4,14 +4,15 @@ import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import type { MediaLoadedInfoEventDetail } from '../../src/types';
import { createLogAction } from '../../src/utils/action';
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
import { isTestMediaInUse } from './test-media';
export const STILL_CAMERA_ENTITY = 'camera.office';
const STILL_FIXTURE_FILENAME = 'still-red.png';
// A same-origin still red image, served by the Vite dev server. The same image
// is also served by the test-media plugin, which can be asked to misbehave in
// useful ways. See test-media-server-plugin.js .
// 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}`;
/**
@@ -39,30 +40,42 @@ const HTTP_NOT_FOUND = 404;
const HTTP_OK = 200;
/**
* A media URL answered with the given statuses in order, and never answered at
* all once they run out.
* 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 server serves a whole run and a
* shared counter would make a test depend on what ran before it.
* 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[]): string =>
`/test-media/${STILL_FIXTURE_FILENAME}?` +
new URLSearchParams({
token: crypto.randomUUID(),
responses: responses.join(','),
}).toString();
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, so a test
* can make a camera recover rather than only fail.
* 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]);
createMediaURL([...Array(failures).fill(HTTP_NOT_FOUND), HTTP_OK], true);
/**
* A media URL that never works, for a camera that is simply broken.
*/
export const createFailingMediaURL = (): string => createMediaURL([HTTP_NOT_FOUND]);
export const createFailingMediaURL = (): string =>
createMediaURL([HTTP_NOT_FOUND], true);
/**
* A media URL that is never answered, for a camera that accepts the request and
@@ -141,6 +154,12 @@ const getImmediateShadowRoots = (root: ParentNode): ShadowRoot[] => {
return roots;
};
/**
* Get every shadow root at or below an element.
*/
export const getAllShadowRoots = (root: ParentNode): ShadowRoot[] =>
getImmediateShadowRoots(root).flatMap((child) => [child, ...getAllShadowRoots(child)]);
/**
* Search an element and every shadow root beneath it. The card nests its own
* components several roots deep, and neither the source tree nor the node
@@ -6,6 +6,7 @@ import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/compo
import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
import { MountedCard, type MountOptions } from '../../../browser/mounted-card';
import { useTestMedia } from '../../../browser/test-media';
import {
createFailingMediaURL,
createStallingMediaURL,
@@ -23,27 +24,24 @@ import {
const SECOND_CAMERA_ENTITY = 'camera.hallway';
const REPORT_TITLE = 'Media unavailable';
const MEDIA_ISSUE_TITLE = 'Media unavailable';
// Holding this reaches the diagnostics view, the only view showing no media
// that a camera without a media browsing engine can get to.
const IRIS_CONTROL = 'Iris / Default View / Unhide menu';
// Only the status bar counts as the report. The notification behind it carries
// the same title, so a wider search would answer a different question.
const findReport = (card: MountedCard): Element | null =>
const findIssue = (card: MountedCard): Element | null =>
deepQuery(card.card, 'advanced-camera-card-status-bar')?.shadowRoot?.querySelector(
`[title="${REPORT_TITLE}"]`,
`[title="${MEDIA_ISSUE_TITLE}"]`,
) ?? null;
const isIssueReported = (card: MountedCard): boolean => !!findReport(card);
const isIssueReported = (card: MountedCard): boolean => !!findIssue(card);
const waitForIssueReported = async (card: MountedCard): Promise<void> => {
await vi.waitFor(() => {
if (!findReport(card)) {
throw new Error(`The issue was not reported: ${REPORT_TITLE}`);
}
});
await card.waitForRender(
() => findIssue(card),
`the ${MEDIA_ISSUE_TITLE} issue being reported`,
);
};
interface MountCardOptions extends MountOptions {
@@ -51,8 +49,8 @@ interface MountCardOptions extends MountOptions {
}
/**
* Every test here needs the status bar rendered, since that is where the report
* appears.
* Every test here needs the status bar rendered, since that is where an issue
* is reported.
*/
const mountCard = async (
config?: Partial<RawAdvancedCameraCardConfig>,
@@ -103,6 +101,10 @@ const mountCardDualCameras = async (): Promise<MountedCard> => {
return card;
};
// Several test cameras here intentionally fail, hang or go quiet, which is
// served from within the page rather than by the dev server.
useTestMedia();
beforeEach(() => {
vi.useFakeTimers();
});
@@ -142,7 +144,7 @@ describe('MediaUnavailableIssue', () => {
card.setEntityState(STILL_CAMERA_ENTITY, 'idle');
// Well past the point the report would have appeared had the blip not
// Well past the point the issue report would have appeared had the blip not
// ended. Nothing should ever have been shown.
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
@@ -154,9 +156,10 @@ describe('MediaUnavailableIssue', () => {
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
await card.waitForSelector('advanced-camera-card-notification-block');
// Which camera, not just that something is wrong: with several on screen a
// report that does not say which one leaves the user to guess.
// Which camera, not just that something is wrong: with several on screen an
// issue report that does not say which one leaves the user guessing.
expect(getBlockNotificationText(card.card)).toContain('Camera entity unavailable');
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
});
@@ -166,6 +169,7 @@ describe('MediaUnavailableIssue', () => {
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
await card.waitForSelector('advanced-camera-card-notification-block');
// One camera failing must not take the other down with it.
expect(
@@ -191,7 +195,7 @@ describe('MediaUnavailableIssue', () => {
expect(getBlockNotificationText(card.card)).toContain(STILL_CAMERA_ENTITY);
});
it('should clear the report once the camera delivers media again', async () => {
it('should clear the issue report once the camera delivers media again', async () => {
const card = await mountCard({
cameras: [
createStillImageCameraConfig(
@@ -205,15 +209,15 @@ describe('MediaUnavailableIssue', () => {
await waitForIssueReported(card);
// Nothing here asks the card to try again. A camera that has come back must
// be picked up by the card's own retry, or the report stays up forever for
// a user who is looking at a working camera.
// be picked up by the card's own retry, or the issue report stays up
// forever for a user who is looking at a working camera.
await card.advanceSeconds(RETRY_EXPONENTIAL_BASE_SECONDS);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
expect(isIssueReported(card)).toBe(false);
// The picture is back, so the cleared report is not the card having thrown
// the whole live view away.
// The picture is back, so the cleared issue report is not the card having
// thrown the whole live view away.
expect(isLiveMediaShowing(card.card)).toBe(true);
});
@@ -294,8 +298,9 @@ describe('MediaUnavailableIssue', () => {
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
// The status bar only summarises. Everything a user can do about the
// failure is behind it, which is the point of the report being clickable.
await card.clickControl(REPORT_TITLE);
// failure is behind it, which is the point of the issue report being
// clickable.
await card.clickControl(MEDIA_ISSUE_TITLE);
await card.clickControl('Retry');
await card.events.waitForFirst('advanced-camera-card:media:loaded');
@@ -315,15 +320,16 @@ describe('MediaUnavailableIssue', () => {
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
// Diagnostics shows no media at all, so there is nothing for the report to
// be about and complaining there would be noise on an unrelated screen.
// Diagnostics shows no media at all, so there is nothing for an issue
// report to be about and complaining there would be noise on an unrelated
// screen.
await card.holdControl(IRIS_CONTROL);
await card.waitForSelector('advanced-camera-card-diagnostics');
expect(isIssueReported(card)).toBe(false);
// Returning to the camera brings the report back. Leaving the view is not
// an answer to the failure, and coming back to a silently broken camera
// Returning to the camera brings the issue report back. Leaving the view is
// not an answer to the failure, and coming back to a silently broken camera
// would be worse than never having been told.
await card.clickControl('Live view');
await waitForIssueReported(card);
@@ -333,6 +339,12 @@ 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: [
{
@@ -352,16 +364,23 @@ describe('MediaUnavailableIssue', () => {
});
await card.events.waitForFirst('advanced-camera-card:media:loaded');
expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(0);
// One missed refresh is not a stall. A camera gets a whole refresh interval
// on top of the standard window before silence is held against it.
await card.advanceSeconds(refreshSeconds + FRAME_STALL_SECONDS - 1);
expect(isIssueReported(card)).toBe(false);
// 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);
}
// Past it. The window is measured from a real media load rather than from
// anything on the card's clock, so landing exactly on the deadline is a
// race: step over it instead.
await card.advanceSeconds(2);
expect(isIssueReported(card)).toBe(true);
// Stalled rather than failed: the picture on screen is real but frozen, and
@@ -384,7 +403,7 @@ describe('MediaUnavailableIssue', () => {
'Could not get camera endpoint',
);
await card.clickControl(REPORT_TITLE);
await card.clickControl(MEDIA_ISSUE_TITLE);
await card.waitForSelector('advanced-camera-card-notification');
expect(