@digital-alchemy/hass 25.11.16 → 25.11.17-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/helpers/entity-state.d.mts +20 -3
- package/dist/helpers/utility.d.mts +1 -1
- package/dist/mock_assistant/mock-assistant.module.mjs.map +1 -1
- package/dist/mock_assistant/services/websocket-api.service.d.mts +3 -1
- package/dist/mock_assistant/services/websocket-api.service.mjs +43 -4
- package/dist/mock_assistant/services/websocket-api.service.mjs.map +1 -1
- package/dist/services/feature.service.d.mts +2 -2
- package/dist/services/feature.service.mjs.map +1 -1
- package/dist/services/reference.service.d.mts +1 -1
- package/dist/services/reference.service.mjs +59 -5
- package/dist/services/reference.service.mjs.map +1 -1
- package/dist/testing/entity.spec.mjs +14 -0
- package/dist/testing/entity.spec.mjs.map +1 -1
- package/dist/testing/ref-by.spec.mjs +802 -2
- package/dist/testing/ref-by.spec.mjs.map +1 -1
- package/dist/testing/scheduler.spec.d.mts +1 -0
- package/dist/testing/scheduler.spec.mjs +412 -0
- package/dist/testing/scheduler.spec.mjs.map +1 -0
- package/dist/testing/workflow.spec.mjs +1 -1
- package/dist/testing/workflow.spec.mjs.map +1 -1
- package/package.json +16 -16
- package/src/helpers/entity-state.mts +20 -3
- package/src/helpers/utility.mts +1 -1
- package/src/mock_assistant/mock-assistant.module.mts +0 -1
- package/src/mock_assistant/services/websocket-api.service.mts +46 -4
- package/src/services/feature.service.mts +9 -6
- package/src/services/reference.service.mts +75 -7
- package/src/testing/entity.spec.mts +15 -0
- package/src/testing/ref-by.spec.mts +962 -2
- package/src/testing/scheduler.spec.mts +444 -0
- package/src/testing/workflow.spec.mts +1 -1
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "@digital-alchemy/hass",
|
|
4
4
|
"repository": "https://github.com/Digital-Alchemy-TS/hass",
|
|
5
5
|
"homepage": "https://docs.digital-alchemy.app",
|
|
6
|
-
"version": "25.11.
|
|
6
|
+
"version": "25.11.17-beta.0",
|
|
7
7
|
"description": "Typescript APIs for Home Assistant. Includes rest & websocket bindings",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "rm -rf dist/; tsc",
|
|
@@ -58,25 +58,25 @@
|
|
|
58
58
|
},
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@cspell/eslint-plugin": "^9.
|
|
62
|
-
"@digital-alchemy/core": "^25.
|
|
61
|
+
"@cspell/eslint-plugin": "^9.3.2",
|
|
62
|
+
"@digital-alchemy/core": "^25.11.19",
|
|
63
63
|
"@digital-alchemy/synapse": "^25.8.21",
|
|
64
|
-
"@digital-alchemy/type-writer": "^25.
|
|
65
|
-
"@eslint/compat": "^
|
|
64
|
+
"@digital-alchemy/type-writer": "^25.11.16",
|
|
65
|
+
"@eslint/compat": "^2.0.0",
|
|
66
66
|
"@eslint/eslintrc": "^3.3.1",
|
|
67
|
-
"@eslint/js": "^9.
|
|
67
|
+
"@eslint/js": "^9.39.1",
|
|
68
68
|
"@faker-js/faker": "^10.1.0",
|
|
69
69
|
"@types/js-yaml": "^4.0.9",
|
|
70
|
-
"@types/node": "^24.
|
|
70
|
+
"@types/node": "^24.10.1",
|
|
71
71
|
"@types/node-cron": "^3.0.11",
|
|
72
72
|
"@types/semver": "^7.7.1",
|
|
73
73
|
"@types/ws": "^8.18.1",
|
|
74
|
-
"@typescript-eslint/eslint-plugin": "8.46.
|
|
75
|
-
"@typescript-eslint/parser": "8.46.
|
|
76
|
-
"@vitest/coverage-v8": "^4.0.
|
|
77
|
-
"dayjs": "^1.11.
|
|
74
|
+
"@typescript-eslint/eslint-plugin": "8.46.4",
|
|
75
|
+
"@typescript-eslint/parser": "8.46.4",
|
|
76
|
+
"@vitest/coverage-v8": "^4.0.10",
|
|
77
|
+
"dayjs": "^1.11.19",
|
|
78
78
|
"dotenv": "^17.2.3",
|
|
79
|
-
"eslint": "9.
|
|
79
|
+
"eslint": "9.39.1",
|
|
80
80
|
"eslint-config-prettier": "10.1.8",
|
|
81
81
|
"eslint-plugin-import": "^2.32.0",
|
|
82
82
|
"eslint-plugin-jsonc": "^2.21.0",
|
|
@@ -93,18 +93,18 @@
|
|
|
93
93
|
"tsx": "^4.20.6",
|
|
94
94
|
"typescript": "^5.9.3",
|
|
95
95
|
"uuid": "^13.0.0",
|
|
96
|
-
"vitest": "^4.0.
|
|
96
|
+
"vitest": "^4.0.10",
|
|
97
97
|
"ws": "^8.18.3"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
100
100
|
"@digital-alchemy/core": "*"
|
|
101
101
|
},
|
|
102
102
|
"dependencies": {
|
|
103
|
-
"dayjs": "^1.11.
|
|
103
|
+
"dayjs": "^1.11.19",
|
|
104
104
|
"semver": "^7.7.3",
|
|
105
|
-
"type-fest": "^5.
|
|
105
|
+
"type-fest": "^5.2.0",
|
|
106
106
|
"uuid": "^13.0.0",
|
|
107
107
|
"ws": "^8.18.3"
|
|
108
108
|
},
|
|
109
|
-
"packageManager": "yarn@4.
|
|
109
|
+
"packageManager": "yarn@4.11.0"
|
|
110
110
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FIRST, RemoveCallback, TBlackHole } from "@digital-alchemy/core";
|
|
1
|
+
import type { FIRST, RemoveCallback, TBlackHole, TContext, TOffset } from "@digital-alchemy/core";
|
|
2
2
|
import type { Dayjs } from "dayjs";
|
|
3
3
|
import type { Except } from "type-fest";
|
|
4
4
|
|
|
@@ -46,6 +46,17 @@ export type RemovableCallback<ENTITY_ID extends ANY_ENTITY> = (
|
|
|
46
46
|
callback: TEntityUpdateCallback<ENTITY_ID>,
|
|
47
47
|
) => RemoveCallback;
|
|
48
48
|
|
|
49
|
+
export type OnStateForOptions<ENTITY_ID extends ANY_ENTITY> = (
|
|
50
|
+
| (Pick<ENTITY_STATE<ENTITY_ID>, "state"> & { matches?: never })
|
|
51
|
+
| ({
|
|
52
|
+
matches: (new_state: ENTITY_STATE<ENTITY_ID>, old_state: ENTITY_STATE<ENTITY_ID>) => boolean;
|
|
53
|
+
} & { state?: never })
|
|
54
|
+
) & {
|
|
55
|
+
for: TOffset;
|
|
56
|
+
context?: TContext;
|
|
57
|
+
exec: (state: ByIdProxy<ENTITY_ID>) => TBlackHole;
|
|
58
|
+
};
|
|
59
|
+
|
|
49
60
|
export type ByIdProxy<ENTITY_ID extends ANY_ENTITY> = ENTITY_STATE<ENTITY_ID> & {
|
|
50
61
|
entity_id: ENTITY_ID;
|
|
51
62
|
/**
|
|
@@ -63,11 +74,17 @@ export type ByIdProxy<ENTITY_ID extends ANY_ENTITY> = ENTITY_STATE<ENTITY_ID> &
|
|
|
63
74
|
/**
|
|
64
75
|
* Will resolve with the next state of the next value. No time limit
|
|
65
76
|
*/
|
|
66
|
-
nextState: (
|
|
77
|
+
nextState: (timeout?: TOffset) => Promise<ENTITY_STATE<ENTITY_ID>>;
|
|
78
|
+
/**
|
|
79
|
+
* Runs on state change -
|
|
80
|
+
* If state matches the indicated target, then a timer will be initiated
|
|
81
|
+
* As long as the condition holds true, the callback will be executed at end of the timer
|
|
82
|
+
*/
|
|
83
|
+
onStateFor: (options: OnStateForOptions<ENTITY_ID>) => RemoveCallback;
|
|
67
84
|
/**
|
|
68
85
|
* Will resolve when state
|
|
69
86
|
*/
|
|
70
|
-
waitForState: (state: string | number,
|
|
87
|
+
waitForState: (state: string | number, timeout?: TOffset) => Promise<ENTITY_STATE<ENTITY_ID>>;
|
|
71
88
|
/**
|
|
72
89
|
* Access the immediate previous entity state
|
|
73
90
|
*/
|
package/src/helpers/utility.mts
CHANGED
|
@@ -5,6 +5,7 @@ import type { PartialDeep, WritableDeep } from "type-fest";
|
|
|
5
5
|
import type WS from "ws";
|
|
6
6
|
|
|
7
7
|
import type { SocketMessageDTO } from "../../helpers/index.mts";
|
|
8
|
+
import { SOCKET_CONNECTED } from "../../services/websocket-api.service.mts";
|
|
8
9
|
|
|
9
10
|
const CONNECTION_CLOSED = 0;
|
|
10
11
|
// const CONNECTION_OPEN = 1;
|
|
@@ -13,7 +14,7 @@ const UNLIMITED = 0;
|
|
|
13
14
|
|
|
14
15
|
export const INTERNAL_MESSAGE = "INTERNAL_MESSAGE";
|
|
15
16
|
|
|
16
|
-
export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
|
|
17
|
+
export function MockWebsocketAPI({ hass, config, lifecycle, event }: TServiceParams) {
|
|
17
18
|
const connection = new EventEmitter() as WritableDeep<WS>;
|
|
18
19
|
connection.setMaxListeners(UNLIMITED);
|
|
19
20
|
lifecycle.onShutdownStart(() => {
|
|
@@ -28,7 +29,8 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
|
|
|
28
29
|
// connection.send = (...data) =>
|
|
29
30
|
|
|
30
31
|
hass.socket.createConnection = () => {
|
|
31
|
-
|
|
32
|
+
// Use Promise.resolve().then() instead of setImmediate for better compatibility with fake timers
|
|
33
|
+
Promise.resolve().then(() => {
|
|
32
34
|
if (!config.mock_assistant.PASS_AUTH) {
|
|
33
35
|
return;
|
|
34
36
|
}
|
|
@@ -37,6 +39,40 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
|
|
|
37
39
|
return connection;
|
|
38
40
|
};
|
|
39
41
|
|
|
42
|
+
// Mock init - resolves immediately for tests
|
|
43
|
+
// The real service's init() waits for onSocketReady, but in tests we don't need that
|
|
44
|
+
async function init(): Promise<void> {
|
|
45
|
+
if (hass.socket.connection) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
hass.socket.connection = hass.socket.createConnection("");
|
|
49
|
+
hass.socket.setConnectionState("connecting");
|
|
50
|
+
|
|
51
|
+
// Set up message handler - the real service's handlers registered in onPreInit
|
|
52
|
+
// will process messages via registerMessageHandler
|
|
53
|
+
connection.on("message", async (message: string) => {
|
|
54
|
+
try {
|
|
55
|
+
JSON.parse(message.toString());
|
|
56
|
+
// Real service's handlers will process this via onMessage/registerMessageHandler
|
|
57
|
+
} catch {
|
|
58
|
+
// Ignore parse errors
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// In tests, we can resolve immediately without waiting for real socket handshake
|
|
63
|
+
// The real service's onBootstrap calls this, and it needs to complete for lifecycle to progress
|
|
64
|
+
hass.socket.setConnectionState("connected");
|
|
65
|
+
|
|
66
|
+
// Emit auth_ok to trigger the real service's handlers (registered in onPreInit)
|
|
67
|
+
// This allows the real service's auth_ok handler to run and do its setup
|
|
68
|
+
if (config.mock_assistant.PASS_AUTH) {
|
|
69
|
+
// Use microtask to ensure handlers are registered first
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
sendMessage({ type: "auth_ok" });
|
|
72
|
+
event.emit(SOCKET_CONNECTED);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
40
76
|
connection.send = (data: string) => {
|
|
41
77
|
const payload = JSON.parse(data) as { type: string; id: number };
|
|
42
78
|
connection.emit(INTERNAL_MESSAGE, payload);
|
|
@@ -49,7 +85,7 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
|
|
|
49
85
|
return;
|
|
50
86
|
}
|
|
51
87
|
default: {
|
|
52
|
-
|
|
88
|
+
Promise.resolve().then(() => {
|
|
53
89
|
sendMessage({
|
|
54
90
|
id: payload.id,
|
|
55
91
|
result: null,
|
|
@@ -61,13 +97,19 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
|
|
|
61
97
|
};
|
|
62
98
|
|
|
63
99
|
function sendMessage(data: PartialDeep<SocketMessageDTO>) {
|
|
64
|
-
|
|
100
|
+
// Use Promise.resolve() instead of setImmediate for better fake timer compatibility
|
|
101
|
+
Promise.resolve().then(() => {
|
|
65
102
|
connection.emit("message", JSON.stringify(data));
|
|
66
103
|
});
|
|
67
104
|
}
|
|
68
105
|
|
|
69
106
|
return {
|
|
107
|
+
attachScheduledFunctions: () => {
|
|
108
|
+
// Mock implementation - no-op for tests
|
|
109
|
+
// The real websocket service sets up intervals here, but we don't need them in tests
|
|
110
|
+
},
|
|
70
111
|
connection: connection as WS,
|
|
112
|
+
init,
|
|
71
113
|
onMessage<DATA extends object>(
|
|
72
114
|
type: string,
|
|
73
115
|
callback: (data: DATA & MessageData) => TBlackHole,
|
|
@@ -38,7 +38,9 @@ export function HassFeatureService({
|
|
|
38
38
|
}, 0);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function lookup
|
|
41
|
+
function lookup<T extends UsedSupportedFeatureDomains>(
|
|
42
|
+
input: PICK_ENTITY | ByIdProxy<PICK_ENTITY<T>>,
|
|
43
|
+
) {
|
|
42
44
|
const ref = is.string(input) ? hass.refBy.id(input) : input;
|
|
43
45
|
const attributes = ref.attributes as { supported_features: number };
|
|
44
46
|
return attributes?.supported_features ?? 0;
|
|
@@ -69,7 +71,9 @@ export function HassFeatureService({
|
|
|
69
71
|
* Helper function to get all supported features as an array
|
|
70
72
|
* Can accept a number (bitmask), entity ID, or entity proxy
|
|
71
73
|
*/
|
|
72
|
-
function getSupportedFeatures
|
|
74
|
+
function getSupportedFeatures<T extends UsedSupportedFeatureDomains>(
|
|
75
|
+
input: number | PICK_ENTITY | ByIdProxy<PICK_ENTITY<T>>,
|
|
76
|
+
): number[] {
|
|
73
77
|
const features = is.number(input) ? input : lookup(input);
|
|
74
78
|
|
|
75
79
|
const supported: number[] = [];
|
|
@@ -84,10 +88,9 @@ export function HassFeatureService({
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
function listEntityFeatures<
|
|
87
|
-
DOMAIN extends UsedSupportedFeatureDomains
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
): SupportedEntityFeatures<DOMAIN>[] {
|
|
91
|
+
DOMAIN extends UsedSupportedFeatureDomains,
|
|
92
|
+
ENTITY extends PICK_ENTITY<DOMAIN> = PICK_ENTITY<DOMAIN>,
|
|
93
|
+
>(input: ENTITY | ByIdProxy<ENTITY>): SupportedEntityFeatures<DOMAIN>[] {
|
|
91
94
|
const inputDomain = domain(is.string(input) ? input : input.entity_id);
|
|
92
95
|
const domainFeatures = SUPPORTED_FEATURES[inputDomain.toUpperCase() as SupportedFeatureDomains];
|
|
93
96
|
if (!domainFeatures) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TAnyFunction, TServiceParams } from "@digital-alchemy/core";
|
|
1
|
+
import type { TAnyFunction, TOffset, TServiceParams } from "@digital-alchemy/core";
|
|
2
2
|
import { DOWN, NONE, sleep, UP } from "@digital-alchemy/core";
|
|
3
3
|
import type { Dayjs } from "dayjs";
|
|
4
4
|
import dayjs from "dayjs";
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ByIdProxy,
|
|
10
10
|
ENTITY_STATE,
|
|
11
11
|
HassReferenceService,
|
|
12
|
+
OnStateForOptions,
|
|
12
13
|
RemoveCallback,
|
|
13
14
|
} from "../helpers/index.mts";
|
|
14
15
|
import { domain, perf } from "../helpers/index.mts";
|
|
@@ -86,6 +87,7 @@ export function ReferenceService({
|
|
|
86
87
|
hass,
|
|
87
88
|
logger,
|
|
88
89
|
internal,
|
|
90
|
+
scheduler,
|
|
89
91
|
event,
|
|
90
92
|
}: TServiceParams): HassReferenceService {
|
|
91
93
|
const { is } = internal.utils;
|
|
@@ -94,6 +96,9 @@ export function ReferenceService({
|
|
|
94
96
|
entity: ENTITY,
|
|
95
97
|
property: PROPERTY,
|
|
96
98
|
): Get<ENTITY_STATE<ENTITY>, PROPERTY> {
|
|
99
|
+
if (!is.string(property)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
97
102
|
const valid = ["state", "attributes", "last"].some(i => property.startsWith(i));
|
|
98
103
|
if (!valid) {
|
|
99
104
|
logger.error({ entity, name: proxyGetLogic, property }, `invalid property lookup`);
|
|
@@ -134,6 +139,7 @@ export function ReferenceService({
|
|
|
134
139
|
"last",
|
|
135
140
|
"nextState",
|
|
136
141
|
"once",
|
|
142
|
+
"onStateFor",
|
|
137
143
|
"onUpdate",
|
|
138
144
|
"previous",
|
|
139
145
|
"removeAllListeners",
|
|
@@ -170,8 +176,62 @@ export function ReferenceService({
|
|
|
170
176
|
// things that shouldn't be needed: this extract
|
|
171
177
|
// eslint-disable-next-line sonarjs/function-return-type
|
|
172
178
|
get: (_, property: Extract<keyof ByIdProxy<ENTITY_ID>, string>) => {
|
|
179
|
+
// Handle Symbol properties (e.g., when vitest formats test output)
|
|
180
|
+
if (!is.string(property)) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
173
183
|
hass.diagnostics.reference?.get_property.publish({ entity_id, property });
|
|
174
184
|
switch (property) {
|
|
185
|
+
// #MARK: runAfter
|
|
186
|
+
case "onStateFor": {
|
|
187
|
+
return function ({
|
|
188
|
+
context,
|
|
189
|
+
...options
|
|
190
|
+
}: OnStateForOptions<ENTITY_ID>): RemoveCallback {
|
|
191
|
+
let timerRemove: RemoveCallback;
|
|
192
|
+
const remove = proxy.onUpdate((new_state, old_state) => {
|
|
193
|
+
const matches = options.matches
|
|
194
|
+
? options.matches(new_state, old_state)
|
|
195
|
+
: options.state === new_state.state;
|
|
196
|
+
if (!matches) {
|
|
197
|
+
if (timerRemove) {
|
|
198
|
+
timerRemove();
|
|
199
|
+
timerRemove = undefined;
|
|
200
|
+
logger.trace({ context, entity_id }, "cleared timer - state no longer matches");
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (timerRemove) {
|
|
206
|
+
logger.trace({ context, entity_id }, "timer already running, skipping");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
timerRemove = scheduler.setTimeout(async () => {
|
|
210
|
+
logger.trace(
|
|
211
|
+
{ context, entity_id, for: options.for },
|
|
212
|
+
"timer fired - executing callback",
|
|
213
|
+
);
|
|
214
|
+
internal.safeExec({
|
|
215
|
+
context,
|
|
216
|
+
exec: async () => await options.exec(proxy),
|
|
217
|
+
});
|
|
218
|
+
}, options.for);
|
|
219
|
+
logger.trace(
|
|
220
|
+
{ context, entity_id, for: options.for },
|
|
221
|
+
"started timer for state condition",
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return internal.removeFn(() => {
|
|
226
|
+
if (timerRemove) {
|
|
227
|
+
timerRemove();
|
|
228
|
+
}
|
|
229
|
+
remove();
|
|
230
|
+
logger.trace({ context, entity_id }, "removed [onStateFor] listener");
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
175
235
|
// #MARK: onUpdate
|
|
176
236
|
case "onUpdate": {
|
|
177
237
|
return (callback: TAnyFunction) => {
|
|
@@ -246,7 +306,7 @@ export function ReferenceService({
|
|
|
246
306
|
|
|
247
307
|
// #MARK: nextState
|
|
248
308
|
case "nextState": {
|
|
249
|
-
return async (timeout?:
|
|
309
|
+
return async (timeout?: TOffset) =>
|
|
250
310
|
await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
|
|
251
311
|
// - set up cleanup function
|
|
252
312
|
const remove = () => {
|
|
@@ -274,9 +334,13 @@ export function ReferenceService({
|
|
|
274
334
|
|
|
275
335
|
// - race!
|
|
276
336
|
let wait: ReturnType<typeof sleep>;
|
|
277
|
-
if (is.
|
|
337
|
+
if (is.undefined(timeout)) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const duration = internal.utils.getIntervalMs(timeout);
|
|
341
|
+
if (duration > NONE) {
|
|
278
342
|
// keep track of sleep so it can be cleaned up also
|
|
279
|
-
wait = sleep(
|
|
343
|
+
wait = sleep(duration);
|
|
280
344
|
await wait;
|
|
281
345
|
wait = undefined;
|
|
282
346
|
if (done) {
|
|
@@ -290,7 +354,7 @@ export function ReferenceService({
|
|
|
290
354
|
|
|
291
355
|
// #MARK: waitForState
|
|
292
356
|
case "waitForState": {
|
|
293
|
-
return async (state: string | number, timeout?:
|
|
357
|
+
return async (state: string | number, timeout?: TOffset) =>
|
|
294
358
|
await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
|
|
295
359
|
const remove = () => {
|
|
296
360
|
done = undefined;
|
|
@@ -326,8 +390,12 @@ export function ReferenceService({
|
|
|
326
390
|
|
|
327
391
|
event.on(entity_id, complete);
|
|
328
392
|
let wait: ReturnType<typeof sleep>;
|
|
329
|
-
if (is.
|
|
330
|
-
|
|
393
|
+
if (is.undefined(timeout)) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const duration = internal.utils.getIntervalMs(timeout);
|
|
397
|
+
if (duration > NONE) {
|
|
398
|
+
wait = sleep(duration);
|
|
331
399
|
await wait;
|
|
332
400
|
wait = undefined;
|
|
333
401
|
if (done) {
|
|
@@ -242,8 +242,23 @@ describe("Entity", () => {
|
|
|
242
242
|
const spy = vi.fn();
|
|
243
243
|
subscribe(hass.diagnostics.entity.refresh_entities.name, spy);
|
|
244
244
|
|
|
245
|
+
// Mock getAllEntities to return entities so refresh doesn't call process.exit()
|
|
246
|
+
vi.spyOn(hass.fetch, "getAllEntities").mockResolvedValue([
|
|
247
|
+
{
|
|
248
|
+
attributes: {},
|
|
249
|
+
context: { id: "test", parent_id: null, user_id: null },
|
|
250
|
+
entity_id: "sensor.magic",
|
|
251
|
+
last_changed: dayjs(),
|
|
252
|
+
last_reported: dayjs(),
|
|
253
|
+
last_updated: dayjs(),
|
|
254
|
+
state: "unavailable",
|
|
255
|
+
} as ENTITY_STATE<ANY_ENTITY>,
|
|
256
|
+
]);
|
|
257
|
+
|
|
245
258
|
lifecycle.onReady(async () => {
|
|
246
259
|
await hass.entity.refresh();
|
|
260
|
+
// Wait for setImmediate to complete
|
|
261
|
+
await sleep(10);
|
|
247
262
|
expect(spy).toHaveBeenCalledWith(
|
|
248
263
|
expect.objectContaining({
|
|
249
264
|
emitUpdates: [],
|