mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-07 22:45:19 +01:00
369 lines
15 KiB
TypeScript
369 lines
15 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 Lit-context init-error family that used to live here is gone:
|
|
// ha-gallery now provides fallback contexts for every demo (mirroring the real
|
|
// app's root), so context-consuming components resolve `localize`, formatters,
|
|
// config, etc. synchronously instead of throwing during init.
|
|
const IGNORED_ERRORS: RegExp[] = [
|
|
/ResizeObserver/,
|
|
/Non-Error/,
|
|
/Extension context/,
|
|
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
|
/^Object$/,
|
|
// 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);
|
|
});
|
|
});
|