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 {