Compare commits

...

8 Commits

Author SHA1 Message Date
Aidan Timson 71c2a8c028 Fix 2026-07-08 10:25:42 +01:00
Aidan Timson a358f5421f Ignore playwright mcp files 2026-07-08 10:01:26 +01:00
Aidan Timson 9e3a697d54 Fix 2026-07-08 09:54:26 +01:00
Aidan Timson 0d35c25b45 Add panel route e2e coverage 2026-07-08 09:54:26 +01:00
Aidan Timson db0813904c Add Settings panel e2e route coverage (#53044) 2026-07-08 10:53:32 +03:00
karwosts b30668995d Suggest picture card for image entities (#53046) 2026-07-08 06:00:58 +02:00
Petar Petrov 9ba003f86a Only re-render context consumers when the selected value changes (#52885)
The consume* decorators paired @lit/context's @consume({ subscribe: true })
with @transform to narrow a context down to a single value. ContextConsumer
calls host.requestUpdate() on every provider notification, so every consumer
ran an (often empty) render cycle on every unrelated statesContext change.

Replace the built-in consumer with a small reactive controller that subscribes
the same way but leaves update scheduling to the property setter, which already
gates on hasChanged. A render is now requested only when the selected value
actually changes.
2026-07-07 18:31:34 +02:00
Krisjanis Lejejs 0536e2cd2a Sync cloud page onboarding_postponed key, update styles (#53043) 2026-07-07 14:37:38 +01:00
17 changed files with 911 additions and 75 deletions
+2
View File
@@ -61,6 +61,8 @@ test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# MCP server
.playwright-mcp/
# AI tooling
.claude
+1 -1
View File
@@ -22,7 +22,7 @@ export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
export interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
// Onboarding postponed server-side (maps to is_onboarding_postponed); hides
// Onboarding postponed server-side (maps to onboarding_postponed); hides
// the onboarding UI without marking it completed.
postponed: boolean;
remote: boolean;
+4 -4
View File
@@ -57,7 +57,7 @@ const cloudStatus: CloudStatusLoggedIn = {
remote_certificate_status: "ready",
http_use_ssl: false,
active_subscription: true,
is_onboarding_postponed: false,
onboarding_postponed: false,
onboarding_completed: true,
prefs: {
google_enabled: true,
@@ -124,7 +124,7 @@ const applyScenario = () => {
? [...ONBOARDING_ITEMS]
: [];
cloudStatus.onboarding_completed = scenario.onboarded;
cloudStatus.is_onboarding_postponed = scenario.postponed;
cloudStatus.onboarding_postponed = scenario.postponed;
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null;
@@ -165,7 +165,7 @@ const syncScenarioFromStatus = () => {
const scenario = getCloudDemoScenario();
const next = {
onboarded: cloudStatus.onboarding_completed,
postponed: cloudStatus.is_onboarding_postponed,
postponed: cloudStatus.onboarding_postponed,
remote: cloudStatus.prefs.remote_enabled,
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
@@ -201,7 +201,7 @@ export const mockCloud = (hass: MockHomeAssistant) => {
cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
cloudStatus.is_onboarding_postponed = true;
cloudStatus.onboarding_postponed = true;
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
+85 -8
View File
@@ -1,5 +1,7 @@
import { consume } from "@lit/context";
import { ContextEvent } from "@lit/context";
import type { Context, ContextCallback } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveElement } from "lit";
import type {
HomeAssistant,
HomeAssistantInternationalization,
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
return previous;
};
/**
* Reactive controller that subscribes to a Lit context and assigns each
* delivered value to a host property — WITHOUT forcing a host update on every
* delivery.
*
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
* unconditionally on every provider notification. For a hot context such as
* `statesContext` (replaced on every entity state change) that means every
* consumer runs an (often empty) update/render cycle on every unrelated state
* change, even when the value it actually reads is unchanged.
*
* This controller instead leaves update scheduling to the property's own
* setter. Combined with {@link transform}, that setter only requests an update
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
* with `hasChanged`), so unrelated context churn no longer triggers renders.
*/
class ContextSubscriptionController<ValueType> implements ReactiveController {
private _unsubscribe?: () => void;
constructor(
private readonly _host: ReactiveElement,
private readonly _context: Context<unknown, ValueType>,
private readonly _assign: (value: ValueType) => void
) {
this._host.addController(this);
}
public hostConnected(): void {
this._host.dispatchEvent(
new ContextEvent(this._context, this._host, this._callback, true)
);
}
public hostDisconnected(): void {
this._unsubscribe?.();
this._unsubscribe = undefined;
}
// Class field arrow function so the identity is stable per instance, which the
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
private readonly _callback: ContextCallback<ValueType> = (
value,
unsubscribe
) => {
// A different provider answered (e.g. re-parenting); drop the stale one.
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
this._unsubscribe();
}
this._unsubscribe = unsubscribe;
// Assign through the property setter, which decides — via `hasChanged` —
// whether an update is actually needed. We intentionally never call
// `host.requestUpdate()` here.
this._assign(value);
};
}
/**
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
* host update on every provider notification — see
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
* update is scheduled only when the derived value actually changes.
*/
const subscribeContext =
<ValueType>(context: Context<unknown, ValueType>) =>
(proto: object, propertyKey: string): void => {
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
(host) => {
new ContextSubscriptionController<ValueType>(
host as ReactiveElement,
context,
(value) => {
(host as unknown as Record<string, unknown>)[propertyKey] = value;
}
);
}
);
};
const composeDecorator = <T, V>(
context: Parameters<typeof consume>[0]["context"],
context: Context<unknown, T>,
watchKey: string | undefined,
select: (this: unknown, value: T) => V | undefined
) => {
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({ context, subscribe: true });
const consumeDec = subscribeContext<T>(context);
return (proto: any, propertyKey: string) => {
transformDec(proto, propertyKey);
consumeDec(proto, propertyKey);
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({
context: statesContext,
subscribe: true,
});
const consumeDec = subscribeContext<HassEntities>(statesContext);
transformDec(proto as never, propertyKey);
consumeDec(proto as never, propertyKey);
};
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
* whenever `localize` changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
+4 -3
View File
@@ -360,9 +360,10 @@ export const cloudBackupHealth = (
return "failed";
}
const next = backupConfig?.next_automatic_backup
? new Date(backupConfig.next_automatic_backup).getTime()
: 0;
const next =
backupConfig?.next_automatic_backup &&
new Date(backupConfig.next_automatic_backup).getTime();
if (next && next < Date.now() - BACKUP_OVERDUE_MARGIN_MS) {
return "old";
}
+1 -1
View File
@@ -52,7 +52,7 @@ export interface CloudStatusLoggedIn {
remote_certificate_status: RemoteCertificateStatus | null;
http_use_ssl: boolean;
active_subscription: boolean;
is_onboarding_postponed: boolean;
onboarding_postponed: boolean;
onboarding_completed: boolean;
}
@@ -147,14 +147,15 @@ export class CloudAccountOnboarding extends LitElement {
display: block;
width: 100%;
}
ha-card.onboarding-card {
container-type: inline-size;
}
.ready-card {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-8);
flex-direction: column;
gap: var(--ha-space-6);
}
.ready-left {
flex: 1.1;
display: flex;
flex-direction: column;
min-width: 0;
@@ -177,16 +178,13 @@ export class CloudAccountOnboarding extends LitElement {
margin-top: var(--ha-space-4);
}
.ready-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--ha-space-4) var(--ha-space-4);
gap: var(--ha-space-4);
}
@media (max-width: 600px) {
.ready-card {
flex-direction: column;
align-items: stretch;
gap: var(--ha-space-5);
@container (max-width: 450px) {
.ready-grid {
grid-template-columns: 1fr;
}
}
.ready-chip {
@@ -119,7 +119,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
private get _onboarded(): boolean {
return (
this.cloudStatus.onboarding_completed ||
this.cloudStatus.is_onboarding_postponed
this.cloudStatus.onboarding_postponed
);
}
@@ -0,0 +1,16 @@
import { computeDomain } from "../../../common/entity/compute_domain";
import type { PictureCardConfig } from "../cards/types";
import type { CardSuggestionProvider } from "./types";
export const pictureCardSuggestions: CardSuggestionProvider<PictureCardConfig> =
{
getEntitySuggestion(_hass, entityId) {
if (computeDomain(entityId) !== "image") return null;
return {
config: {
type: "picture",
image_entity: entityId,
},
};
},
};
@@ -5,6 +5,7 @@ import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestion
import { humidifierCardSuggestions } from "./hui-humidifier-card-suggestions";
import { mapCardSuggestions } from "./hui-map-card-suggestions";
import { mediaControlCardSuggestions } from "./hui-media-control-card-suggestions";
import { pictureCardSuggestions } from "./hui-picture-card-suggestions";
import { pictureEntityCardSuggestions } from "./hui-picture-entity-card-suggestions";
import { plantStatusCardSuggestions } from "./hui-plant-status-card-suggestions";
import { statisticsGraphCardSuggestions } from "./hui-statistics-graph-card-suggestions";
@@ -23,6 +24,7 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
humidifier: humidifierCardSuggestions,
map: mapCardSuggestions,
"media-control": mediaControlCardSuggestions,
picture: pictureCardSuggestions,
"picture-entity": pictureEntityCardSuggestions,
"plant-status": plantStatusCardSuggestions,
"statistics-graph": statisticsGraphCardSuggestions,
@@ -0,0 +1,417 @@
import { afterEach, describe, expect, it } from "vitest";
import { ContextProvider } from "@lit/context";
import { html, LitElement } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../../src/data/context";
import {
consumeEntityRegistryEntry,
consumeEntityState,
consumeEntityStates,
consumeLocalize,
} from "../../../src/common/decorators/consume-context-entry";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
import type { HomeAssistantInternationalization } from "../../../src/types";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
const makeEntity = (entityId: string, stateValue: string): HassEntity =>
({
entity_id: entityId,
state: stateValue,
attributes: {},
last_changed: "2024-01-01T00:00:00Z",
last_updated: "2024-01-01T00:00:00Z",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const makeRegistryEntry = (
entityId: string,
name: string
): EntityRegistryDisplayEntry => ({
entity_id: entityId,
name,
labels: [],
});
const makeI18n = (localize: LocalizeFunc): HomeAssistantInternationalization =>
({ localize }) as HomeAssistantInternationalization;
declare global {
interface HTMLElementTagNameMap {
"test-consume-entity-state": TestConsumeEntityState;
"test-consume-entity-states": TestConsumeEntityStates;
"test-consume-registry-entry": TestConsumeRegistryEntry;
"test-consume-localize": TestConsumeLocalize;
"test-consume-localize-no-state": TestConsumeLocalizeNoState;
}
}
// --- Test consumers ----------------------------------------------------------
// Counts update cycles via `updated()` (not `render()`, which lint forbids
// assigning to `this` in). One render() runs per update cycle, so this is the
// render count. With the fix, an unrelated context change schedules no update,
// so this counter does not advance.
class RenderCounter extends LitElement {
public renderCount = 0;
protected updated(changed: PropertyValues) {
super.updated(changed);
this.renderCount += 1;
}
}
@customElement("test-consume-entity-state")
class TestConsumeEntityState extends RenderCounter {
@property({ attribute: false }) public entityId = "light.kitchen";
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
public stateObj?: HassEntity;
protected render() {
return html`${this.stateObj?.state ?? "none"}`;
}
}
@customElement("test-consume-entity-states")
class TestConsumeEntityStates extends RenderCounter {
@property({ attribute: false }) public entityIds: string[] = [
"light.kitchen",
];
@state()
@consumeEntityStates({ entityIdPath: ["entityIds"] })
public stateObjs?: Record<string, HassEntity>;
protected render() {
return html`${Object.keys(this.stateObjs ?? {}).length}`;
}
}
@customElement("test-consume-registry-entry")
class TestConsumeRegistryEntry extends RenderCounter {
@property({ attribute: false }) public entityId = "light.kitchen";
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["entityId"] })
public entry?: EntityRegistryDisplayEntry;
protected render() {
return html`${this.entry?.name ?? "none"}`;
}
}
@customElement("test-consume-localize")
class TestConsumeLocalize extends RenderCounter {
@state()
@consumeLocalize()
public localize?: LocalizeFunc;
protected render() {
return html`${this.localize ? "set" : "none"}`;
}
}
// Mirrors the ~10 call sites that use `@consumeLocalize()` WITHOUT `@state()`.
@customElement("test-consume-localize-no-state")
class TestConsumeLocalizeNoState extends RenderCounter {
@consumeLocalize()
public localize?: LocalizeFunc;
protected render() {
return html`${this.localize ? "set" : "none"}`;
}
}
// --- Helpers -----------------------------------------------------------------
let host: HTMLDivElement | undefined;
afterEach(() => {
host?.remove();
host = undefined;
});
const mount = async <T extends LitElement>(
tag: string,
context: any,
initialValue: unknown
): Promise<{ el: T; provider: ContextProvider<any> }> => {
host = document.createElement("div");
document.body.appendChild(host);
const provider = new ContextProvider(host, {
context,
initialValue,
});
const el = document.createElement(tag) as T;
// The consumer must be a connected DOM descendant of the provider host.
host.appendChild(el);
await el.updateComplete;
return { el, provider };
};
// --- Tests -------------------------------------------------------------------
describe("consumeEntityState", () => {
it("renders on mount and reads the watched entity", async () => {
const { el } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{
"light.kitchen": makeEntity("light.kitchen", "on"),
"light.bedroom": makeEntity("light.bedroom", "on"),
}
);
expect(el.stateObj?.state).toBe("on");
expect(el.renderCount).toBe(1);
});
it("does NOT re-render when an unrelated entity changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
);
expect(el.renderCount).toBe(1);
// New states object, but the watched entity keeps the same reference.
provider.setValue({
"light.kitchen": kitchen,
"light.bedroom": makeEntity("light.bedroom", "off"),
});
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when the watched entity changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{ "light.kitchen": kitchen }
);
expect(el.renderCount).toBe(1);
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
await el.updateComplete;
expect(el.stateObj?.state).toBe("off");
expect(el.renderCount).toBe(2);
});
it("re-renders when the watched host property changes", async () => {
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{
"light.kitchen": makeEntity("light.kitchen", "on"),
"light.bedroom": makeEntity("light.bedroom", "off"),
}
);
expect(el.stateObj?.state).toBe("on");
el.entityId = "light.bedroom";
await el.updateComplete;
expect(el.stateObj?.state).toBe("off");
// The provider is still wired, but churn that leaves the watched entity
// untouched must not render.
const renderCount = el.renderCount;
provider.setValue({
"light.kitchen": makeEntity("light.kitchen", "on"),
"light.bedroom": el.stateObj!,
});
await el.updateComplete;
expect(el.renderCount).toBe(renderCount);
});
it("clears the value and re-renders when the watched entity is removed", async () => {
const { el, provider } = await mount<TestConsumeEntityState>(
"test-consume-entity-state",
statesContext,
{ "light.kitchen": makeEntity("light.kitchen", "on") }
);
expect(el.stateObj?.state).toBe("on");
expect(el.renderCount).toBe(1);
provider.setValue({});
await el.updateComplete;
expect(el.stateObj).toBeUndefined();
expect(el.renderCount).toBe(2);
});
});
describe("ContextSubscriptionController lifecycle", () => {
it("mounts without a provider: value stays undefined and renders once", async () => {
host = document.createElement("div");
document.body.appendChild(host);
const el = document.createElement(
"test-consume-entity-state"
) as TestConsumeEntityState;
host.appendChild(el);
await el.updateComplete;
expect(el.stateObj).toBeUndefined();
expect(el.renderCount).toBe(1);
});
it("unsubscribes on disconnect and re-subscribes on reconnect", async () => {
host = document.createElement("div");
document.body.appendChild(host);
const provider = new ContextProvider(host, {
context: statesContext,
initialValue: { "light.kitchen": makeEntity("light.kitchen", "on") },
});
const el = document.createElement(
"test-consume-entity-state"
) as TestConsumeEntityState;
host.appendChild(el);
await el.updateComplete;
expect(el.stateObj?.state).toBe("on");
// Disconnect, then push an update — the disconnected element must not render.
el.remove();
const renderCount = el.renderCount;
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
await el.updateComplete;
expect(el.renderCount).toBe(renderCount);
// Reconnect — it must re-subscribe and pick up the current value.
host.appendChild(el);
await el.updateComplete;
expect(el.stateObj?.state).toBe("off");
});
});
describe("consumeEntityStates", () => {
it("does NOT re-render when an entity outside the watched set changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityStates>(
"test-consume-entity-states",
statesContext,
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
);
expect(el.renderCount).toBe(1);
provider.setValue({
"light.kitchen": kitchen,
"light.bedroom": makeEntity("light.bedroom", "off"),
});
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when a watched entity changes", async () => {
const kitchen = makeEntity("light.kitchen", "on");
const { el, provider } = await mount<TestConsumeEntityStates>(
"test-consume-entity-states",
statesContext,
{ "light.kitchen": kitchen }
);
expect(el.renderCount).toBe(1);
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
await el.updateComplete;
expect(el.renderCount).toBe(2);
});
});
describe("consumeEntityRegistryEntry", () => {
it("does NOT re-render when an unrelated registry entry changes", async () => {
const kitchen = makeRegistryEntry("light.kitchen", "Kitchen");
const { el, provider } = await mount<TestConsumeRegistryEntry>(
"test-consume-registry-entry",
entitiesContext,
{
"light.kitchen": kitchen,
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom"),
}
);
expect(el.entry?.name).toBe("Kitchen");
expect(el.renderCount).toBe(1);
provider.setValue({
"light.kitchen": kitchen,
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom 2"),
});
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when the watched registry entry changes", async () => {
const { el, provider } = await mount<TestConsumeRegistryEntry>(
"test-consume-registry-entry",
entitiesContext,
{
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen"),
}
);
expect(el.renderCount).toBe(1);
provider.setValue({
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen renamed"),
});
await el.updateComplete;
expect(el.entry?.name).toBe("Kitchen renamed");
expect(el.renderCount).toBe(2);
});
});
describe("consumeLocalize", () => {
const localizeA = (() => "A") as LocalizeFunc;
const localizeB = (() => "B") as LocalizeFunc;
it("does NOT re-render when the i18n context changes but localize is identical", async () => {
const { el, provider } = await mount<TestConsumeLocalize>(
"test-consume-localize",
internationalizationContext,
makeI18n(localizeA)
);
expect(el.localize).toBe(localizeA);
expect(el.renderCount).toBe(1);
// New i18n object, same localize reference (e.g. only locale changed).
provider.setValue(makeI18n(localizeA));
await el.updateComplete;
expect(el.renderCount).toBe(1);
});
it("re-renders when localize changes", async () => {
const { el, provider } = await mount<TestConsumeLocalize>(
"test-consume-localize",
internationalizationContext,
makeI18n(localizeA)
);
expect(el.renderCount).toBe(1);
provider.setValue(makeI18n(localizeB));
await el.updateComplete;
expect(el.localize).toBe(localizeB);
expect(el.renderCount).toBe(2);
});
it("works without @state(): updates only when localize changes", async () => {
const { el, provider } = await mount<TestConsumeLocalizeNoState>(
"test-consume-localize-no-state",
internationalizationContext,
makeI18n(localizeA)
);
expect(el.localize).toBe(localizeA);
expect(el.renderCount).toBe(1);
provider.setValue(makeI18n(localizeA));
await el.updateComplete;
expect(el.renderCount).toBe(1);
provider.setValue(makeI18n(localizeB));
await el.updateComplete;
expect(el.localize).toBe(localizeB);
expect(el.renderCount).toBe(2);
});
});
+133 -41
View File
@@ -7,6 +7,7 @@
import { test, expect, type Page } from "@playwright/test";
import type { MoreInfoView } from "../../src/dialogs/more-info/const";
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
import { e2ePanelRouteAssertions } from "./app/src/ha-test-panels";
/**
* Each More info view renders one root element inside the dialog, plus one or
@@ -205,48 +206,14 @@ test.describe("App shell", () => {
// ---------------------------------------------------------------------------
test.describe("Panel navigation", () => {
test("navigates to lovelace dashboard", async ({ page }) => {
await goToPanel(page, "/lovelace");
await expect(
page.locator("ha-panel-lovelace, hui-root").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
for (const [path, element] of e2ePanelRouteAssertions) {
test(`renders registered panel ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
});
test("navigates to energy panel", async ({ page }) => {
await goToPanel(page, "/energy");
await expect(
page.locator("ha-panel-energy, energy-view").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to map panel", async ({ page }) => {
await goToPanel(page, "/map");
await expect(
page.locator("ha-panel-lovelace, hui-root").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to history panel", async ({ page }) => {
await goToPanel(page, "/history");
await expect(
page.locator("ha-panel-history, history-panel").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to profile panel", async ({ page }) => {
await goToPanel(page, "/profile");
await expect(
page.locator("ha-panel-profile, ha-config-user-profile").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
});
}
});
// ---------------------------------------------------------------------------
@@ -451,6 +418,96 @@ test.describe("Theming", () => {
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
const DASHBOARD_LINKS = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
{ href: "/knx", label: "KNX" },
{ href: "/config/thread", label: "Thread" },
{ href: "/config/bluetooth", label: "Bluetooth" },
{ href: "/config/infrared", label: "Infrared" },
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
const CONFIG_ROUTES: { path: string; element: string }[] = [
{ path: "/config/integrations", element: "ha-config-integrations" },
{ path: "/config/devices", element: "ha-config-devices" },
{ path: "/config/entities", element: "ha-config-entities" },
{ path: "/config/helpers", element: "ha-config-helpers" },
{ path: "/config/areas", element: "ha-config-areas" },
{ path: "/config/apps", element: "ha-config-apps" },
{ path: "/config/app", element: "ha-config-app-dashboard" },
{ path: "/config/automation", element: "ha-config-automation" },
{ path: "/config/backup", element: "ha-config-backup" },
{ path: "/config/scene", element: "ha-config-scene" },
{ path: "/config/script", element: "ha-config-script" },
{ path: "/config/blueprint", element: "ha-config-blueprint" },
{ path: "/config/cloud", element: "ha-config-cloud" },
{ path: "/config/energy", element: "ha-config-energy" },
{ path: "/config/hardware", element: "ha-config-hardware" },
{ path: "/config/labs", element: "ha-config-labs" },
{ path: "/config/lovelace", element: "ha-config-lovelace" },
{ path: "/config/person", element: "ha-config-person" },
{ path: "/config/storage", element: "ha-config-section-storage" },
{ path: "/config/tags", element: "ha-config-tags" },
{ path: "/config/users", element: "ha-config-users" },
{ path: "/config/voice-assistants", element: "ha-config-voice-assistants" },
{ path: "/config/system", element: "ha-config-system-navigation" },
{ path: "/config/info", element: "ha-config-info" },
{ path: "/config/logs", element: "ha-config-logs" },
{ path: "/config/general", element: "ha-config-section-general" },
{ path: "/config/updates", element: "ha-config-section-updates" },
{ path: "/config/repairs", element: "ha-config-repairs-dashboard" },
{ path: "/config/analytics", element: "ha-config-section-analytics" },
{ path: "/config/ai-tasks", element: "ha-config-section-ai-tasks" },
{ path: "/config/labels", element: "ha-config-labels" },
{ path: "/config/zone", element: "ha-config-zone" },
{ path: "/config/network", element: "ha-config-section-network" },
{
path: "/config/application_credentials",
element: "ha-config-application-credentials",
},
{ path: "/config/bluetooth", element: "bluetooth-config-dashboard-router" },
{ path: "/config/dhcp", element: "dhcp-config-panel" },
{ path: "/config/infrared", element: "infrared-config-dashboard-router" },
{ path: "/config/matter", element: "matter-config-panel" },
{ path: "/config/mqtt", element: "mqtt-config-panel" },
{
path: "/config/radio-frequency",
element: "radio-frequency-config-dashboard-router",
},
{ path: "/config/ssdp", element: "ssdp-config-panel" },
{ path: "/config/thread", element: "thread-config-panel" },
{ path: "/config/zeroconf", element: "zeroconf-config-panel" },
{ path: "/config/zha", element: "zha-config-dashboard-router" },
{ path: "/config/zwave_js", element: "zwave_js-config-router" },
];
const NESTED_CONFIG_ROUTES: { path: string; element: string }[] = [
{
path: "/config/integrations/dashboard",
element: "ha-config-integrations-dashboard",
},
{
path: "/config/devices/dashboard",
element: "ha-config-devices-dashboard",
},
{ path: "/config/areas/dashboard", element: "ha-config-areas-dashboard" },
{ path: "/config/backup/settings", element: "ha-config-backup-settings" },
];
test("config panel loads without JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
@@ -466,4 +523,39 @@ test.describe("Config panel", () => {
);
expect(realErrors).toHaveLength(0);
});
test("dashboard renders key settings links", async ({ page }) => {
await goToPanel(page, "/config");
const dashboard = page.locator("ha-config-dashboard");
await expect(dashboard).toBeAttached({ timeout: PANEL_TIMEOUT });
for (const { href, label } of DASHBOARD_LINKS) {
const link = dashboard.getByRole("link", {
name: new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
});
// eslint-disable-next-line no-await-in-loop
await expect(link).toHaveAttribute("href", href, {
timeout: QUICK_TIMEOUT,
});
}
});
for (const { path, element } of CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
for (const { path, element } of NESTED_CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
+1
View File
@@ -1 +1,2 @@
import "./ha-panel-e2e-todo";
import "./ha-test";
+15
View File
@@ -0,0 +1,15 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-panel-e2e-todo")
class HaPanelE2ETodo extends LitElement {
protected render() {
return html`<span>Todo panel route</span>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-e2e-todo": HaPanelE2ETodo;
}
}
+97 -2
View File
@@ -1,12 +1,25 @@
import type { Panels } from "../../../../src/types";
import type { PanelInfo } from "../../../../src/types";
export const e2eTestPanels: Panels = {
interface E2ETestPanelInfo extends PanelInfo {
testSelector?: string;
}
export const e2eTestPanels: Record<string, E2ETestPanelInfo> = {
home: {
component_name: "home",
icon: "mdi:home",
title: "home",
config: null,
url_path: "home",
testSelector: "ha-panel-home",
},
lovelace: {
component_name: "lovelace",
icon: "mdi:view-dashboard",
title: "home",
config: { mode: "storage" },
url_path: "lovelace",
testSelector: "ha-panel-lovelace, hui-root",
},
map: {
component_name: "lovelace",
@@ -14,6 +27,7 @@ export const e2eTestPanels: Panels = {
title: "map",
config: { mode: "storage" },
url_path: "map",
testSelector: "ha-panel-lovelace, hui-root",
},
energy: {
component_name: "energy",
@@ -21,6 +35,7 @@ export const e2eTestPanels: Panels = {
title: "energy",
config: null,
url_path: "energy",
testSelector: "ha-panel-energy, energy-view",
},
history: {
component_name: "history",
@@ -28,6 +43,71 @@ export const e2eTestPanels: Panels = {
title: "history",
config: null,
url_path: "history",
testSelector: "ha-panel-history, history-panel",
},
logbook: {
component_name: "logbook",
icon: "mdi:format-list-bulleted-type",
title: "logbook",
config: null,
url_path: "logbook",
testSelector: "ha-panel-logbook",
},
calendar: {
component_name: "calendar",
icon: "mdi:calendar",
title: "calendar",
config: null,
url_path: "calendar",
testSelector: "ha-panel-calendar",
},
todo: {
component_name: "e2e-todo",
icon: "mdi:clipboard-list",
title: "todo",
config: null,
url_path: "todo",
testSelector: "ha-panel-e2e-todo",
},
"media-browser": {
component_name: "media-browser",
icon: "mdi:play-box-multiple",
title: "media_browser",
config: null,
url_path: "media-browser",
testSelector: "ha-panel-media-browser",
},
light: {
component_name: "light",
icon: "mdi:lightbulb-group",
title: "light",
config: null,
url_path: "light",
testSelector: "ha-panel-light",
},
climate: {
component_name: "climate",
icon: "mdi:thermostat",
title: "climate",
config: null,
url_path: "climate",
testSelector: "ha-panel-climate",
},
maintenance: {
component_name: "maintenance",
icon: "mdi:wrench-clock",
title: "maintenance",
config: null,
url_path: "maintenance",
testSelector: "ha-panel-maintenance",
},
iframe: {
component_name: "iframe",
icon: "mdi:web",
title: "iframe",
config: { url: "/static/blank.html" },
url_path: "iframe",
testSelector: "ha-panel-iframe",
},
config: {
component_name: "config",
@@ -42,5 +122,20 @@ export const e2eTestPanels: Panels = {
title: null,
config: null,
url_path: "profile",
testSelector: "ha-panel-profile, ha-config-user-profile",
},
notfound: {
component_name: "notfound",
icon: null,
title: null,
config: null,
url_path: "notfound",
testSelector: "ha-panel-notfound",
},
};
export const e2ePanelRouteAssertions = new Map<string, string>(
Object.values(e2eTestPanels).flatMap((panel): [string, string][] =>
panel.testSelector ? [[`/${panel.url_path}`, panel.testSelector]] : []
)
);
+122 -2
View File
@@ -2,6 +2,7 @@ import { customElement } from "lit/decorators";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
import { navigate } from "../../../../src/common/navigate";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import type { LogbookStreamMessage } from "../../../../src/data/logbook";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../../../src/types";
@@ -10,7 +11,10 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockAssist } from "../../../../demo/src/stubs/assist";
import { mockAuth } from "../../../../demo/src/stubs/auth";
import { mockCloud } from "../../../../demo/src/stubs/cloud";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import {
demoConfigEntries,
mockConfigEntries,
} from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEnergy } from "../../../../demo/src/stubs/energy";
import { energyEntities } from "../../../../demo/src/stubs/entities";
@@ -20,6 +24,8 @@ import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockIntegration } from "../../../../demo/src/stubs/integration";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
@@ -32,9 +38,56 @@ import { mockTemplate } from "../../../../demo/src/stubs/template";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockTranslations } from "../../../../demo/src/stubs/translations";
import { mockUpdate } from "../../../../demo/src/stubs/update";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import { demoConfig } from "../../../../src/fake_data/demo_config";
import { e2eTestPanels } from "./ha-test-panels";
import { scenarios } from "./scenarios";
const E2E_CONFIG_COMPONENTS = [
...demoConfig.components,
"bluetooth",
"dhcp",
"hardware",
"infrared",
"insteon",
"knx",
"lovelace",
"matter",
"mqtt",
"radio_frequency",
"ssdp",
"tag",
"thread",
"zeroconf",
"zha",
"zone",
"zwave_js",
];
const E2E_FILTER_ENTITIES: Record<string, EntityRegistryDisplayEntry> = {
"infrared.remote": {
entity_id: "infrared.remote",
labels: [],
platform: "demo",
},
"radio_frequency.remote": {
entity_id: "radio_frequency.remote",
labels: [],
platform: "demo",
},
};
const MEDIA_BROWSER_ROOT = {
title: "Media",
media_content_id: "media-source://media_source",
media_content_type: "app",
media_class: "directory",
can_play: false,
can_expand: true,
can_search: false,
children: [],
};
declare global {
interface Window {
__mockHass: MockHomeAssistant;
@@ -56,6 +109,13 @@ export class HaTest extends HomeAssistantAppEl {
const initial: Partial<MockHomeAssistant> = {
// Use the full panel map (history + config enabled)
panels: e2eTestPanels,
config: {
...demoConfig,
// Include common protocol and discovery integrations so Settings shows
// the same high-level panels most real Home Assistant instances expose.
components: E2E_CONFIG_COMPONENTS,
},
entities: E2E_FILTER_ENTITIES,
panelUrl: (() => {
const path = window.location.pathname;
const dividerPos = path.indexOf("/", 1);
@@ -100,11 +160,71 @@ export class HaTest extends HomeAssistantAppEl {
mockEntityRegistry(hass, []);
mockConfigEntries(hass);
mockIcons(hass);
mockIntegration(hass);
mockHassioSupervisor(hass);
mockPersistentNotification(hass);
mockSearch(hass);
const { mockConfigPanel } =
await import("../../../../demo/src/stubs/config-panel");
mockConfigPanel(hass);
hass.mockWS("config_entries/get", (msg: { domain?: string }) => {
const protocolEntries = demoConfigEntries
.map(({ entry }) => entry)
.concat(
[
{ entry_id: "mock-bluetooth", domain: "bluetooth" },
{ entry_id: "mock-lovelace", domain: "lovelace" },
].map((entry) => ({
disabled_by: null,
domain: entry.domain,
entry_id: entry.entry_id,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
reason: null,
source: "user" as const,
state: "loaded" as const,
supported_subentry_types: {},
supports_options: false,
supports_reconfigure: false,
supports_remove_device: false,
supports_unload: true,
title: entry.domain,
}))
);
return protocolEntries.filter(
(entry) => !msg.domain || entry.domain === msg.domain
);
});
hass.mockWS("radio_frequency/list", () => ({ transmitters: [] }));
hass.mockWS("calendar/event/subscribe", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] });
return () => undefined;
});
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
const message: LogbookStreamMessage = { events: [] };
onChange?.(message);
return () => undefined;
});
hass.mockWS("config/auth/list", () => []);
hass.mockWS("trace/contexts", () => ({}));
hass.mockWS("media_source/browse_media", () => MEDIA_BROWSER_ROOT);
// Load default entities from the sections config
hass.addEntities(energyEntities());
hass.addEntities([
...energyEntities(),
{
entity_id: "todo.shopping_list",
state: "0",
attributes: {
friendly_name: "Shopping list",
supported_features: 15,
},
},
]);
Promise.all([Promise.resolve(demoSections), localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
@@ -80,7 +80,7 @@ const makeStatus = (
remote_certificate_status: "ready",
http_use_ssl: false,
active_subscription: true,
is_onboarding_postponed: false,
onboarding_postponed: false,
onboarding_completed: false,
...overrides,
});