@digital-alchemy/hass 25.11.16 → 25.11.23

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.
Files changed (54) hide show
  1. package/dist/helpers/entity-state.d.mts +20 -3
  2. package/dist/helpers/fetch/service-list.d.mts +86 -5
  3. package/dist/helpers/id-by.d.mts +1 -1
  4. package/dist/helpers/utility.d.mts +1 -1
  5. package/dist/mock_assistant/mock-assistant.module.mjs.map +1 -1
  6. package/dist/mock_assistant/services/websocket-api.service.d.mts +3 -1
  7. package/dist/mock_assistant/services/websocket-api.service.mjs +43 -4
  8. package/dist/mock_assistant/services/websocket-api.service.mjs.map +1 -1
  9. package/dist/services/feature.service.d.mts +2 -2
  10. package/dist/services/feature.service.mjs.map +1 -1
  11. package/dist/services/id-by.service.mjs +4 -1
  12. package/dist/services/id-by.service.mjs.map +1 -1
  13. package/dist/services/reference.service.d.mts +1 -1
  14. package/dist/services/reference.service.mjs +62 -7
  15. package/dist/services/reference.service.mjs.map +1 -1
  16. package/dist/testing/area.spec.mjs +142 -1
  17. package/dist/testing/area.spec.mjs.map +1 -1
  18. package/dist/testing/call-proxy.spec.d.mts +1 -0
  19. package/dist/testing/call-proxy.spec.mjs +204 -0
  20. package/dist/testing/call-proxy.spec.mjs.map +1 -0
  21. package/dist/testing/conversation.spec.mjs +1 -1
  22. package/dist/testing/conversation.spec.mjs.map +1 -1
  23. package/dist/testing/entity.spec.mjs +14 -0
  24. package/dist/testing/entity.spec.mjs.map +1 -1
  25. package/dist/testing/id-by.spec.mjs +38 -0
  26. package/dist/testing/id-by.spec.mjs.map +1 -1
  27. package/dist/testing/ref-by.spec.mjs +805 -4
  28. package/dist/testing/ref-by.spec.mjs.map +1 -1
  29. package/dist/testing/scheduler.spec.d.mts +1 -0
  30. package/dist/testing/scheduler.spec.mjs +412 -0
  31. package/dist/testing/scheduler.spec.mjs.map +1 -0
  32. package/dist/testing/websocket.spec.mjs +25 -0
  33. package/dist/testing/websocket.spec.mjs.map +1 -1
  34. package/dist/testing/workflow.spec.mjs +1 -1
  35. package/dist/testing/workflow.spec.mjs.map +1 -1
  36. package/package.json +17 -16
  37. package/src/helpers/entity-state.mts +20 -3
  38. package/src/helpers/fetch/service-list.mts +89 -5
  39. package/src/helpers/id-by.mts +1 -0
  40. package/src/helpers/utility.mts +1 -1
  41. package/src/mock_assistant/mock-assistant.module.mts +0 -1
  42. package/src/mock_assistant/services/websocket-api.service.mts +46 -4
  43. package/src/services/feature.service.mts +9 -6
  44. package/src/services/id-by.service.mts +4 -1
  45. package/src/services/reference.service.mts +78 -9
  46. package/src/testing/area.spec.mts +166 -2
  47. package/src/testing/call-proxy.spec.mts +241 -0
  48. package/src/testing/conversation.spec.mts +1 -1
  49. package/src/testing/entity.spec.mts +15 -0
  50. package/src/testing/id-by.spec.mts +50 -0
  51. package/src/testing/ref-by.spec.mts +965 -4
  52. package/src/testing/scheduler.spec.mts +444 -0
  53. package/src/testing/websocket.spec.mts +33 -0
  54. package/src/testing/workflow.spec.mts +1 -1
