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
+39 -10
View File
@@ -38,7 +38,7 @@ jobs:
run: yarn run format-check
test:
name: Unit Test
name: Test / Unit
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -54,12 +54,36 @@ jobs:
run: yarn run coverage
browser-test:
name: Browser Test
# Not yet a required check: the browser suite is new/potentially unstable.
continue-on-error: true
name: Test / Browser / ${{ matrix.name }}
runs-on: ubuntu-latest
# Use Playwright's own image to avoid having to (slowly) install browsers
# and dependencies. The tag has to name the `playwright` version in
# package.json .
container:
image: mcr.microsoft.com/playwright:v1.62.0-noble
# As the image's own user rather than root, which Firefox refuses to run
# as when the home directory belongs to somebody else.
#
# See https://playwright.dev/docs/ci#via-containers .
options: --user 1001
strategy:
# One browser failing does not invalidate the others.
fail-fast: false
matrix:
# `name` is only how the browser is written for a human: it titles the
# job, while `browser` is the name Playwright knows it by.
include:
- browser: chromium
name: Chromium
- browser: firefox
name: Firefox
- browser: webkit
name: WebKit
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -70,13 +94,18 @@ jobs:
- name: Install dependencies
run: yarn install --immutable
# The browser binary is not in the lockfile, and the media tests need the
# proprietary codecs the Chrome for Testing builds carry.
- name: Install Chromium
run: yarn playwright install --with-deps chromium
- name: Browser test
run: yarn run test:browser
env:
VITEST_BROWSER: ${{ matrix.browser }}
- name: Upload browser test failures
if: failure()
uses: actions/upload-artifact@v6
with:
name: browser-test-failures-${{ matrix.browser }}
path: .vitest/
if-no-files-found: ignore
build:
name: Build
+8
View File
@@ -1,2 +1,10 @@
# Third party libraries, shipped minified: formatting them would rewrite code #
# that is not ours to style.
docs/js/
# Home Assistant's own files, which it writes and reads back for itself.
.devcontainer/
# Generated by `msw init` and copied here again on every install, so any #
# formatting would be undone. See tests/browser/public/README.md .
tests/browser/public/mockServiceWorker.js
+6
View File
@@ -76,6 +76,7 @@
"eslint-config-prettier": "^9.1.0",
"jsdom": "^21.1.2",
"knip": "^6.29.0",
"msw": "^2.15.0",
"playwright": "1.62.0",
"prettier": "^3.3.2",
"rollup": "^3.29.4",
@@ -197,5 +198,10 @@
"volta": {
"node": "22.14.0",
"yarn": "4.9.1"
},
"msw": {
"workerDirectory": [
"tests/browser/public"
]
}
}
-75
View File
@@ -1,75 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const FIXTURE_DIRECTORY = 'tests/browser/fixtures';
const CONTENT_TYPES = {
'.png': 'image/png',
};
const OK = 200;
// Requests answered per token, so `responses` can be read as a sequence.
const requestCounts = new Map();
/**
* Serves a fixture at `/test-media/<file>`, behaving as the query asks:
*
* token Which counter the request belongs to. Required, because one
* server serves every test in a run and a shared counter would
* make a test's behaviour 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. Once the
* list runs out, requests are never answered at all, which is how
* a camera goes quiet.
*
* 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.
*/
export const testMediaServer = () => ({
name: 'test-media-server',
configureServer(server) {
server.middlewares.use('/test-media', (req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
// A name rather than a path: nothing outside the fixtures is servable.
const file = path.basename(url.pathname);
const contentType = CONTENT_TYPES[path.extname(file)];
const location = path.resolve(FIXTURE_DIRECTORY, file);
const token = url.searchParams.get('token');
if (!contentType || !token || !fs.existsSync(location)) {
res.statusCode = 404;
res.end();
return;
}
const responses = (url.searchParams.get('responses') ?? '')
.split(',')
.filter((status) => status !== '')
.map(Number);
const answered = requestCounts.get(token) ?? 0;
requestCounts.set(token, answered + 1);
// Held open deliberately. The socket is released when the test ends and
// the card that asked for it is torn down.
if (answered >= responses.length) {
return;
}
const status = responses[answered];
if (status !== OK) {
res.statusCode = status;
res.end();
return;
}
res.setHeader('Content-Type', contentType);
res.end(fs.readFileSync(location));
});
},
});
+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(
+34 -7
View File
@@ -4,7 +4,22 @@ import { defineConfig } from 'vitest/config';
import { releaseVersion } from './scripts/release-version-plugin.js';
import { scssString } from './scripts/scss-string-plugin.js';
import { svgPath } from './scripts/svg-path-plugin.js';
import { testMediaServer } from './scripts/test-media-server-plugin.js';
const BROWSERS = ['chromium', 'firefox', 'webkit'] as const;
type Browser = (typeof BROWSERS)[number];
const isBrowser = (name: string): name is Browser =>
BROWSERS.some((browser) => browser === name);
const requestedBrowser = process.env.VITEST_BROWSER;
if (requestedBrowser !== undefined && !isBrowser(requestedBrowser)) {
throw new Error(`Unknown browser: ${requestedBrowser}`);
}
// Which browsers to run, all of them unless one is named. CI names one per job
// so that the three run at the same time on separate machines rather than one
// after another on one.
const browsers = requestedBrowser ? [requestedBrowser] : BROWSERS;
// Browser tests mount the real card in Chromium. They live in their own config
// rather than as a fourth project in `vitest.config.ts` because `vitest run`
@@ -16,7 +31,15 @@ export default defineConfig({
// same asset shapes the Rollup build's plugins do: an SVG becomes the `{
// path, viewBox }` a custom iconset serves, SCSS becomes the string
// `unsafeCSS` takes. `svgPath` is the build's own plugin, reused unchanged.
plugins: [releaseVersion(), scssString(), svgPath(), testMediaServer()],
plugins: [releaseVersion(), scssString(), svgPath()],
// Where the Mock Service Worker script is served from, which Vite serves at
// the root of the page. 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.
//
// See tests/browser/public/README.md .
publicDir: 'tests/browser/public',
resolve: {
// Several dependencies declare their own Lit. Two copies in one page do not
@@ -72,6 +95,10 @@ export default defineConfig({
// at one directory that `.gitignore` can name once.
attachmentsDir: '.vitest/attachments',
// When to name a test in the output for taking too long. Browser tests blow
// past the default of 300ms.
slowTestThreshold: 10000,
server: {
deps: {
// These dependencies import without extensions.
@@ -91,14 +118,14 @@ export default defineConfig({
// screenshot taken when one fails.
viewport: { width: 1280, height: 800 },
instances: [{ browser: 'chromium' }],
instances: browsers.map((browser: Browser) => ({ browser })),
screenshotDirectory: '.vitest/screenshots',
},
// Hide console writing to keep output clean, as the unit tests do. What
// the card writes is still captured in the page, where a test can assert on
// it -- a `log` action is only observable there.
onConsoleLog: () => false,
// Keep the output clean, as the unit tests do, but hand over everything a
// failing test wrote: much of what the card reports is written nowhere
// else, and on CI nobody can look at the card themselves.
silent: 'passed-only',
},
});
+333
View File
@@ -498,6 +498,67 @@ __metadata:
languageName: node
linkType: hard
"@inquirer/ansi@npm:^2.0.7":
version: 2.0.7
resolution: "@inquirer/ansi@npm:2.0.7"
checksum: 10c0/a574f97a899f0d9346fa26b528b3f4a9ba6dcb9172288efb6b4314d8486470ed53d2f538200f66a25b843c6e0cbf83688c6d5174a8dc6eca853b291b09609c5a
languageName: node
linkType: hard
"@inquirer/confirm@npm:^6.0.11":
version: 6.1.1
resolution: "@inquirer/confirm@npm:6.1.1"
dependencies:
"@inquirer/core": "npm:^11.2.1"
"@inquirer/type": "npm:^4.0.7"
peerDependencies:
"@types/node": ">=18"
peerDependenciesMeta:
"@types/node":
optional: true
checksum: 10c0/4684406161c09327df830b4026f3165b31e13831276d215051586408ed434423263b15686393ce95a4b55058c1b7f9b08aa4b66f5ac930b47523fff75051d36f
languageName: node
linkType: hard
"@inquirer/core@npm:^11.2.1":
version: 11.2.1
resolution: "@inquirer/core@npm:11.2.1"
dependencies:
"@inquirer/ansi": "npm:^2.0.7"
"@inquirer/figures": "npm:^2.0.7"
"@inquirer/type": "npm:^4.0.7"
cli-width: "npm:^4.1.0"
fast-wrap-ansi: "npm:^0.2.0"
mute-stream: "npm:^3.0.0"
signal-exit: "npm:^4.1.0"
peerDependencies:
"@types/node": ">=18"
peerDependenciesMeta:
"@types/node":
optional: true
checksum: 10c0/b5be386cecd9e441ac2f9d3417a6ae1c4658b3ee6cdf5dae791211400f4de158851f81fca2245e2062833716f95366b9e1717770828cb7365e756c16e822f0d2
languageName: node
linkType: hard
"@inquirer/figures@npm:^2.0.7":
version: 2.0.7
resolution: "@inquirer/figures@npm:2.0.7"
checksum: 10c0/e0573dc9ad25fa3628d5164745e52852d8cd832a9918605b7716df2e37a0005a0aaf40b6d81cef2ca09cb708b200e61b82d1dcd17003f572577e233c19a9ec7b
languageName: node
linkType: hard
"@inquirer/type@npm:^4.0.7":
version: 4.0.7
resolution: "@inquirer/type@npm:4.0.7"
peerDependencies:
"@types/node": ">=18"
peerDependenciesMeta:
"@types/node":
optional: true
checksum: 10c0/80678ac1c6e19ce309909e4a54a69adc95697ea3abc2cb92f17b1bc52f4caadbcb4003ae7339fb5a70c0d36d3bde975e1bb4450069662f41c953a0d28695bb70
languageName: node
linkType: hard
"@isaacs/cliui@npm:^8.0.2":
version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2"
@@ -642,6 +703,20 @@ __metadata:
languageName: node
linkType: hard
"@mswjs/interceptors@npm:^0.41.3":
version: 0.41.9
resolution: "@mswjs/interceptors@npm:0.41.9"
dependencies:
"@open-draft/deferred-promise": "npm:^2.2.0"
"@open-draft/logger": "npm:^0.3.0"
"@open-draft/until": "npm:^2.0.0"
is-node-process: "npm:^1.2.0"
outvariant: "npm:^1.4.3"
strict-event-emitter: "npm:^0.5.1"
checksum: 10c0/2efff40877e07ce29846be76c2683177308a72a3ccfe4c096b496412a279acd92b5b9cdad40ad71c36b149a4a7d9e9b4e7d295893a8ba9661b43334f5784ecd8
languageName: node
linkType: hard
"@napi-rs/wasm-runtime@npm:^1.1.6":
version: 1.1.6
resolution: "@napi-rs/wasm-runtime@npm:1.1.6"
@@ -1011,6 +1086,37 @@ __metadata:
languageName: node
linkType: hard
"@open-draft/deferred-promise@npm:^2.2.0":
version: 2.2.0
resolution: "@open-draft/deferred-promise@npm:2.2.0"
checksum: 10c0/eafc1b1d0fc8edb5e1c753c5e0f3293410b40dde2f92688211a54806d4136887051f39b98c1950370be258483deac9dfd17cf8b96557553765198ef2547e4549
languageName: node
linkType: hard
"@open-draft/deferred-promise@npm:^3.0.0":
version: 3.0.0
resolution: "@open-draft/deferred-promise@npm:3.0.0"
checksum: 10c0/4dd697e55495e436be9536413cc9975e792e9ca7472e81e3d3d69e9b65cb678465aac90b463ac02f2b490c0581c4e9aa8a33d2a5857decbe2c6d9ffb310f8e1f
languageName: node
linkType: hard
"@open-draft/logger@npm:^0.3.0":
version: 0.3.0
resolution: "@open-draft/logger@npm:0.3.0"
dependencies:
is-node-process: "npm:^1.2.0"
outvariant: "npm:^1.4.0"
checksum: 10c0/90010647b22e9693c16258f4f9adb034824d1771d3baa313057b9a37797f571181005bc50415a934eaf7c891d90ff71dcd7a9d5048b0b6bb438f31bef2c7c5c1
languageName: node
linkType: hard
"@open-draft/until@npm:^2.0.0":
version: 2.1.0
resolution: "@open-draft/until@npm:2.1.0"
checksum: 10c0/61d3f99718dd86bb393fee2d7a785f961dcaf12f2055f0c693b27f4d0cd5f7a03d498a6d9289773b117590d794a43cd129366fd8e99222e4832f67b1653d54cf
languageName: node
linkType: hard
"@oxc-parser/binding-android-arm-eabi@npm:0.140.0":
version: 0.140.0
resolution: "@oxc-parser/binding-android-arm-eabi@npm:0.140.0"
@@ -2020,6 +2126,15 @@ __metadata:
languageName: node
linkType: hard
"@types/set-cookie-parser@npm:^2.4.10":
version: 2.4.10
resolution: "@types/set-cookie-parser@npm:2.4.10"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/010b0c582ea70a2088618b4725808e80c30cce296c19ec58e51d94e0fd1038201b7b99238bf3ea74e1894163c8037d10a4f1729de62b2801ce240ff070f43e76
languageName: node
linkType: hard
"@types/sizzle@npm:*":
version: 2.3.8
resolution: "@types/sizzle@npm:2.3.8"
@@ -2027,6 +2142,13 @@ __metadata:
languageName: node
linkType: hard
"@types/statuses@npm:^2.0.6":
version: 2.0.6
resolution: "@types/statuses@npm:2.0.6"
checksum: 10c0/dd88c220b0e2c6315686289525fd61472d2204d2e4bef4941acfb76bda01d3066f749ac74782aab5b537a45314fcd7d6261eefa40b6ec872691f5803adaa608d
languageName: node
linkType: hard
"@types/trusted-types@npm:^2.0.2":
version: 2.0.7
resolution: "@types/trusted-types@npm:2.0.7"
@@ -2417,6 +2539,7 @@ __metadata:
lodash-es: "npm:^4.17.21"
masonry-layout: "npm:^4.2.2"
moment: "npm:^2.30.1"
msw: "npm:^2.15.0"
p-queue: "npm:^8.0.1"
playwright: "npm:1.62.0"
prettier: "npm:^3.3.2"
@@ -3097,6 +3220,13 @@ __metadata:
languageName: node
linkType: hard
"cli-width@npm:^4.1.0":
version: 4.1.0
resolution: "cli-width@npm:4.1.0"
checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f
languageName: node
linkType: hard
"cliui@npm:^6.0.0":
version: 6.0.0
resolution: "cliui@npm:6.0.0"
@@ -3375,6 +3505,13 @@ __metadata:
languageName: node
linkType: hard
"cookie@npm:^1.1.1":
version: 1.1.1
resolution: "cookie@npm:1.1.1"
checksum: 10c0/79c4ddc0fcad9c4f045f826f42edf54bcc921a29586a4558b0898277fa89fb47be95bc384c2253f493af7b29500c830da28341274527328f18eba9f58afa112c
languageName: node
linkType: hard
"core-util-is@npm:~1.0.0":
version: 1.0.3
resolution: "core-util-is@npm:1.0.3"
@@ -4413,6 +4550,31 @@ __metadata:
languageName: node
linkType: hard
"fast-string-truncated-width@npm:^3.0.2":
version: 3.0.3
resolution: "fast-string-truncated-width@npm:3.0.3"
checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981
languageName: node
linkType: hard
"fast-string-width@npm:^3.0.2":
version: 3.0.2
resolution: "fast-string-width@npm:3.0.2"
dependencies:
fast-string-truncated-width: "npm:^3.0.2"
checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7
languageName: node
linkType: hard
"fast-wrap-ansi@npm:^0.2.0":
version: 0.2.2
resolution: "fast-wrap-ansi@npm:0.2.2"
dependencies:
fast-string-width: "npm:^3.0.2"
checksum: 10c0/1aa7be4f7cb86f4bdb14691cb6bcc0b8df8b3b89df142ade3ae1602332dcf6f990cd750a923cd581ca0847808cb4ec1aa5afaafa7a72f849e87a2a62c98fa370
languageName: node
linkType: hard
"fastest-levenshtein@npm:^1.0.16":
version: 1.0.16
resolution: "fastest-levenshtein@npm:1.0.16"
@@ -4966,6 +5128,13 @@ __metadata:
languageName: node
linkType: hard
"graphql@npm:^16.13.2":
version: 16.14.2
resolution: "graphql@npm:16.14.2"
checksum: 10c0/a95a96961eaff55cc9fe9d31fae6f33499ac988b972d07ea5085024cb1333f515b902f376e7393a5489aa82200a8aff3eb96580e4d1b69d702ed19b6eb1ce97a
languageName: node
linkType: hard
"ha-nunjucks@npm:~1.6.2":
version: 1.6.2
resolution: "ha-nunjucks@npm:1.6.2"
@@ -5035,6 +5204,16 @@ __metadata:
languageName: node
linkType: hard
"headers-polyfill@npm:^5.0.1":
version: 5.0.1
resolution: "headers-polyfill@npm:5.0.1"
dependencies:
"@types/set-cookie-parser": "npm:^2.4.10"
set-cookie-parser: "npm:^3.0.1"
checksum: 10c0/c269730a88a12c88718037aa71f178601f2b193ba8a37e276b6ced6b8f7e06fc1ac051f2a7acb0a8b4cc878407066555fdcdbb270e90374baaa472cb26af0c30
languageName: node
linkType: hard
"highlight.js@npm:^10.7.1":
version: 10.7.3
resolution: "highlight.js@npm:10.7.3"
@@ -5472,6 +5651,13 @@ __metadata:
languageName: node
linkType: hard
"is-node-process@npm:^1.2.0":
version: 1.2.0
resolution: "is-node-process@npm:1.2.0"
checksum: 10c0/5b24fda6776d00e42431d7bcd86bce81cb0b6cabeb944142fe7b077a54ada2e155066ad06dbe790abdb397884bdc3151e04a9707b8cd185099efbc79780573ed
languageName: node
linkType: hard
"is-npm@npm:^4.0.0":
version: 4.0.0
resolution: "is-npm@npm:4.0.0"
@@ -6877,6 +7063,39 @@ __metadata:
languageName: node
linkType: hard
"msw@npm:^2.15.0":
version: 2.15.0
resolution: "msw@npm:2.15.0"
dependencies:
"@inquirer/confirm": "npm:^6.0.11"
"@mswjs/interceptors": "npm:^0.41.3"
"@open-draft/deferred-promise": "npm:^3.0.0"
"@types/statuses": "npm:^2.0.6"
cookie: "npm:^1.1.1"
graphql: "npm:^16.13.2"
headers-polyfill: "npm:^5.0.1"
is-node-process: "npm:^1.2.0"
outvariant: "npm:^1.4.3"
path-to-regexp: "npm:^6.3.0"
picocolors: "npm:^1.1.1"
rettime: "npm:^0.11.11"
statuses: "npm:^2.0.2"
strict-event-emitter: "npm:^0.5.1"
tough-cookie: "npm:^6.0.1"
type-fest: "npm:^5.5.0"
until-async: "npm:^3.0.2"
yargs: "npm:^17.7.2"
peerDependencies:
typescript: ">= 4.8.x"
peerDependenciesMeta:
typescript:
optional: true
bin:
msw: cli/index.js
checksum: 10c0/0d3dbf1b062a82b3711ff195e10ffd9e6f2a5e337b4a0f08cabdb8d0b96c5c0c057f1cdcb67da14c356612554ea463fb5ad8f9c618b5ac009fc4eee43a83c94e
languageName: node
linkType: hard
"mute-stream@npm:^1.0.0":
version: 1.0.0
resolution: "mute-stream@npm:1.0.0"
@@ -6884,6 +7103,13 @@ __metadata:
languageName: node
linkType: hard
"mute-stream@npm:^3.0.0":
version: 3.0.0
resolution: "mute-stream@npm:3.0.0"
checksum: 10c0/12cdb36a101694c7a6b296632e6d93a30b74401873cf7507c88861441a090c71c77a58f213acadad03bc0c8fa186639dec99d68a14497773a8744320c136e701
languageName: node
linkType: hard
"mz@npm:^2.4.0":
version: 2.7.0
resolution: "mz@npm:2.7.0"
@@ -7415,6 +7641,13 @@ __metadata:
languageName: node
linkType: hard
"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3":
version: 1.4.3
resolution: "outvariant@npm:1.4.3"
checksum: 10c0/5976ca7740349cb8c71bd3382e2a762b1aeca6f33dc984d9d896acdf3c61f78c3afcf1bfe9cc633a7b3c4b295ec94d292048f83ea2b2594fae4496656eba992c
languageName: node
linkType: hard
"oxc-parser@npm:^0.140.0":
version: 0.140.0
resolution: "oxc-parser@npm:0.140.0"
@@ -7938,6 +8171,13 @@ __metadata:
languageName: node
linkType: hard
"path-to-regexp@npm:^6.3.0":
version: 6.3.0
resolution: "path-to-regexp@npm:6.3.0"
checksum: 10c0/73b67f4638b41cde56254e6354e46ae3a2ebc08279583f6af3d96fe4664fc75788f74ed0d18ca44fa4a98491b69434f9eee73b97bb5314bd1b5adb700f5c18d6
languageName: node
linkType: hard
"path-type@npm:^4.0.0":
version: 4.0.0
resolution: "path-type@npm:4.0.0"
@@ -8875,6 +9115,13 @@ __metadata:
languageName: node
linkType: hard
"rettime@npm:^0.11.11":
version: 0.11.11
resolution: "rettime@npm:0.11.11"
checksum: 10c0/021fc9d9870ce04f032952e63fc5576f3f8e7c9c15513b1a479a64646df90239802eef6d60a98cbfb6ac87bb623d4f120a8ee71193d02984e3d2915c28695f6e
languageName: node
linkType: hard
"reusify@npm:^1.0.4":
version: 1.0.4
resolution: "reusify@npm:1.0.4"
@@ -9261,6 +9508,13 @@ __metadata:
languageName: node
linkType: hard
"set-cookie-parser@npm:^3.0.1":
version: 3.1.2
resolution: "set-cookie-parser@npm:3.1.2"
checksum: 10c0/ea3d4fba5affd57f2a4c2e5172d701c4c17645879f9fddf1331622ae556210d840cdcb6666f0d71288ad545e31db5927bf49696051928e0186e079588cd5ddb0
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
@@ -9564,6 +9818,13 @@ __metadata:
languageName: node
linkType: hard
"statuses@npm:^2.0.2":
version: 2.0.2
resolution: "statuses@npm:2.0.2"
checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f
languageName: node
linkType: hard
"statuses@npm:~1.5.0":
version: 1.5.0
resolution: "statuses@npm:1.5.0"
@@ -9588,6 +9849,13 @@ __metadata:
languageName: node
linkType: hard
"strict-event-emitter@npm:^0.5.1":
version: 0.5.1
resolution: "strict-event-emitter@npm:0.5.1"
checksum: 10c0/f5228a6e6b6393c57f52f62e673cfe3be3294b35d6f7842fc24b172ae0a6e6c209fa83241d0e433fc267c503bc2f4ffdbe41a9990ff8ffd5ac425ec0489417f7
languageName: node
linkType: hard
"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3":
version: 4.2.3
resolution: "string-width@npm:4.2.3"
@@ -9792,6 +10060,13 @@ __metadata:
languageName: node
linkType: hard
"tagged-tag@npm:^1.0.0":
version: 1.0.0
resolution: "tagged-tag@npm:1.0.0"
checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1
languageName: node
linkType: hard
"tar@npm:^6.1.11, tar@npm:^6.1.2":
version: 6.2.0
resolution: "tar@npm:6.2.0"
@@ -9949,6 +10224,24 @@ __metadata:
languageName: node
linkType: hard
"tldts-core@npm:^7.4.10":
version: 7.4.10
resolution: "tldts-core@npm:7.4.10"
checksum: 10c0/a5f42aad8aa8adfc57d33e9bd5c43094e9de226dce4851867ea7014d8679040b4c01c500b9947595ab22cdfe9783ea5d88a7c0be2092d19a14f75c7cd4671181
languageName: node
linkType: hard
"tldts@npm:^7.0.5":
version: 7.4.10
resolution: "tldts@npm:7.4.10"
dependencies:
tldts-core: "npm:^7.4.10"
bin:
tldts: bin/cli.js
checksum: 10c0/a9a85d260bd0bfd046794f140667aae0975da4a3aa805d180b0808b672a6fa7de0fc9f36110c3bdeed87ad780e961e1e5bb1ed8b41f8aa3ad5d5fb469e70ba38
languageName: node
linkType: hard
"to-readable-stream@npm:^1.0.0":
version: 1.0.0
resolution: "to-readable-stream@npm:1.0.0"
@@ -9991,6 +10284,15 @@ __metadata:
languageName: node
linkType: hard
"tough-cookie@npm:^6.0.1":
version: 6.0.2
resolution: "tough-cookie@npm:6.0.2"
dependencies:
tldts: "npm:^7.0.5"
checksum: 10c0/5ff521a476a3c540821352125a5d481c8d2fe16035de7e0efda4df120f290c95500a0e9b51ea0aa56343955be482e014c30f5ba73e04b93ba138e4e855cb9e89
languageName: node
linkType: hard
"tr46@npm:^4.1.1":
version: 4.1.1
resolution: "tr46@npm:4.1.1"
@@ -10134,6 +10436,15 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^5.5.0":
version: 5.8.0
resolution: "type-fest@npm:5.8.0"
dependencies:
tagged-tag: "npm:^1.0.0"
checksum: 10c0/c8aae118a763d550a9552a511dff6b71840a23dab4edf693cf1c4df22596942794e6f6723389bd9036a90182249d915158bacf0815a1ae87f05f901b1d5f574e
languageName: node
linkType: hard
"typedarray-to-buffer@npm:^3.1.5":
version: 3.1.5
resolution: "typedarray-to-buffer@npm:3.1.5"
@@ -10280,6 +10591,13 @@ __metadata:
languageName: node
linkType: hard
"until-async@npm:^3.0.2":
version: 3.0.2
resolution: "until-async@npm:3.0.2"
checksum: 10c0/61c8b03895dbe18fe3d90316d0a1894e0c131ea4b1673f6ce78eed993d0bb81bbf4b7adf8477e9ff7725782a76767eed9d077561cfc9f89b4a1ebe61f7c9828e
languageName: node
linkType: hard
"update-browserslist-db@npm:^1.0.13":
version: 1.0.13
resolution: "update-browserslist-db@npm:1.0.13"
@@ -10992,6 +11310,21 @@ __metadata:
languageName: node
linkType: hard
"yargs@npm:^17.7.2":
version: 17.7.3
resolution: "yargs@npm:17.7.3"
dependencies:
cliui: "npm:^8.0.1"
escalade: "npm:^3.1.1"
get-caller-file: "npm:^2.0.5"
require-directory: "npm:^2.1.1"
string-width: "npm:^4.2.3"
y18n: "npm:^5.0.5"
yargs-parser: "npm:^21.1.1"
checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f
languageName: node
linkType: hard
"yocto-queue@npm:^0.1.0":
version: 0.1.0
resolution: "yocto-queue@npm:0.1.0"