From 4b101545cc7fdaf601608b60b748eeef2b65d9db Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 29 Aug 2026 13:47:38 -0700 Subject: [PATCH] fix: Prevent premature reinitialization when stale HA reconnects (#2717) - Closes: #2714 --- src/card-controller/controller.ts | 5 +- src/card-controller/hass/hass-manager.ts | 49 ++++- src/card-controller/hass/types.ts | 6 + .../initialization/initialization-manager.ts | 12 +- .../issues/issues/connection.ts | 36 +--- src/condition-trigger/conditions/types.ts | 6 + src/ha/connection/subscription-manager.ts | 3 +- src/ha/is-hass-ready.ts | 15 -- src/ha/source.ts | 5 + tests/browser/fake-hass.ts | 8 + .../card-controller/hass/hass-manager.test.ts | 200 ++++++++++++++++++ .../initialization-manager.test.ts | 18 +- .../issues/issues/connection.test.ts | 59 +++--- tests/ha/is-hass-ready.test.ts | 35 --- tests/test-utils.ts | 7 +- 15 files changed, 333 insertions(+), 131 deletions(-) delete mode 100644 src/ha/is-hass-ready.ts delete mode 100644 tests/ha/is-hass-ready.test.ts diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 8d4013ed..234b8a8a 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -166,7 +166,10 @@ export class CardController // camera-trigger handler that writes back to ConditionStateManager, fanning // out to automations that still read a stale `hass`. this._hassManager.addListener((hass) => - this._conditionStateManager.setState({ hass }), + this._conditionStateManager.setState({ + hass, + hassReadiness: this._hassManager.getReadiness(), + }), ); host.addController(this); diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index 511b8763..febc46ad 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -1,4 +1,5 @@ -import { isHassReady } from '../../ha/is-hass-ready'; +import { STATE_RUNNING, type HassConfig } from 'home-assistant-js-websocket'; + import type { HASSListener } from '../../ha/source'; import type { HomeAssistant } from '../../ha/types'; import type { UnsubscribeCallback } from '../../types'; @@ -7,12 +8,17 @@ import { InitializationAspect } from '../initialization/initialization-manager'; import type { CardHASSAPI } from '../types'; import { EventWatcher, type EventWatcherSubscriptionInterface } from './event-watcher'; import { StateWatcher, type StateWatcherSubscriptionInterface } from './state-watcher'; -import type { HASSManagerReadonlyInterface } from './types'; +import type { HASSManagerReadonlyInterface, HASSReadiness } from './types'; export class HASSManager implements HASSManagerReadonlyInterface { private _hass: HomeAssistant | null = null; private _api: CardHASSAPI; + // The Home Assistant frontend restores `connected` the moment the socket + // returns, but keeps reporting this same configuration until its own + // `get_config` answers. + private _disconnectedConfig: HassConfig | null = null; + private _hassListeners = new Set(); private _stateWatcher: StateWatcherSubscriptionInterface; @@ -32,6 +38,28 @@ export class HASSManager implements HASSManagerReadonlyInterface { return !!this._hass; } + public getReadiness(): HASSReadiness { + if (!this._hass?.connected) { + return 'disconnected'; + } + return this._isReady(this._hass) ? 'ready' : 'starting'; + } + + public isReady(): boolean { + return this.getReadiness() === 'ready'; + } + + // The frontend reuses the pre-disconnect `hass.config` after reconnecting, so + // `STATE_RUNNING` can be stale. `_disconnectedConfig` rejects it until a + // new config object appears. + private _isReady(hass: HomeAssistant | null): boolean { + return ( + !!hass?.connected && + hass.config !== this._disconnectedConfig && + hass.config?.state === STATE_RUNNING + ); + } + public getStateWatcher(): StateWatcherSubscriptionInterface { return this._stateWatcher; } @@ -54,8 +82,15 @@ export class HASSManager implements HASSManagerReadonlyInterface { return; } - const wasReady = !!this._hass && isHassReady(this._hass); - const isReady = isHassReady(hass); + const wasReady = this._isReady(this._hass); + + if (!hass.connected) { + this._disconnectedConfig = hass.config; + } else if (hass.config !== this._disconnectedConfig) { + this._disconnectedConfig = null; + } + + const isReady = this._isReady(hass); // A card cannot be started without Home Assistant, so losing it ends the // card's initialization session. The aspects initialized during that @@ -65,9 +100,9 @@ export class HASSManager implements HASSManagerReadonlyInterface { this._api.getInitializationManager().getSessionManager().end(); } - // When HA goes from "not ready" to "ready" (WebSocket reconnected AND all - // integrations finished loading), rebuild cameras and the view from - // scratch: the available entities may have changed while it was down. + // When HA goes from "not ready" to "ready" (reconnected, fresh + // `hass.config`, and `STATE_RUNNING`), rebuild cameras and view from + // scratch: available entities may have changed while it was down. if (!!this._hass && !wasReady && isReady) { // Tear cameras down before the listeners below see the new hass, // otherwise they would briefly rebuild against the old entities. diff --git a/src/card-controller/hass/types.ts b/src/card-controller/hass/types.ts index d9f5b042..b2efe6ff 100644 --- a/src/card-controller/hass/types.ts +++ b/src/card-controller/hass/types.ts @@ -2,6 +2,12 @@ import type { HASSSource } from '../../ha/source'; import type { EventWatcherSubscriptionInterface } from './event-watcher'; import type { StateWatcherSubscriptionInterface } from './state-watcher'; +// The card's view of HA's readiness: +// - 'disconnected' : WebSocket is down +// - 'starting' : connected, but HA has not finished loading integrations +// - 'ready' : connected and fully running +export type HASSReadiness = 'disconnected' | 'starting' | 'ready'; + export interface HASSManagerReadonlyInterface extends HASSSource { getStateWatcher(): StateWatcherSubscriptionInterface; getEventWatcher(): EventWatcherSubscriptionInterface; diff --git a/src/card-controller/initialization/initialization-manager.ts b/src/card-controller/initialization/initialization-manager.ts index 62703039..807afbb4 100644 --- a/src/card-controller/initialization/initialization-manager.ts +++ b/src/card-controller/initialization/initialization-manager.ts @@ -1,6 +1,5 @@ import PQueue from 'p-queue'; -import { isHassReady } from '../../ha/is-hass-ready'; import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements'; import { loadLanguages } from '../../localize/localize'; import { errorToConsole } from '../../utils/basic'; @@ -104,7 +103,7 @@ export class InitializationManager { return ( this._api.getConfigManager().hasConfig() && this._api.getCardElementManager().isConnected() && - isHassReady(this._api.getHASSManager().getHASS()) && + this._api.getHASSManager().isReady() && // Start when aspects remain to be initialized, or when the session is // idle even though aspects are otherwise initialized. A run that // initialized every aspect and then declined (e.g. because something @@ -138,11 +137,10 @@ export class InitializationManager { // and the card may be detached, lose Home Assistant, or finish initializing // while it waits. This is what stops a stale attempt running. // - // The `isHassReady` call also narrows `hass` for the steps below. Its - // RUNNING requirement waits out a Home Assistant that is still loading - // integrations, against which integration-specific WS calls fail with - // "Unknown command". - if (!isHassReady(hass) || !this._shouldInitializeMandatory()) { + // Readiness itself is asked of the HASS manager, whose RUNNING requirement + // waits out a Home Assistant that is still loading integrations, against + // which integration-specific WS calls fail with "Unknown command". + if (!hass || !this._shouldInitializeMandatory()) { return; } diff --git a/src/card-controller/issues/issues/connection.ts b/src/card-controller/issues/issues/connection.ts index d4f251fe..b8c1a3a5 100644 --- a/src/card-controller/issues/issues/connection.ts +++ b/src/card-controller/issues/issues/connection.ts @@ -1,41 +1,21 @@ -import { STATE_RUNNING } from 'home-assistant-js-websocket'; - import type { ConditionState } from '../../../condition-trigger/conditions/types.js'; import { localize } from '../../../localize/localize.js'; +import type { HASSReadiness } from '../../hass/types.js'; import type { Issue, IssueDescription } from '../types.js'; -// Tracks "is HA fully ready to talk to". Active in two sub-states: -// - 'lost' : the WebSocket is disconnected -// - 'starting' : the WebSocket is connected but HA hasn't finished loading -// integrations yet (hass.config.state !== STATE_RUNNING) -// Both sub-states render as a full-card notification with a spinner. The card -// only attempts re-initialization when the issue clears (i.e. HA is fully -// ready), so integration-specific WS calls (e.g. Frigate event subscriptions) -// don't fail with "Unknown command" against a half-loaded HA. -type ConnectionState = 'ready' | 'lost' | 'starting'; - export class ConnectionIssue implements Issue { public readonly key = 'connection' as const; - private _state: ConnectionState = 'ready'; + private _readiness: HASSReadiness = 'ready'; public detectDynamic(state: ConditionState): void { - // Before HASS is ever provided, leave state untouched -- undefined hass is - // not a disconnection, just "not yet initialized". - if (state.hass === undefined) { - return; - } - if (!state.hass.connected) { - this._state = 'lost'; - } else if (state.hass.config?.state !== STATE_RUNNING) { - this._state = 'starting'; - } else { - this._state = 'ready'; + if (state.hassReadiness) { + this._readiness = state.hassReadiness; } } public hasIssue(): boolean { - return this._state !== 'ready'; + return this._readiness !== 'ready'; } public isFullCardIssue(): boolean { @@ -43,7 +23,7 @@ export class ConnectionIssue implements Issue { } public getIssue(): IssueDescription | null { - return this._state === 'lost' + return this._readiness === 'disconnected' ? { icon: 'mdi:lan-disconnect', severity: 'high', @@ -57,7 +37,7 @@ export class ConnectionIssue implements Issue { in_progress: true, }, } - : this._state === 'starting' + : this._readiness === 'starting' ? { icon: 'mdi:home-assistant', severity: 'medium', @@ -75,6 +55,6 @@ export class ConnectionIssue implements Issue { } public reset(): void { - this._state = 'ready'; + this._readiness = 'ready'; } } diff --git a/src/condition-trigger/conditions/types.ts b/src/condition-trigger/conditions/types.ts index a60aa973..b32565a7 100644 --- a/src/condition-trigger/conditions/types.ts +++ b/src/condition-trigger/conditions/types.ts @@ -1,3 +1,4 @@ +import type { HASSReadiness } from '../../card-controller/hass/types'; import type { KeysState, MicrophoneState } from '../../card-controller/types'; import type { AdvancedCameraCardView } from '../../config/schema/common/const'; import type { ViewDisplayMode } from '../../config/schema/common/display'; @@ -31,7 +32,12 @@ export interface ConditionState { mediaLoadedInfo?: MediaLoadedInfo | null; microphone?: MicrophoneState; panel?: boolean; + + // Home Assistant: + // - The main HA object itself. hass?: HomeAssistant; + // - The card's view of HA's readiness. + hassReadiness?: HASSReadiness; // Initialization: // - Currently initialized. diff --git a/src/ha/connection/subscription-manager.ts b/src/ha/connection/subscription-manager.ts index a3df8860..d2816b82 100644 --- a/src/ha/connection/subscription-manager.ts +++ b/src/ha/connection/subscription-manager.ts @@ -6,7 +6,6 @@ import { type GetKeyCallback, } from '../../utils/concurrency/keyed-subscription-manager'; import { RetryTimer } from '../../utils/retry-timer'; -import { isHassReady } from '../is-hass-ready'; import type { HASSSource } from '../source'; import type { HomeAssistant } from '../types'; import type { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types'; @@ -226,7 +225,7 @@ export class HASSConnectionSubscriptionManager { } private _handleHASSChange(hass: HomeAssistant | null): void { - if (!hass || !isHassReady(hass)) { + if (!hass || !this._source.isReady()) { if (this._connectionEra !== null) { this._endEra(); // Surface the era end to consumers so they can update any UI that was diff --git a/src/ha/is-hass-ready.ts b/src/ha/is-hass-ready.ts deleted file mode 100644 index a20e5fa7..00000000 --- a/src/ha/is-hass-ready.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { STATE_RUNNING } from 'home-assistant-js-websocket'; - -import type { HomeAssistant } from './types'; - -// HA is "ready" when the WebSocket is connected AND integrations have finished -// loading (`config.state === STATE_RUNNING`). HA exposes the WebSocket before -// integrations load, so `connected` alone is insufficient for -// integration-specific calls (e.g. Frigate WS subscriptions, which fail with -// "Unknown command" against a half-loaded HA). -// -// Typed as a predicate so callers can use the readiness check to narrow a -// nullable `hass?` reference to a non-null `HomeAssistant` for follow-up -// `hass.connection`-style access. -export const isHassReady = (hass?: HomeAssistant | null): hass is HomeAssistant => - !!hass?.connected && hass.config?.state === STATE_RUNNING; diff --git a/src/ha/source.ts b/src/ha/source.ts index 72b9884c..c0820a48 100644 --- a/src/ha/source.ts +++ b/src/ha/source.ts @@ -11,5 +11,10 @@ export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null) export interface HASSSource { getHASS(): HomeAssistant | null; + + // Whether the current HASS is ready to be talked to. Consumers must ask here + // rather than testing the HASS themselves. + isReady(): boolean; + addListener(listener: HASSListener): UnsubscribeCallback; } diff --git a/tests/browser/fake-hass.ts b/tests/browser/fake-hass.ts index 5210e9b8..af2189b2 100644 --- a/tests/browser/fake-hass.ts +++ b/tests/browser/fake-hass.ts @@ -210,6 +210,14 @@ export class FakeHASS { */ public setConnected(connected: boolean): void { this._connected = connected; + + // The real frontend keeps the pre-disconnect `hass.config` until its own + // `get_config` resolves after the socket returns. Mint a fresh object + // here to models that behavior. + if (connected) { + this._config = createConfig(this._config.state); + } + this._renew(); } diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index 856f299f..9ab0b0ac 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -5,11 +5,25 @@ import { EventWatcher } from '../../../src/card-controller/hass/event-watcher'; import { HASSManager } from '../../../src/card-controller/hass/hass-manager'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { InitializationAspect } from '../../../src/card-controller/initialization/initialization-manager'; +import type { HomeAssistant } from '../../../src/ha/types'; import { createCameraManager, createStore } from '../../camera-manager/test-utils'; import { createCameraConfig, createConfig } from '../../config/test-utils'; import { createCardAPI, createHASS, createStateEntity } from '../../test-utils'; import { createView } from '../../view/test-utils'; +// The Home Assistant frontend merges each update into the previous `hass`, so +// until it has read the configuration again the card keeps being handed the +// exact object it already had. +const createHASSWithConfigOf = ( + source: HomeAssistant, + options: { connected: boolean }, +): HomeAssistant => { + const hass = createHASS(); + hass.connected = options.connected; + hass.config = source.config; + return hass; +}; + describe('HASSManager', () => { beforeEach(() => { vi.resetAllMocks(); @@ -98,6 +112,106 @@ describe('HASSManager', () => { }); }); + describe('should report readiness when', () => { + it('should report not ready before any hass is set', () => { + const manager = new HASSManager(createCardAPI()); + + expect(manager.isReady()).toBeFalsy(); + }); + + it('should report not ready when disconnected', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = false; + hass.config.state = STATE_RUNNING; + manager.setHASS(hass); + + expect(manager.isReady()).toBeFalsy(); + }); + + it('should report not ready while integrations are still loading', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_STARTING; + manager.setHASS(hass); + + expect(manager.isReady()).toBeFalsy(); + }); + + it('should report not ready when reconnected with the pre-disconnect config', () => { + const manager = new HASSManager(createCardAPI()); + + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + manager.setHASS(hass); + expect(manager.isReady()).toBeTruthy(); + + manager.setHASS(createHASSWithConfigOf(hass, { connected: false })); + manager.setHASS(createHASSWithConfigOf(hass, { connected: true })); + + expect(manager.isReady()).toBeFalsy(); + }); + + it('should report ready when connected and running', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + manager.setHASS(hass); + + expect(manager.isReady()).toBeTruthy(); + }); + + it('should report disconnected readiness before any hass is set', () => { + const manager = new HASSManager(createCardAPI()); + expect(manager.getReadiness()).toBe('disconnected'); + }); + + it('should report disconnected readiness when disconnected', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = false; + manager.setHASS(hass); + + expect(manager.getReadiness()).toBe('disconnected'); + }); + + it('should report starting readiness while integrations are still loading', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_STARTING; + manager.setHASS(hass); + + expect(manager.getReadiness()).toBe('starting'); + }); + + it('should report starting readiness when reconnected with stale config', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + manager.setHASS(hass); + + manager.setHASS(createHASSWithConfigOf(hass, { connected: false })); + manager.setHASS(createHASSWithConfigOf(hass, { connected: true })); + + expect(manager.getReadiness()).toBe('starting'); + }); + + it('should report ready readiness when connected and running', () => { + const manager = new HASSManager(createCardAPI()); + const hass = createHASS(); + hass.connected = true; + hass.config.state = STATE_RUNNING; + manager.setHASS(hass); + + expect(manager.getReadiness()).toBe('ready'); + }); + }); + describe('should handle connection state change when', () => { it('should end the session on ready → lost transition', () => { const api = createCardAPI(); @@ -267,6 +381,92 @@ describe('HASSManager', () => { manager.setHASS(null); manager.setHASS(connectedHASS); }); + + it('should end the session when the connection is lost and the configuration is unchanged', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false })); + + expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled(); + }); + + it('should not reinitialize while a reconnection still reports the pre-disconnection configuration', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false })); + + // The socket is back, but Home Assistant has not answered the frontend's + // request for its configuration, so the RUNNING it reports is the one + // from before the restart. + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true })); + + expect(manager.isReady()).toBeFalsy(); + expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled(); + expect(api.getCameraManager().destroy).not.toHaveBeenCalled(); + }); + + it('should not reinitialize when a freshly read configuration reports Home Assistant still starting', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false })); + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true })); + + const startingHASS = createHASS(); + startingHASS.connected = true; + startingHASS.config.state = STATE_STARTING; + manager.setHASS(startingHASS); + + expect(manager.isReady()).toBeFalsy(); + expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled(); + expect(api.getCameraManager().destroy).not.toHaveBeenCalled(); + }); + + it('should reinitialize once after a freshly read configuration reports Home Assistant running', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); + + const readyHASS = createHASS(); + readyHASS.connected = true; + readyHASS.config.state = STATE_RUNNING; + manager.setHASS(readyHASS); + + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false })); + manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true })); + + const startingHASS = createHASS(); + startingHASS.connected = true; + startingHASS.config.state = STATE_STARTING; + manager.setHASS(startingHASS); + + const recoveredHASS = createHASS(); + recoveredHASS.connected = true; + recoveredHASS.config.state = STATE_RUNNING; + manager.setHASS(recoveredHASS); + + expect(manager.isReady()).toBeTruthy(); + expect(api.getCameraManager().destroy).toHaveBeenCalledOnce(); + expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith( + InitializationAspect.CAMERAS, + ); + }); }); describe('should not set default view when', () => { diff --git a/tests/card-controller/initialization/initialization-manager.test.ts b/tests/card-controller/initialization/initialization-manager.test.ts index 6997af83..530abacf 100644 --- a/tests/card-controller/initialization/initialization-manager.test.ts +++ b/tests/card-controller/initialization/initialization-manager.test.ts @@ -8,6 +8,7 @@ import { } from '../../../src/card-controller/initialization/initialization-manager'; import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager'; import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements.js'; +import type { HomeAssistant } from '../../../src/ha/types'; import { loadLanguages } from '../../../src/localize/localize'; import type { Initializer } from '../../../src/utils/initializer/initializer'; import { createConfig } from '../../config/test-utils'; @@ -16,6 +17,15 @@ import { createCardAPI, createHASS } from '../../test-utils'; vi.mock('../../../src/localize/localize.js'); vi.mock('../../../src/ha/side-load-ha-elements.js'); +const setupHASSMocks = ( + api: ReturnType, + hass: HomeAssistant, + ready = true, +): void => { + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getHASSManager().isReady).mockReturnValue(ready); +}; + // An API that passes the whole start predicate, checked both when an attempt is // queued and again when it runs. const createReadyAPI = (): ReturnType => { @@ -26,7 +36,7 @@ const createReadyAPI = (): ReturnType => { const hass = createHASS(); hass.connected = true; hass.config.state = STATE_RUNNING; - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + setupHASSMocks(api, hass); vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue).mockReturnValue( false, ); @@ -78,7 +88,7 @@ describe('InitializationManager', () => { it('should handle without config', async () => { const api = createCardAPI(); const manager = new InitializationManager(api); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + setupHASSMocks(api, createHASS()); await manager.initializeMandatory(); expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy(); @@ -88,7 +98,7 @@ describe('InitializationManager', () => { const api = createReadyAPI(); const hass = createHASS(); hass.config.state = STATE_STARTING; - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + setupHASSMocks(api, hass, false); const initializer = mock(); const manager = new InitializationManager(api, initializer); @@ -709,7 +719,7 @@ describe('InitializationManager', () => { const hass = createHASS(); hass.connected = true; hass.config.state = STATE_STARTING; - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + setupHASSMocks(api, hass, false); const initializer = mock(); const manager = new InitializationManager(api, initializer); diff --git a/tests/card-controller/issues/issues/connection.test.ts b/tests/card-controller/issues/issues/connection.test.ts index ef4c41f7..b5c1eae9 100644 --- a/tests/card-controller/issues/issues/connection.test.ts +++ b/tests/card-controller/issues/issues/connection.test.ts @@ -1,8 +1,6 @@ -import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket'; import { describe, expect, it } from 'vitest'; import { ConnectionIssue } from '../../../../src/card-controller/issues/issues/connection'; -import { createHASS } from '../../../test-utils'; describe('ConnectionIssue', () => { it('should have correct key', () => { @@ -10,7 +8,7 @@ describe('ConnectionIssue', () => { expect(issue.key).toBe('connection'); }); - it('should report no issue when hass has never been set', () => { + it('should report no issue when hassReadiness has never been set', () => { const issue = new ConnectionIssue(); issue.detectDynamic({}); @@ -19,12 +17,10 @@ describe('ConnectionIssue', () => { expect(issue.getIssue()).toBeNull(); }); - it('should report a lost issue when hass is disconnected', () => { + it('should report a lost issue when disconnected', () => { const issue = new ConnectionIssue(); - const hass = createHASS(); - hass.connected = false; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'disconnected' }); expect(issue.hasIssue()).toBe(true); expect(issue.getIssue()).toEqual( @@ -46,13 +42,10 @@ describe('ConnectionIssue', () => { ); }); - it('should report a starting issue when hass is connected but not running', () => { + it('should report a starting issue when starting', () => { const issue = new ConnectionIssue(); - const hass = createHASS(); - hass.connected = true; - hass.config.state = STATE_STARTING; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'starting' }); expect(issue.hasIssue()).toBe(true); expect(issue.getIssue()).toEqual( @@ -74,40 +67,50 @@ describe('ConnectionIssue', () => { ); }); - it('should not report an issue when hass is connected and running', () => { + it('should not report an issue when ready', () => { const issue = new ConnectionIssue(); - const hass = createHASS(); - hass.connected = true; - hass.config.state = STATE_RUNNING; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'ready' }); expect(issue.hasIssue()).toBe(false); expect(issue.getIssue()).toBeNull(); }); - it('should clear when hass transitions lost → starting → ready', () => { + it('should clear when hass transitions disconnected to starting to ready', () => { const issue = new ConnectionIssue(); - const hass = createHASS(); - hass.connected = false; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'disconnected' }); expect(issue.hasIssue()).toBe(true); expect(issue.getIssue()?.notification.heading?.text).toBe('Connection lost'); - hass.connected = true; - hass.config.state = STATE_STARTING; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'starting' }); expect(issue.hasIssue()).toBe(true); expect(issue.getIssue()?.notification.heading?.text).toBe( 'Home Assistant is starting', ); - hass.config.state = STATE_RUNNING; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'ready' }); expect(issue.hasIssue()).toBe(false); }); + it('should report starting when reconnected with stale config', () => { + const issue = new ConnectionIssue(); + + issue.detectDynamic({ hassReadiness: 'ready' }); + expect(issue.hasIssue()).toBe(false); + + issue.detectDynamic({ hassReadiness: 'disconnected' }); + expect(issue.hasIssue()).toBe(true); + + // HASSManager reports 'starting' when the reconnected hass still carries + // the pre-disconnect config object. + issue.detectDynamic({ hassReadiness: 'starting' }); + expect(issue.hasIssue()).toBe(true); + expect(issue.getIssue()?.notification.heading?.text).toBe( + 'Home Assistant is starting', + ); + }); + it('should return true for isFullCardIssue', () => { const issue = new ConnectionIssue(); expect(issue.isFullCardIssue()).toBe(true); @@ -115,9 +118,7 @@ describe('ConnectionIssue', () => { it('should clear the issue after reset', () => { const issue = new ConnectionIssue(); - const hass = createHASS(); - hass.connected = false; - issue.detectDynamic({ hass }); + issue.detectDynamic({ hassReadiness: 'disconnected' }); expect(issue.hasIssue()).toBe(true); issue.reset(); diff --git a/tests/ha/is-hass-ready.test.ts b/tests/ha/is-hass-ready.test.ts deleted file mode 100644 index e012ed0f..00000000 --- a/tests/ha/is-hass-ready.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { STATE_RUNNING } from 'home-assistant-js-websocket'; -import { describe, expect, it } from 'vitest'; - -import { isHassReady } from '../../src/ha/is-hass-ready'; -import { createHASS } from '../test-utils'; - -describe('isHassReady', () => { - it('should return false for null', () => { - expect(isHassReady(null)).toBe(false); - }); - - it('should return false for undefined', () => { - expect(isHassReady(undefined)).toBe(false); - }); - - it('should return false when disconnected', () => { - const hass = createHASS(); - hass.connected = false; - expect(isHassReady(hass)).toBe(false); - }); - - it('should return false when integrations are still loading', () => { - const hass = createHASS(); - hass.connected = true; - hass.config.state = 'NOT_RUNNING'; - expect(isHassReady(hass)).toBe(false); - }); - - it('should return true when connected and running', () => { - const hass = createHASS(); - hass.connected = true; - hass.config.state = STATE_RUNNING; - expect(isHassReady(hass)).toBe(true); - }); -}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 3ac8b385..56c21061 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -109,6 +109,7 @@ export const createHASSSource = ( const listeners = new Set(); const source: HASSSource = { getHASS: () => current, + isReady: () => !!current?.connected && current.config?.state === STATE_RUNNING, addListener: (listener) => { listeners.add(listener); return () => { @@ -135,9 +136,9 @@ export const createHASSManager = (options?: { eventWatcher?: EventWatcherSubscriptionInterface; }): HASSManagerReadonlyInterface => { const hassManager = mock(); - hassManager.getHASS.mockReturnValue( - options?.hass === undefined ? createHASS() : options.hass, - ); + const hass = options?.hass === undefined ? createHASS() : options.hass; + hassManager.getHASS.mockReturnValue(hass); + hassManager.isReady.mockReturnValue(!!hass); hassManager.getStateWatcher.mockReturnValue( options?.stateWatcher ?? mock(), );