mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-08 00:56:20 +00:00
Compare commits
1 Commits
e2e-config
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba003f86a |
@@ -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>(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -451,96 +451,6 @@ 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));
|
||||
@@ -556,39 +466,4 @@ 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,10 +10,7 @@ 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 {
|
||||
demoConfigEntries,
|
||||
mockConfigEntries,
|
||||
} from "../../../../demo/src/stubs/config_entries";
|
||||
import { 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";
|
||||
@@ -23,8 +20,6 @@ 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";
|
||||
@@ -37,45 +32,9 @@ 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",
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockHass: MockHomeAssistant;
|
||||
@@ -97,13 +56,6 @@ 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);
|
||||
@@ -148,46 +100,8 @@ 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: [] }));
|
||||
|
||||
// Load default entities from the sections config
|
||||
hass.addEntities(energyEntities());
|
||||
|
||||
Reference in New Issue
Block a user