mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-08 09:33:00 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71c2a8c028 | |||
| a358f5421f | |||
| 9e3a697d54 | |||
| 0d35c25b45 | |||
| db0813904c | |||
| b30668995d | |||
| 9ba003f86a | |||
| 0536e2cd2a |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 +1,2 @@
|
||||
import "./ha-panel-e2e-todo";
|
||||
import "./ha-test";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user