test: Also run firefox & webkit automated tests (#2651)
This commit is contained in:
+142
-18
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user