@@ -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`);
@@ -131,9 +136,12 @@ export function ReferenceService({
131
136
  "attributes",
132
137
  "entity_id",
133
138
  "history",
134
- "last",
139
+ "last_changed",
140
+ "last_reported",
141
+ "last_updated",
135
142
  "nextState",
136
143
  "once",
144
+ "onStateFor",
137
145
  "onUpdate",
138
146
  "previous",
139
147
  "removeAllListeners",
@@ -170,8 +178,62 @@ export function ReferenceService({
170
178
  // things that shouldn't be needed: this extract
171
179
  // eslint-disable-next-line sonarjs/function-return-type
172
180
  get: (_, property: Extract<keyof ByIdProxy<ENTITY_ID>, string>) => {
181
+ // Handle Symbol properties (e.g., when vitest formats test output)
182
+ if (!is.string(property)) {
183
+ return undefined;
184
+ }
173
185
  hass.diagnostics.reference?.get_property.publish({ entity_id, property });
174
186
  switch (property) {
187
+ // #MARK: runAfter
188
+ case "onStateFor": {
189
+ return function ({
190
+ context,
191
+ ...options
192
+ }: OnStateForOptions<ENTITY_ID>): RemoveCallback {
193
+ let timerRemove: RemoveCallback;
194
+ const remove = proxy.onUpdate((new_state, old_state) => {
195
+ const matches = options.matches
196
+ ? options.matches(new_state, old_state)
197
+ : options.state === new_state.state;
198
+ if (!matches) {
199
+ if (timerRemove) {
200
+ timerRemove();
201
+ timerRemove = undefined;
202
+ logger.trace({ context, entity_id }, "cleared timer - state no longer matches");
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (timerRemove) {
208
+ logger.trace({ context, entity_id }, "timer already running, skipping");
209
+ return;
210
+ }
211
+ timerRemove = scheduler.setTimeout(async () => {
212
+ logger.trace(
213
+ { context, entity_id, for: options.for },
214
+ "timer fired - executing callback",
215
+ );
216
+ internal.safeExec({
217
+ context,
218
+ exec: async () => await options.exec(proxy),
219
+ });
220
+ }, options.for);
221
+ logger.trace(
222
+ { context, entity_id, for: options.for },
223
+ "started timer for state condition",
224
+ );
225
+ });
226
+
227
+ return internal.removeFn(() => {
228
+ if (timerRemove) {
229
+ timerRemove();
230
+ }
231
+ remove();
232
+ logger.trace({ context, entity_id }, "removed [onStateFor] listener");
233
+ });
234
+ };
235
+ }
236
+
175
237
  // #MARK: onUpdate
176
238
  case "onUpdate": {
177
239
  return (callback: TAnyFunction) => {
@@ -246,7 +308,7 @@ export function ReferenceService({
246
308
 
247
309
  // #MARK: nextState
248
310
  case "nextState": {
249
- return async (timeout?: number) =>
311
+ return async (timeout?: TOffset) =>
250
312
  await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
251
313
  // - set up cleanup function
252
314
  const remove = () => {
@@ -274,9 +336,13 @@ export function ReferenceService({
274
336
 
275
337
  // - race!
276
338
  let wait: ReturnType<typeof sleep>;
277
- if (is.number(timeout) && timeout > NONE) {
339
+ if (is.undefined(timeout)) {
340
+ return;
341
+ }
342
+ const duration = internal.utils.getIntervalMs(timeout);
343
+ if (duration > NONE) {
278
344
  // keep track of sleep so it can be cleaned up also
279
- wait = sleep(timeout);
345
+ wait = sleep(duration);
280
346
  await wait;
281
347
  wait = undefined;
282
348
  if (done) {
@@ -290,10 +356,9 @@ export function ReferenceService({
290
356
 
291
357
  // #MARK: waitForState
292
358
  case "waitForState": {
293
- return async (state: string | number, timeout?: number) =>
359
+ return async (state: string | number, timeout?: TOffset) =>
294
360
  await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
295
361
  const remove = () => {
296
- done = undefined;
297
362
  listeners.delete(remove);
298
363
  done = undefined;
299
364
  logger.trace({ entity_id }, "remove [waitForState] listener");
@@ -326,8 +391,12 @@ export function ReferenceService({
326
391
 
327
392
  event.on(entity_id, complete);
328
393
  let wait: ReturnType<typeof sleep>;
329
- if (is.number(timeout) && timeout > NONE) {
330
- wait = sleep(timeout);
394
+ if (is.undefined(timeout)) {
395
+ return;
396
+ }
397
+ const duration = internal.utils.getIntervalMs(timeout);
398
+ if (duration > NONE) {
399
+ wait = sleep(duration);
331
400
  await wait;
332
401
  wait = undefined;
333
402
  if (done) {
@@ -2,9 +2,9 @@ import { sleep } from "@digital-alchemy/core";
2
2
  import { subscribe } from "diagnostics_channel";
3
3
 
4
4
  import type { AreaDetails } from "../helpers/index.mts";
5
- import { AREA_REGISTRY_UPDATED } from "../helpers/index.mts";
5
+ import { AREA_REGISTRY_UPDATED, ENTITY_REGISTRY_UPDATED } from "../helpers/index.mts";
6
6
  import { hassTestRunner, INTERNAL_MESSAGE } from "../mock_assistant/index.mts";
7
- import type { TAreaId } from "../user.mts";
7
+ import type { ANY_ENTITY, TAreaId } from "../user.mts";
8
8
 
9
9
  const EXAMPLE_AREA = {
10
10
  area_id: "empty_area" as TAreaId,
@@ -200,4 +200,168 @@ describe("API", () => {
200
200
  });
201
201
  });
202
202
  });
203
+
204
+ describe("apply", () => {
205
+ it("should apply area to a single entity", async () => {
206
+ expect.assertions(2);
207
+ await hassTestRunner.run(({ lifecycle, hass, event }) => {
208
+ const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
209
+ lifecycle.onReady(async () => {
210
+ const entity = "sensor.magic" as ANY_ENTITY;
211
+ const area = "living_room" as TAreaId;
212
+ const response = hass.area.apply(area, [entity]);
213
+ setImmediate(() => event.emit(ENTITY_REGISTRY_UPDATED));
214
+ const result = await response;
215
+
216
+ expect(spy).toHaveBeenCalledWith({
217
+ area_id: area,
218
+ entity_id: entity,
219
+ type: "config/entity_registry/update",
220
+ });
221
+ expect(result.updated).toEqual([entity]);
222
+ });
223
+ });
224
+ });
225
+
226
+ it("should apply area to multiple entities", async () => {
227
+ expect.assertions(3);
228
+ await hassTestRunner.run(({ lifecycle, hass, event }) => {
229
+ lifecycle.onReady(async () => {
230
+ const updateCalls: Array<{ area_id: TAreaId; entity_id: ANY_ENTITY; type: string }> = [];
231
+ vi.spyOn(hass.socket, "sendMessage").mockImplementation(async message => {
232
+ if (message?.type === "config/entity_registry/update") {
233
+ updateCalls.push(
234
+ message as { area_id: TAreaId; entity_id: ANY_ENTITY; type: string },
235
+ );
236
+ // Emit event asynchronously to ensure listener is registered
237
+ setImmediate(() => event.emit(ENTITY_REGISTRY_UPDATED));
238
+ }
239
+ return undefined;
240
+ });
241
+
242
+ const entities = ["sensor.magic", "light.kitchen_lamp"] as ANY_ENTITY[];
243
+ const area = "living_room" as TAreaId;
244
+ const result = await hass.area.apply(area, entities);
245
+
246
+ expect(updateCalls).toHaveLength(2);
247
+ expect(updateCalls).toEqual([
248
+ {
249
+ area_id: area,
250
+ entity_id: entities[0],
251
+ type: "config/entity_registry/update",
252
+ },
253
+ {
254
+ area_id: area,
255
+ entity_id: entities[1],
256
+ type: "config/entity_registry/update",
257
+ },
258
+ ]);
259
+ expect(result.updated).toEqual(entities);
260
+ });
261
+ });
262
+ });
263
+
264
+ it("should skip entities that already have the correct area", async () => {
265
+ expect.assertions(2);
266
+ await hassTestRunner.run(({ lifecycle, hass }) => {
267
+ lifecycle.onReady(async () => {
268
+ const spy = vi
269
+ .spyOn(hass.socket, "sendMessage")
270
+ .mockImplementation(async () => undefined);
271
+ // Find an entity that already has an area assigned
272
+ const entityWithArea = hass.entity.registry.current.find(item => item.area_id !== null);
273
+ if (!entityWithArea) {
274
+ throw new Error("No entity with area found in fixtures");
275
+ }
276
+
277
+ const entity = entityWithArea.entity_id as ANY_ENTITY;
278
+ const area = entityWithArea.area_id as TAreaId;
279
+ const result = await hass.area.apply(area, [entity]);
280
+
281
+ const updateCalls = spy.mock.calls.filter(
282
+ call => call[0]?.type === "config/entity_registry/update",
283
+ );
284
+ expect(updateCalls).toHaveLength(0);
285
+ expect(result.updated).toEqual([]);
286
+ });
287
+ });
288
+ });
289
+
290
+ it("should throw error for unknown entity", async () => {
291
+ expect.assertions(1);
292
+ await hassTestRunner.run(({ lifecycle, hass }) => {
293
+ lifecycle.onReady(async () => {
294
+ const unknownEntity = "sensor.unknown_entity" as ANY_ENTITY;
295
+ const area = "living_room" as TAreaId;
296
+
297
+ try {
298
+ await hass.area.apply(area, [unknownEntity]);
299
+ } catch (error) {
300
+ // InternalError structure: check various possible properties
301
+ const err = error as Record<string, unknown>;
302
+ const errorString = String(error);
303
+ const hasCode = err.code === "UNKNOWN_ENTITY";
304
+ const hasName = err.name === "UNKNOWN_ENTITY";
305
+ const hasMessage = String(err.message || "").includes("UNKNOWN_ENTITY");
306
+ const hasString = errorString.includes("UNKNOWN_ENTITY");
307
+ expect(hasCode || hasName || hasMessage || hasString).toBe(true);
308
+ }
309
+ });
310
+ });
311
+ });
312
+
313
+ it("should wait for ENTITY_REGISTRY_UPDATED before continuing", async () => {
314
+ expect.assertions(1);
315
+ await hassTestRunner.run(({ lifecycle, hass, event }) => {
316
+ vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
317
+ lifecycle.onReady(async () => {
318
+ const entity = "sensor.magic" as ANY_ENTITY;
319
+ const area = "living_room" as TAreaId;
320
+ const response = hass.area.apply(area, [entity]);
321
+ let order = "";
322
+ setTimeout(() => {
323
+ order += "a";
324
+ event.emit(ENTITY_REGISTRY_UPDATED);
325
+ }, 5);
326
+ await response;
327
+ order += "b";
328
+ expect(order).toEqual("ab");
329
+ });
330
+ });
331
+ });
332
+
333
+ it("should return only updated entities when some are skipped", async () => {
334
+ expect.assertions(2);
335
+ await hassTestRunner.run(({ lifecycle, hass, event }) => {
336
+ lifecycle.onReady(async () => {
337
+ const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async message => {
338
+ if (message?.type === "config/entity_registry/update") {
339
+ setImmediate(() => event.emit(ENTITY_REGISTRY_UPDATED));
340
+ }
341
+ return undefined;
342
+ });
343
+
344
+ // Find an entity that already has "kitchen" area so it will be skipped
345
+ const entityWithArea = hass.entity.registry.current.find(
346
+ item => item.area_id === "kitchen",
347
+ );
348
+ if (!entityWithArea) {
349
+ throw new Error("No entity with kitchen area found in fixtures");
350
+ }
351
+
352
+ const existingEntity = entityWithArea.entity_id as ANY_ENTITY;
353
+ const newEntity = "sensor.magic" as ANY_ENTITY;
354
+ const newArea = "kitchen" as TAreaId;
355
+
356
+ const result = await hass.area.apply(newArea, [existingEntity, newEntity]);
357
+
358
+ const updateCalls = spy.mock.calls.filter(
359
+ call => call[0]?.type === "config/entity_registry/update",
360
+ );
361
+ expect(updateCalls).toHaveLength(1);
362
+ expect(result.updated).toEqual([newEntity]);
363
+ });
364
+ });
365
+ });
366
+ });
203
367
  });
@@ -0,0 +1,241 @@
1
+ import type { HassServiceDTO } from "../helpers/index.mts";
2
+ import { hassTestRunner } from "../mock_assistant/index.mts";
3
+
4
+ afterEach(async () => {
5
+ await hassTestRunner.teardown();
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ describe("CallProxy", () => {
10
+ describe("pauseMessages", () => {
11
+ it("should return undefined when pauseMessages is true", async () => {
12
+ expect.assertions(2);
13
+ await hassTestRunner.run(({ lifecycle, hass }) => {
14
+ const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
15
+ hass.socket.pauseMessages = true;
16
+
17
+ lifecycle.onReady(async () => {
18
+ const result = await hass.call.switch.turn_on({
19
+ entity_id: "switch.porch_light",
20
+ });
21
+
22
+ expect(result).toBeUndefined();
23
+ const callServiceCalls = spy.mock.calls.filter(
24
+ call =>
25
+ call[0]?.type === "call_service" &&
26
+ call[0]?.domain === "switch" &&
27
+ call[0]?.service === "turn_on",
28
+ );
29
+ expect(callServiceCalls).toHaveLength(0);
30
+ });
31
+ });
32
+ });
33
+
34
+ it("should call sendMessage when pauseMessages is false", async () => {
35
+ expect.assertions(1);
36
+ await hassTestRunner.run(({ lifecycle, hass }) => {
37
+ const spy = vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => undefined);
38
+ hass.socket.pauseMessages = false;
39
+
40
+ lifecycle.onReady(async () => {
41
+ await hass.call.switch.turn_on({
42
+ entity_id: "switch.porch_light",
43
+ });
44
+
45
+ expect(spy).toHaveBeenCalledWith(
46
+ expect.objectContaining({
47
+ domain: "switch",
48
+ service: "turn_on",
49
+ service_data: {
50
+ entity_id: "switch.porch_light",
51
+ },
52
+ type: "call_service",
53
+ }),
54
+ true,
55
+ );
56
+ });
57
+ });
58
+ });
59
+ });
60
+
61
+ describe("return_response", () => {
62
+ it("should include return_response in payload when service has response.optional", async () => {
63
+ expect.assertions(1);
64
+ const mockServices: HassServiceDTO[] = [
65
+ {
66
+ domain: "switch",
67
+ services: {
68
+ turn_on: {
69
+ description: "Test service",
70
+ fields: {},
71
+ name: "turn_on",
72
+ response: { optional: true },
73
+ },
74
+ },
75
+ },
76
+ ];
77
+
78
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
79
+ mock_assistant.services.loadFixtures(mockServices);
80
+ vi.spyOn(hass.fetch, "listServices").mockImplementation(async () => mockServices);
81
+
82
+ const spy = vi
83
+ .spyOn(hass.socket, "sendMessage")
84
+ .mockImplementation(async () => ({ response: { success: true } }));
85
+
86
+ lifecycle.onReady(async () => {
87
+ await hass.call.switch.turn_on({ entity_id: "switch.bedroom_lamp" });
88
+
89
+ expect(spy).toHaveBeenCalledWith(
90
+ expect.objectContaining({
91
+ domain: "switch",
92
+ return_response: true,
93
+ service: "turn_on",
94
+ type: "call_service",
95
+ }),
96
+ true,
97
+ );
98
+ });
99
+ });
100
+ });
101
+
102
+ it("should return response when service returns a response", async () => {
103
+ expect.assertions(1);
104
+ const mockServices: HassServiceDTO[] = [
105
+ {
106
+ domain: "switch",
107
+ services: {
108
+ turn_on: {
109
+ description: "Test service",
110
+ fields: {},
111
+ name: "turn_on",
112
+ response: { optional: true },
113
+ },
114
+ },
115
+ },
116
+ ];
117
+
118
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
119
+ mock_assistant.services.loadFixtures(mockServices);
120
+ vi.spyOn(hass.fetch, "listServices").mockImplementation(async () => mockServices);
121
+
122
+ const mockResponse = { data: "test_response" };
123
+ vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => ({
124
+ response: mockResponse,
125
+ }));
126
+
127
+ lifecycle.onReady(async () => {
128
+ const result = await hass.call.switch.turn_on({ entity_id: "switch.bedroom_lamp" });
129
+ expect(result).toEqual(mockResponse);
130
+ });
131
+ });
132
+ });
133
+
134
+ it("should return undefined when service does not return a response", async () => {
135
+ expect.assertions(1);
136
+ const mockServices: HassServiceDTO[] = [
137
+ {
138
+ domain: "switch",
139
+ services: {
140
+ turn_on: {
141
+ description: "Test service",
142
+ fields: {},
143
+ name: "turn_on",
144
+ response: { optional: true },
145
+ },
146
+ },
147
+ },
148
+ ];
149
+
150
+ await hassTestRunner.run(({ lifecycle, hass, mock_assistant }) => {
151
+ mock_assistant.services.loadFixtures(mockServices);
152
+ vi.spyOn(hass.fetch, "listServices").mockImplementation(async () => mockServices);
153
+
154
+ vi.spyOn(hass.socket, "sendMessage").mockImplementation(async () => ({}));
155
+
156
+ lifecycle.onReady(async () => {
157
+ const result = await hass.call.switch.turn_on({ entity_id: "switch.bedroom_lamp" });
158
+ expect(result).toBeUndefined();
159
+ });
160
+ });
161
+ });
162
+ });
163
+
164
+ describe("early access", () => {
165
+ it("should call console.trace when accessed before load with LOG_LEVEL trace", async () => {
166
+ expect.assertions(1);
167
+ const traceSpy = vi.spyOn(console, "trace").mockImplementation(() => {});
168
+
169
+ await hassTestRunner.configure({ boilerplate: { LOG_LEVEL: "trace" } }).run(({ hass }) => {
170
+ hass.call.switch;
171
+ });
172
+ expect(traceSpy).toHaveBeenCalledWith(`hass.call`);
173
+ });
174
+
175
+ it("should not log error when accessed before load with LOG_LEVEL not trace", async () => {
176
+ expect.assertions(2);
177
+ await hassTestRunner
178
+ .configure({ boilerplate: { LOG_LEVEL: "warn" } })
179
+ .run(({ lifecycle, hass, internal }) => {
180
+ const logger = internal.boilerplate.logger.getBaseLogger();
181
+ const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
182
+ const traceSpy = vi.spyOn(console, "trace").mockImplementation(() => {});
183
+
184
+ // Access hass.call in onPreInit, which runs before onBootstrap (where services load)
185
+ hass.call.switch;
186
+
187
+ lifecycle.onReady(() => {
188
+ expect(errorSpy).not.toHaveBeenCalled();
189
+ expect(traceSpy).not.toHaveBeenCalled();
190
+ });
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("proxy methods", () => {
196
+ it("should return true for has when domain exists", async () => {
197
+ expect.assertions(1);
198
+ await hassTestRunner.run(({ lifecycle, hass }) => {
199
+ lifecycle.onReady(() => {
200
+ expect("switch" in hass.call).toBe(true);
201
+ });
202
+ });
203
+ });
204
+
205
+ it("should return false for has when domain does not exist", async () => {
206
+ expect.assertions(1);
207
+ await hassTestRunner.run(({ lifecycle, hass }) => {
208
+ lifecycle.onReady(() => {
209
+ expect("nonexistent_domain" in hass.call).toBe(false);
210
+ });
211
+ });
212
+ });
213
+
214
+ it("should return ownKeys with all domain keys", async () => {
215
+ expect.assertions(2);
216
+ await hassTestRunner.run(({ lifecycle, hass }) => {
217
+ lifecycle.onReady(() => {
218
+ const keys = Object.keys(hass.call);
219
+ expect(keys).toContain("switch");
220
+ expect(keys.length).toBeGreaterThan(0);
221
+ });
222
+ });
223
+ });
224
+
225
+ it("should return false when trying to set a property", async () => {
226
+ expect.assertions(1);
227
+ await hassTestRunner.run(({ lifecycle, hass }) => {
228
+ lifecycle.onReady(() => {
229
+ try {
230
+ // @ts-expect-error testing
231
+ hass.call.test_domain = {};
232
+ } catch {
233
+ // Some environments throw, others return false
234
+ }
235
+ // Verify the property was not set
236
+ expect("test_domain" in hass.call).toBe(false);
237
+ });
238
+ });
239
+ });
240
+ });
241
+ });
@@ -18,7 +18,7 @@ describe("Conversation Service", () => {
18
18
  },
19
19
  ];
20
20
 
21
- await hassTestRunner.run(({ lifecycle, hass }) => {
21
+ await hassTestRunner.bootLibrariesFirst().run(({ lifecycle, hass }) => {
22
22
  const spy = vi
23
23
  .spyOn(hass.socket, "sendMessage")
24
24
  .mockImplementation(async () => ({ agents: mockAgents }));
@@ -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: [],
@@ -137,6 +137,56 @@ describe("enabled entities", () => {
137
137
  });
138
138
  });
139
139
  });
140
+
141
+ describe("unique_id", () => {
142
+ it("find entity by unique_id without platform", async () => {
143
+ expect.assertions(1);
144
+ await hassTestRunner.run(({ lifecycle, hass }) => {
145
+ lifecycle.onReady(() => {
146
+ const entity = hass.idBy.unique_id(
147
+ "e1806fdc93296bbd5ab42967003cd38729ff9ba6cfeefc3e15a03ad01ac894fe",
148
+ );
149
+ expect(entity).toBe("sensor.magic");
150
+ });
151
+ });
152
+ });
153
+
154
+ it("find entity by unique_id with matching platform", async () => {
155
+ expect.assertions(1);
156
+ await hassTestRunner.run(({ lifecycle, hass }) => {
157
+ lifecycle.onReady(() => {
158
+ const entity = hass.idBy.unique_id(
159
+ "e1806fdc93296bbd5ab42967003cd38729ff9ba6cfeefc3e15a03ad01ac894fe",
160
+ "synapse",
161
+ );
162
+ expect(entity).toBe("sensor.magic");
163
+ });
164
+ });
165
+ });
166
+
167
+ it("find entity by unique_id with non-matching platform", async () => {
168
+ expect.assertions(1);
169
+ await hassTestRunner.run(({ lifecycle, hass }) => {
170
+ lifecycle.onReady(() => {
171
+ const entity = hass.idBy.unique_id(
172
+ "e1806fdc93296bbd5ab42967003cd38729ff9ba6cfeefc3e15a03ad01ac894fe",
173
+ "sun",
174
+ );
175
+ expect(entity).toBeUndefined();
176
+ });
177
+ });
178
+ });
179
+
180
+ it("find entity by unique_id with different platform", async () => {
181
+ expect.assertions(1);
182
+ await hassTestRunner.run(({ lifecycle, hass }) => {
183
+ lifecycle.onReady(() => {
184
+ const entity = hass.idBy.unique_id("5622d76001a335e3ea893c4d60d31b3d-next_dawn", "sun");
185
+ expect(entity).toBe("sensor.sun_next_dawn");
186
+ });
187
+ });
188
+ });
189
+ });
140
190
  });
141
191
 
142
192
  describe("disabled entities", () => {