diff --git a/demo/AGENTS.md b/demo/AGENTS.md index 54ffdb7eeb..015f56694b 100644 --- a/demo/AGENTS.md +++ b/demo/AGENTS.md @@ -44,3 +44,13 @@ http://localhost:8090/?demo=#/energy/water - `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling. - `src/stubs/`: Mocked WebSocket/REST APIs. - `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers. + +## The gallery imports these stubs too + +`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo. + +```bash +grep -rn "stubs/" demo/src gallery/src +``` + +The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page. diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 9d40036e29..b379ebdddf 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -16,6 +16,8 @@ import { mockEntityRegistry } from "./stubs/entity_registry"; import { mockEvents } from "./stubs/events"; import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry"; import { mockFrontend } from "./stubs/frontend"; +import { mockHardware } from "./stubs/hardware"; +import { mockHassioSupervisor } from "./stubs/hassio_supervisor"; import { mockIntegration } from "./stubs/integration"; import { mockLabelRegistry } from "./stubs/label_registry"; import { mockIcons } from "./stubs/icons"; @@ -73,10 +75,6 @@ export class HaDemo extends HomeAssistantAppEl { // `contextMixin`, so let provideHass skip them to avoid duplicate providers. const hass = provideHass(this, initial, true, false); - // The cloud account page only fetches backup config and the webhook count - // when those integrations are loaded. Enable them here (demo only) so the - // mocked backup/config/info and webhook/list are queried. usage_prediction - // is needed for common-controls sections in strategy dashboards. hass.updateHass({ config: { ...hass.config, @@ -86,6 +84,8 @@ export class HaDemo extends HomeAssistantAppEl { "webhook", "usage_prediction", "assist_pipeline", + "hassio", + "hardware", ], }, }); @@ -113,6 +113,8 @@ export class HaDemo extends HomeAssistantAppEl { mockEvents(hass); mockMediaPlayer(hass); mockFrontend(hass); + mockHardware(hass); + mockHassioSupervisor(hass); mockIcons(hass); mockEnergy(hass); mockPersistentNotification(hass); diff --git a/demo/src/stubs/hardware.ts b/demo/src/stubs/hardware.ts new file mode 100644 index 0000000000..a758285fed --- /dev/null +++ b/demo/src/stubs/hardware.ts @@ -0,0 +1,48 @@ +import type { + HardwareInfo, + SystemStatusStreamMessage, +} from "../../../src/data/hardware"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +// Mirrors what homeassistant_green reports, so the hardware page resolves the +// board name and the brands image the same way it does on a real Green. +const HARDWARE_INFO: HardwareInfo = { + hardware: [ + { + board: { + hassio_board_id: "green", + manufacturer: "homeassistant", + model: "green", + }, + dongle: null, + config_entries: [], + name: "Home Assistant Green", + url: "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green", + }, + ], +}; + +export const mockHardware = (hass: MockHomeAssistant) => { + hass.mockWS("hardware/info", () => HARDWARE_INFO); + + hass.mockWS( + "hardware/subscribe_system_status", + (_msg, _currentHass, onChange) => { + // Rounded like the hardware integration rounds psutil's values. + const send = () => { + const usedMb = 1560 + Math.round(Math.random() * 80); + const message: SystemStatusStreamMessage = { + cpu_percent: Math.round((8 + Math.random() * 6) * 10) / 10, + memory_free_mb: 4096 - usedMb, + memory_used_mb: usedMb, + memory_used_percent: Math.round((usedMb / 4096) * 1000) / 10, + timestamp: new Date().toISOString(), + }; + onChange?.(message); + }; + send(); + const interval = window.setInterval(send, 1000); + return () => clearInterval(interval); + } + ); +}; diff --git a/demo/src/stubs/hassio_supervisor.ts b/demo/src/stubs/hassio_supervisor.ts index c3ee8ce760..e7b7f7e6b3 100644 --- a/demo/src/stubs/hassio_supervisor.ts +++ b/demo/src/stubs/hassio_supervisor.ts @@ -1,59 +1,385 @@ -import type { HassioSupervisorInfo } from "../../../src/data/hassio/supervisor"; +import type { + HassioAddonDetails, + HassioAddonInfo, + HassioAddonsInfo, +} from "../../../src/data/hassio/addon"; +import type { HassioStats } from "../../../src/data/hassio/common"; +import type { + HassioHassOSInfo, + HassioHostInfo, + HostDisksUsage, +} from "../../../src/data/hassio/host"; +import type { NetworkInfo } from "../../../src/data/hassio/network"; +import type { + HassioInfo, + HassioSupervisorInfo, +} from "../../../src/data/hassio/supervisor"; +import type { SupervisorMounts } from "../../../src/data/supervisor/mounts"; +import type { SupervisorUpdateConfig } from "../../../src/data/supervisor/update"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; +// `icon`/`logo` are false on purpose: the panel would otherwise request +// /api/hassio/addons//icon, which the demo has no backend for. +const DEMO_ADDONS: HassioAddonInfo[] = [ + { + name: "Music Assistant", + slug: "d5369777_music_assistant", + description: + "Music library manager for all your media sources and streaming services, with support for a wide range of players", + advanced: false, + available: true, + build: false, + detached: false, + homeassistant: "2025.7.0", + icon: false, + installed: true, + logo: false, + repository: "d5369777", + stage: "stable", + state: "started", + update_available: false, + url: "https://github.com/music-assistant/home-assistant-addon", + version: "2.6.3", + version_latest: "2.6.3", + }, + { + name: "ESPHome Device Builder", + slug: "5c53de3b_esphome", + description: + "Manage and program your ESP8266/ESP32 based microcontrollers directly via WiFi and with a simple, yet powerful configuration file syntax", + advanced: false, + available: true, + build: false, + detached: false, + homeassistant: "2025.7.0", + icon: false, + installed: true, + logo: false, + repository: "5c53de3b", + stage: "stable", + state: "started", + update_available: false, + url: "https://esphome.io/", + version: "2025.7.3", + version_latest: "2025.7.3", + }, +]; + +const LONG_DESCRIPTIONS: Record = { + d5369777_music_assistant: `## Music Assistant + +Music Assistant brings all your music sources together in one library and streams +them to the players you already own. + +- Combines local files with streaming services into a single searchable library +- Plays to Sonos, Chromecast, AirPlay, Squeezebox, DLNA and Home Assistant media players +- Group players together for synced multi-room audio +- Exposes players and playlists to Home Assistant automations and voice assistants`, + "5c53de3b_esphome": `## ESPHome Device Builder + +ESPHome turns an ESP8266 or ESP32 into a Home Assistant device using a short YAML +configuration instead of hand-written firmware. + +- Compile and flash firmware straight from the browser, over WiFi after the first flash +- Hundreds of supported sensors, displays, lights and switches +- Devices are discovered by Home Assistant automatically, with no cloud in between +- Configuration lives next to your Home Assistant config, so it is covered by backups`, +}; + +// Supervisor schema format (converted to selectors by the config tab). Music +// Assistant is configured in its own UI, so it has no add-on options. +const CONFIG_SCHEMAS: Record = { + "5c53de3b_esphome": [ + { name: "ssl", type: "boolean", required: true }, + { name: "certfile", type: "string", required: true }, + { name: "keyfile", type: "string", required: true }, + { name: "leave_front_door_open", type: "boolean", required: false }, + { name: "status_use_ping", type: "boolean", required: false }, + ], +}; + +const CONFIG_OPTIONS: Record> = { + "5c53de3b_esphome": { + ssl: false, + certfile: "fullchain.pem", + keyfile: "privkey.pem", + }, +}; + +const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({ + ...addon, + apparmor: "default", + arch: ["aarch64", "amd64"], + audio_input: null, + audio_output: null, + audio: false, + auth_api: false, + auto_uart: false, + auto_update: false, + boot: "auto", + changelog: false, + devices: [], + devicetree: false, + discovery: [], + docker_api: false, + documentation: false, + full_access: false, + gpio: false, + hassio_api: false, + hassio_role: "default", + hostname: addon.slug.replace(/_/g, "-"), + homeassistant_api: false, + host_dbus: false, + host_ipc: false, + host_network: false, + host_pid: false, + ingress_entry: null, + ingress_panel: false, + ingress_url: null, + ingress: false, + ip_address: "172.30.33.2", + kernel_modules: false, + long_description: LONG_DESCRIPTIONS[addon.slug], + machine: [], + network_description: null, + network: null, + options: CONFIG_OPTIONS[addon.slug] ?? {}, + privileged: [], + protected: true, + rating: 6, + schema: CONFIG_SCHEMAS[addon.slug] ?? null, + services_role: [], + signed: false, + startup: "application", + stdin: false, + system_managed: false, + system_managed_config_entry: null, + translations: {}, + watchdog: true, + webui: null, +}); + +const LOGS: Record = { + d5369777_music_assistant: `[server] Starting Music Assistant Server 2.6.3 +[server] Loaded provider: filesystem_local +[server] Loaded provider: spotify +[server] Loaded provider: sonos +[players] Discovered player: Living Room (Sonos) +[players] Discovered player: Kitchen (Chromecast) +[server] Music Assistant is ready +`, + "5c53de3b_esphome": `[esphome] Starting ESPHome Device Builder 2025.7.3 +[esphome] Dashboard running on port 6052 +[esphome] Found 3 configurations +[esphome] bedroom-sensor is online (2025.7.3) +[esphome] garage-door is online (2025.7.3) +[esphome] office-display is online (2025.7.3) +`, +}; + +const ADDON_STATS: HassioStats = { + blk_read: 12300000, + blk_write: 4500000, + cpu_percent: 1.4, + memory_limit: 3900000000, + memory_percent: 4.2, + memory_usage: 163000000, + network_rx: 8900000, + network_tx: 2300000, +}; + export const mockHassioSupervisor = (hass: MockHomeAssistant) => { - hass.config.components.push("hassio"); + // Gallery pages rely on this to enable the hassio-gated pickers. The demo + // lists hassio in its own components, hence the guard against duplicates. + if (!hass.config.components.includes("hassio")) { + hass.config.components.push("hassio"); + } + hass.mockWS("supervisor/api", (msg) => { if (msg.endpoint === "/supervisor/info") { const data: HassioSupervisorInfo = { - version: "2021.10.dev0805", - version_latest: "2021.10.dev0806", - update_available: true, - channel: "dev", + version: "2026.07.1", + version_latest: "2026.07.1", + update_available: false, + channel: "stable", arch: "aarch64", supported: true, healthy: true, ip_address: "172.30.32.2", wait_boot: 5, - timezone: "America/Los_Angeles", + timezone: "Europe/Amsterdam", logging: "info", debug: false, debug_block: false, diagnostics: true, - addons: [ - { - name: "Visual Studio Code", - slug: "a0d7b954_vscode", - description: - "Fully featured VSCode experience, to edit your HA config in the browser, including auto-completion!", - state: "started", - version: "3.6.2", - version_latest: "3.6.2", - update_available: false, - repository: "a0d7b954", - icon: false, - logo: true, - }, - { - name: "Z-Wave JS", - slug: "core_zwave_js", - description: - "Control a ZWave network with Home Assistant Z-Wave JS", - state: "started", - version: "0.1.45", - version_latest: "0.1.45", - update_available: false, - repository: "core", - icon: true, - logo: true, - }, - ] as any, + addons: DEMO_ADDONS as any, addons_repositories: [ - "https://github.com/hassio-addons/repository", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", ] as any, }; return data; } + + if (msg.endpoint === "/addons") { + const data: HassioAddonsInfo = { + addons: DEMO_ADDONS, + repositories: [ + { + slug: "d5369777", + name: "Music Assistant", + source: "https://github.com/music-assistant/home-assistant-addon", + url: "https://github.com/music-assistant/home-assistant-addon", + maintainer: "Music Assistant", + }, + { + slug: "5c53de3b", + name: "ESPHome", + source: "https://github.com/esphome/home-assistant-addon", + url: "https://esphome.io/", + maintainer: "ESPHome", + }, + ], + }; + return data; + } + + const addonMatch = msg.endpoint.match(/^\/addons\/([^/]+)\/(info|stats)$/); + if (addonMatch) { + const addon = DEMO_ADDONS.find((item) => item.slug === addonMatch[1]); + if (!addon) { + return Promise.reject(`Addon ${addonMatch[1]} not found`); + } + return addonMatch[2] === "stats" ? ADDON_STATS : addonDetails(addon); + } + + if (msg.endpoint === "/info") { + const data: HassioInfo = { + arch: "aarch64", + channel: "stable", + docker: "27.5.1", + features: ["reboot", "shutdown", "network", "hostname", "os_agent"], + hassos: null, + homeassistant: "2026.7.2", + hostname: "homeassistant", + logging: "info", + machine: "green", + state: "running", + operating_system: "Home Assistant OS 18.2", + supervisor: "2026.07.1", + supported: true, + supported_arch: ["aarch64", "armv7", "armhf"], + timezone: "Europe/Amsterdam", + }; + return data; + } + + if (msg.endpoint === "/host/info") { + const data: HassioHostInfo = { + agent_version: "1.8.0", + chassis: "embedded", + cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*", + deployment: "production", + disk_life_time: 6, + disk_free: 22.3, + disk_total: 31.2, + disk_used: 8.9, + features: ["reboot", "shutdown", "network", "hostname", "os_agent"], + hostname: "homeassistant", + kernel: "6.12.48-haos", + operating_system: "Home Assistant OS 18.2", + boot_timestamp: 1751932800000000, + startup_time: 12.4, + }; + return data; + } + + if (msg.endpoint === "/os/info") { + const data: HassioHassOSInfo = { + board: "green", + boot: "A", + update_available: false, + version: "18.2", + version_latest: "18.2", + data_disk: "Home Assistant Green (mmcblk0)", + }; + return data; + } + + if (msg.endpoint === "/host/disks/default/usage") { + const data: HostDisksUsage = { + id: "root", + label: "Total", + total_bytes: 31200000000, + used_bytes: 8900000000, + children: [ + { id: "media", label: "Media", used_bytes: 4100000000 }, + { id: "addons", label: "Apps", used_bytes: 2600000000 }, + { id: "backup", label: "Backups", used_bytes: 1400000000 }, + { id: "share", label: "Share", used_bytes: 800000000 }, + ], + }; + return data; + } + + if (msg.endpoint === "/mounts") { + const data: SupervisorMounts = { + default_backup_mount: null, + mounts: [], + }; + return data; + } + + if (msg.endpoint === "/network/info") { + const data: NetworkInfo = { + interfaces: [ + { + primary: true, + privacy: false, + interface: "eth0", + enabled: true, + type: "ethernet", + ipv4: { + address: ["192.168.1.10/24"], + gateway: "192.168.1.1", + method: "auto", + nameservers: ["192.168.1.1"], + }, + wifi: null, + }, + ], + docker: { + address: "172.30.32.0/23", + dns: "172.30.32.3", + gateway: "172.30.32.1", + interface: "hassio", + }, + }; + return data; + } + + if (msg.endpoint === "/store/reload") { + return null; + } + return Promise.reject(`${msg.method} ${msg.endpoint} is not implemented`); }); + + hass.mockWS("hassio/update/config/info", (): SupervisorUpdateConfig => ({ + add_on_backup_before_update: true, + add_on_backup_retain_copies: 1, + core_backup_before_update: true, + })); + + hass.mockAPI(/^hassio\/host\/logs\/boots$/, () => ({ + data: { boots: { "0": "2026-07-26T09:00:00.000000+00:00" } }, + })); + + hass.mockAPI(/^hassio\/addons\/[^/]+\/logs/, (_hass, _method, path) => { + const slug = path.split("/")[2]; + // X-First-Cursor tells error-log-card there is nothing older to page to. + return new Response(LOGS[slug], { + headers: { "X-First-Cursor": "demo" }, + }); + }); }; diff --git a/public/static/images/home-assistant-logo-loading.svg b/public/static/images/home-assistant-logo-loading.svg index 4cae9447b9..79b1afa79c 100644 --- a/public/static/images/home-assistant-logo-loading.svg +++ b/public/static/images/home-assistant-logo-loading.svg @@ -5,19 +5,19 @@ .dot-top { animation: pulse-top 1300ms 350ms linear infinite; } @keyframes pulse-left { 0% { transform: translate(50px, 69.634804px) scale(1); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } - 15.384615% { transform: translate(50px, 69.634804px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } + 15.384615% { transform: translate(50px, 69.634804px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } 38.461538%, 100% { transform: translate(50px, 69.634804px) scale(1); } } @keyframes pulse-right { 0%, 15.384615% { transform: translate(90px, 58.634798px) scale(1); } 15.384615% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); } - 30.769231% { transform: translate(90px, 58.634798px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } + 30.769231% { transform: translate(90px, 58.634798px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } 53.846154%, 100% { transform: translate(90px, 58.634798px) scale(1); } } @keyframes pulse-top { 0%, 30.769231% { transform: translate(70px, 37.6348px) scale(1); } 30.769231% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); } - 46.153846% { transform: translate(70px, 37.6348px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } + 46.153846% { transform: translate(70px, 37.6348px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } 69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); } } diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index a67d1db4f8..7bd6fad32d 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -95,7 +95,7 @@ export interface HassioAddonDetails extends HassioAddonInfo { options: Record; privileged: any; protected: boolean; - rating: "1-8"; + rating: number; schema: HaFormSchema[] | null; services_role: string[]; signed: boolean; diff --git a/src/data/supervisor/store.ts b/src/data/supervisor/store.ts index 4aff294296..7b98d8860e 100644 --- a/src/data/supervisor/store.ts +++ b/src/data/supervisor/store.ts @@ -26,6 +26,7 @@ export interface StoreAddonDetails extends StoreAddon { apparmor: boolean; arch: SupervisorArch[]; auth_api: boolean; + changelog: boolean; detached: boolean; docker_api: boolean; documentation: boolean; diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 4a0bf77184..2f4ce6317c 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -481,6 +481,17 @@ export const provideHass = ( ? response[1](hass(), method, path, parameters) : Promise.reject(`API Mock for ${path} is not implemented`); }, + // Mocks return a plain body; wrap it so callers can stream it like a fetch + // Response. Callbacks may return a Response themselves to set headers. + async callApiRaw(method, path, parameters, headers) { + const result = await hassObj.callApi( + method, + path, + parameters, + headers + ); + return result instanceof Response ? result : new Response(result); + }, hassUrl: (path?) => path, fetchWithAuth: () => Promise.reject("Not implemented"), sendWS: (msg) => hassObj.connection.sendMessage(msg), diff --git a/src/html/index.html.template b/src/html/index.html.template index 2d5d73c93c..0be360215a 100644 --- a/src/html/index.html.template +++ b/src/html/index.html.template @@ -132,11 +132,12 @@ } } @media (max-height: 560px) { - #ha-launch-screen .ha-launch-screen-spacer-top { + /* body selector to avoid minification causing bad jinja2 */ + body #ha-launch-screen .ha-launch-screen-spacer-top { margin-top: 24px; padding-top: 24px; } - #ha-launch-screen .ha-launch-screen-spacer-bottom { + body #ha-launch-screen .ha-launch-screen-spacer-bottom { padding-top: 16px; } .ohf-logo { diff --git a/src/panels/config/apps/app-view/info/supervisor-app-info.ts b/src/panels/config/apps/app-view/info/supervisor-app-info.ts index fd909ffa5a..db81140bf1 100644 --- a/src/panels/config/apps/app-view/info/supervisor-app-info.ts +++ b/src/panels/config/apps/app-view/info/supervisor-app-info.ts @@ -225,20 +225,33 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) { "ui.panel.config.apps.dashboard.current_version", { version: this._currentAddon.version } )} -
- (${this.i18n.localize( - "ui.panel.config.apps.dashboard.changelog" - )}) -
+ ${ + this._currentAddon.changelog + ? html`
+ (${this.i18n.localize( + "ui.panel.config.apps.dashboard.changelog" + )}) +
` + : nothing + } ` : html`${this._currentAddon.version_latest} - ${this.i18n.localize( - "ui.panel.config.apps.dashboard.changelog" - )}` + ${ + this._currentAddon.changelog + ? html`${this.i18n.localize( + "ui.panel.config.apps.dashboard.changelog" + )}` + : nothing + }` } diff --git a/test/e2e/demo/helpers.ts b/test/e2e/demo/helpers.ts index 80cf9be1b1..ceedd7e606 100644 --- a/test/e2e/demo/helpers.ts +++ b/test/e2e/demo/helpers.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test"; +import type { HaDrawer } from "../../../src/components/ha-drawer"; import type { ThemeSettings } from "../../../src/types"; import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers"; @@ -66,26 +67,34 @@ export async function expectStoredDemoTheme( } export async function openDemoSidebar(page: Page) { - const menuButton = page.locator("ha-menu-button"); - if (await menuButton.isVisible()) { - const modalDrawer = page.locator("ha-drawer").locator("wa-drawer"); - await Promise.all([ - modalDrawer.evaluate( - (element) => - new Promise((resolve) => { - element.addEventListener("wa-after-show", () => resolve(), { - once: true, - }); - }) - ), - menuButton.click(), - ]); + const drawer = page.locator("ha-drawer"); + await expect(drawer).toBeAttached({ timeout: NAVIGATION_TIMEOUT }); + + // Pick the layout from ha-drawer, which home-assistant-main sets to "modal" + // while committing its very first render (the narrow media query is resolved + // synchronously in its constructor). ha-menu-button must not be used for + // this: it renders nothing until its Lit contexts resolve, so sampling its + // visibility straight after load reports "hidden" on the narrow layout too, + // and the drawer would then silently never be opened. + const isModal = await drawer.evaluate( + (element) => (element as HaDrawer).type === "modal" + ); + + if (!isModal) { + // Wide layout: the sidebar is permanently on screen. + await expect(page.locator("ha-sidebar")).toBeVisible({ + timeout: NAVIGATION_TIMEOUT, + }); return; } - await expect(page.locator("ha-sidebar")).toBeAttached({ - timeout: NAVIGATION_TIMEOUT, - }); + const menuButton = page.locator("ha-menu-button"); + await expect(menuButton).toBeVisible({ timeout: SHELL_TIMEOUT }); + await menuButton.click(); + // ha-drawer reflects `open` once the app has actually opened the drawer. + // The slide-in animation is covered by Playwright's stability check on the + // next interaction. + await expect(drawer).toHaveAttribute("open", "", { timeout: SHELL_TIMEOUT }); } export async function activateDemoSidebarPanel(page: Page, panel: string) {