fix: Prevent premature reinitialization when stale HA reconnects (#2717)
- Closes: #2714
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<typeof createCardAPI>,
|
||||
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<typeof createCardAPI> => {
|
||||
@@ -26,7 +36,7 @@ const createReadyAPI = (): ReturnType<typeof createCardAPI> => {
|
||||
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<Initializer>();
|
||||
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<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -109,6 +109,7 @@ export const createHASSSource = (
|
||||
const listeners = new Set<HASSListener>();
|
||||
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<HASSManagerReadonlyInterface>();
|
||||
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<StateWatcherSubscriptionInterface>(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user