mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-09 15:34:02 +01:00
2c8d6c1a02
Adds Playwright end-to-end tests covering three targets: - the demo build - a new lightweight test app exercising several scenarios (theming, admin/non-admin sidebar, panel navigation, more-info dialog) - the component gallery Includes the gulp/rspack build infra for the test app and an "E2E Tests" GitHub Actions workflow that builds each target once, shares it via artifacts, and runs the suites on Chromium and mobile Chrome. Browser install is cached and retried to avoid intermittent download stalls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
/**
|
|
* E2E tests for the HA gallery (port 8100).
|
|
*
|
|
* Each component page is tested by navigating to its hash and asserting that
|
|
* the demo content renders without JS errors and the page element is visible.
|
|
*
|
|
* Run with:
|
|
* yarn test:e2e:gallery
|
|
*/
|
|
import { test, expect, type Page } from "@playwright/test";
|
|
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Navigate to a gallery page via hash and wait for it to render. */
|
|
async function goToGalleryPage(page: Page, hash: string) {
|
|
// First visit to let ha-gallery boot up
|
|
await page.goto(`/#${hash}`);
|
|
await page.waitForSelector("ha-gallery", { state: "attached" });
|
|
// Wait for the demo element to appear in ha-gallery's shadow root.
|
|
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
|
|
// page-description is only rendered for pages that have a description field,
|
|
// so we cannot use it as a universal readiness signal.
|
|
const demoTag = `demo-${hash.replace("/", "-")}`;
|
|
await page.waitForFunction((tag) => {
|
|
const gallery = document.querySelector("ha-gallery") as any;
|
|
return gallery?.shadowRoot?.querySelector(tag) != null;
|
|
}, demoTag);
|
|
}
|
|
|
|
/** Assert a gallery page loads without console errors.
|
|
* Demo elements live inside ha-gallery's shadow root — use >> to pierce it.
|
|
*/
|
|
async function assertPageLoads(page: Page, hash: string, selector: string) {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
|
|
await goToGalleryPage(page, hash);
|
|
|
|
// Pierce ha-gallery's shadow root with >>
|
|
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
|
|
timeout: SHELL_TIMEOUT,
|
|
});
|
|
|
|
const realErrors = errors.filter(
|
|
(e) => !IGNORED_ERRORS.some((re) => re.test(e))
|
|
);
|
|
expect(
|
|
realErrors,
|
|
`JS errors on ${hash}: ${realErrors.join("; ")}`
|
|
).toHaveLength(0);
|
|
}
|
|
|
|
// Errors that are gallery-harness artifacts rather than bugs in the component
|
|
// under test. The gallery feeds demos a synchronous mock `hass`, but migrated
|
|
// components now read `localize`/formatters from Lit context, which resolves
|
|
// asynchronously — so a demo can render one frame before the context lands and
|
|
// throw a transient "undefined" error during init. These don't prevent the
|
|
// demo from rendering (the toBeAttached check above still has to pass), and
|
|
// they're timing-dependent, so we filter the whole init-error family here.
|
|
const IGNORED_ERRORS: RegExp[] = [
|
|
/ResizeObserver/,
|
|
/Non-Error/,
|
|
/Extension context/,
|
|
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
|
/^Object$/,
|
|
// localize consumed from context before it resolves (`this.localize` /
|
|
// `this._localize is not a function`, or `hass.localize` read as undefined).
|
|
/_?localize is not a function/,
|
|
/Cannot read properties of undefined \(reading 'localize'\)/,
|
|
// Formatters consumed from context before it resolves.
|
|
/Cannot read properties of undefined \(reading 'format[A-Za-z]+'\)/,
|
|
// hass API methods consumed from context before it resolves (e.g. the
|
|
// update demo fetches release notes via callWS during init).
|
|
/Cannot read properties of undefined \(reading 'call(WS|Api|Service)'\)/,
|
|
// locale fields read before the mock locale is wired up.
|
|
/Cannot read properties of undefined \(reading '(time|number|date)_format'\)/,
|
|
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
|
// from mock state data — pre-existing gallery data issue.
|
|
/Cannot read properties of undefined \(reading 'some'\)/,
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gallery shell
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Gallery shell", () => {
|
|
test("page loads and ha-gallery mounts", async ({ page }) => {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
|
|
await page.goto("/");
|
|
await expect(page.locator("ha-gallery")).toBeAttached({
|
|
timeout: SHELL_TIMEOUT,
|
|
});
|
|
|
|
const realErrors = errors.filter(
|
|
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
|
);
|
|
expect(realErrors).toHaveLength(0);
|
|
});
|
|
|
|
test("sidebar renders navigation links", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.waitForSelector("ha-gallery", { state: "attached" });
|
|
// The gallery drawer sidebar is inside ha-gallery's shadow root
|
|
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
|
|
timeout: QUICK_TIMEOUT,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const componentPages: { name: string; selector: string }[] = [
|
|
{ name: "ha-alert", selector: "demo-components-ha-alert" },
|
|
{ name: "ha-badge", selector: "demo-components-ha-badge" },
|
|
{ name: "ha-bar", selector: "demo-components-ha-bar" },
|
|
{ name: "ha-button", selector: "demo-components-ha-button" },
|
|
{ name: "ha-chips", selector: "demo-components-ha-chips" },
|
|
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
|
|
{
|
|
name: "ha-control-circular-slider",
|
|
selector: "demo-components-ha-control-circular-slider",
|
|
},
|
|
{
|
|
name: "ha-control-number-buttons",
|
|
selector: "demo-components-ha-control-number-buttons",
|
|
},
|
|
{
|
|
name: "ha-control-select-menu",
|
|
selector: "demo-components-ha-control-select-menu",
|
|
},
|
|
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
|
|
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
|
|
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
|
|
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
|
|
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
|
|
{
|
|
name: "ha-expansion-panel",
|
|
selector: "demo-components-ha-expansion-panel",
|
|
},
|
|
{ name: "ha-faded", selector: "demo-components-ha-faded" },
|
|
{ name: "ha-form", selector: "demo-components-ha-form" },
|
|
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
|
|
{
|
|
name: "ha-hs-color-picker",
|
|
selector: "demo-components-ha-hs-color-picker",
|
|
},
|
|
{ name: "ha-input", selector: "demo-components-ha-input" },
|
|
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
|
|
{ name: "ha-list", selector: "demo-components-ha-list" },
|
|
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
|
|
{
|
|
name: "ha-progress-button",
|
|
selector: "demo-components-ha-progress-button",
|
|
},
|
|
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
|
|
{ name: "ha-selector", selector: "demo-components-ha-selector" },
|
|
{ name: "ha-slider", selector: "demo-components-ha-slider" },
|
|
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
|
|
{ name: "ha-switch", selector: "demo-components-ha-switch" },
|
|
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
|
|
{ name: "ha-tip", selector: "demo-components-ha-tip" },
|
|
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
|
|
{
|
|
name: "ha-adaptive-dialog",
|
|
selector: "demo-components-ha-adaptive-dialog",
|
|
},
|
|
{
|
|
name: "ha-adaptive-popover",
|
|
selector: "demo-components-ha-adaptive-popover",
|
|
},
|
|
];
|
|
|
|
test.describe("Components", () => {
|
|
for (const { name, selector } of componentPages) {
|
|
test(`${name} renders without errors`, async ({ page }) => {
|
|
await assertPageLoads(page, `components/${name}`, selector);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// More-info pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const moreInfoPages: { name: string; selector: string }[] = [
|
|
{ name: "light", selector: "demo-more-info-light" },
|
|
{ name: "climate", selector: "demo-more-info-climate" },
|
|
{ name: "cover", selector: "demo-more-info-cover" },
|
|
{ name: "fan", selector: "demo-more-info-fan" },
|
|
{ name: "humidifier", selector: "demo-more-info-humidifier" },
|
|
{ name: "input-number", selector: "demo-more-info-input-number" },
|
|
{ name: "input-text", selector: "demo-more-info-input-text" },
|
|
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
|
|
{ name: "lock", selector: "demo-more-info-lock" },
|
|
{ name: "media-player", selector: "demo-more-info-media-player" },
|
|
{ name: "number", selector: "demo-more-info-number" },
|
|
{ name: "scene", selector: "demo-more-info-scene" },
|
|
{ name: "timer", selector: "demo-more-info-timer" },
|
|
{ name: "update", selector: "demo-more-info-update" },
|
|
{ name: "vacuum", selector: "demo-more-info-vacuum" },
|
|
{ name: "water-heater", selector: "demo-more-info-water-heater" },
|
|
];
|
|
|
|
test.describe("More-info dialogs", () => {
|
|
for (const { name, selector } of moreInfoPages) {
|
|
test(`more-info ${name} renders without errors`, async ({ page }) => {
|
|
await assertPageLoads(page, `more-info/${name}`, selector);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lovelace card pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const lovelacePages: { name: string; selector: string }[] = [
|
|
{ name: "area-card", selector: "demo-lovelace-area-card" },
|
|
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
|
|
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
|
|
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
|
|
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
|
|
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
|
|
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
|
|
{
|
|
name: "grid-and-stack-card",
|
|
selector: "demo-lovelace-grid-and-stack-card",
|
|
},
|
|
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
|
|
{ name: "light-card", selector: "demo-lovelace-light-card" },
|
|
{ name: "map-card", selector: "demo-lovelace-map-card" },
|
|
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
|
|
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
|
|
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
|
|
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
|
|
{
|
|
name: "picture-elements-card",
|
|
selector: "demo-lovelace-picture-elements-card",
|
|
},
|
|
{
|
|
name: "picture-entity-card",
|
|
selector: "demo-lovelace-picture-entity-card",
|
|
},
|
|
{
|
|
name: "picture-glance-card",
|
|
selector: "demo-lovelace-picture-glance-card",
|
|
},
|
|
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
|
|
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
|
|
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
|
|
];
|
|
|
|
test.describe("Lovelace cards", () => {
|
|
for (const { name, selector } of lovelacePages) {
|
|
test(`${name} renders without errors`, async ({ page }) => {
|
|
await assertPageLoads(page, `lovelace/${name}`, selector);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Specific interaction tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Component interactions", () => {
|
|
test("ha-alert renders all four types", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-alert");
|
|
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
|
|
// The demo uses property binding (.alertType) not attribute binding,
|
|
// so we verify that multiple ha-alert elements are present.
|
|
const alerts = demo.locator("ha-alert");
|
|
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
|
|
// There should be at least 4 alerts (one per type)
|
|
await expect(alerts)
|
|
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
|
|
.catch(async () => {
|
|
// If not exactly 4, just verify there are some (demo may include more)
|
|
const count = await alerts.count();
|
|
expect(count).toBeGreaterThanOrEqual(4);
|
|
});
|
|
});
|
|
|
|
test("ha-button renders primary action button", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-button");
|
|
const demo = page.locator("ha-gallery >> demo-components-ha-button");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
|
timeout: QUICK_TIMEOUT,
|
|
});
|
|
});
|
|
|
|
test("ha-control-slider can be found in DOM", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-control-slider");
|
|
const demo = page.locator(
|
|
"ha-gallery >> demo-components-ha-control-slider"
|
|
);
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
|
timeout: QUICK_TIMEOUT,
|
|
});
|
|
});
|
|
|
|
test("ha-form renders schema-driven fields", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-form");
|
|
const demo = page.locator("ha-gallery >> demo-components-ha-form");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
await expect(demo.locator("ha-form").first()).toBeAttached({
|
|
timeout: QUICK_TIMEOUT,
|
|
});
|
|
});
|
|
|
|
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-dialog");
|
|
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
});
|
|
|
|
test("tile-card renders entity state", async ({ page }) => {
|
|
await goToGalleryPage(page, "lovelace/tile-card");
|
|
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
|
timeout: QUICK_TIMEOUT,
|
|
});
|
|
});
|
|
|
|
test("more-info light renders controls", async ({ page }) => {
|
|
await goToGalleryPage(page, "more-info/light");
|
|
const demo = page.locator("ha-gallery >> demo-more-info-light");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
// Light more-info should contain a brightness or color-temp control
|
|
await expect(
|
|
demo
|
|
.locator("ha-control-slider, ha-more-info-light, more-info-content")
|
|
.first()
|
|
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
});
|
|
|
|
test("more-info cover renders position controls", async ({ page }) => {
|
|
await goToGalleryPage(page, "more-info/cover");
|
|
const demo = page.locator("ha-gallery >> demo-more-info-cover");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
});
|
|
|
|
test("ha-gauge renders a gauge element", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-gauge");
|
|
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
// ha-gauge page is markdown-based; gauge elements render in the description area
|
|
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
|
timeout: QUICK_TIMEOUT,
|
|
});
|
|
});
|
|
|
|
test("ha-switch toggles state on click", async ({ page }) => {
|
|
await goToGalleryPage(page, "components/ha-switch");
|
|
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
|
|
|
// Find the first interactive (non-disabled) switch. Pull its checked state
|
|
// from the property — ha-switch toggles via property, not the attribute.
|
|
const switchEl = demo.locator("ha-switch:not([disabled])").first();
|
|
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
|
|
|
|
const before = await switchEl.evaluate((el: any) => el.checked === true);
|
|
await switchEl.click();
|
|
await expect
|
|
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
|
|
timeout: QUICK_TIMEOUT,
|
|
})
|
|
.toBe(!before);
|
|
});
|
|
});
|