ha-ai-mcp-server 0.3.8

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 (142) hide show
  1. package/dist/adapters/addon/addon.adapter.d.ts +22 -0
  2. package/dist/adapters/addon/addon.adapter.d.ts.map +1 -0
  3. package/dist/adapters/addon/addon.adapter.js +92 -0
  4. package/dist/adapters/addon/addon.adapter.js.map +1 -0
  5. package/dist/adapters/browser/playwright.adapter.d.ts +27 -0
  6. package/dist/adapters/browser/playwright.adapter.d.ts.map +1 -0
  7. package/dist/adapters/browser/playwright.adapter.js +156 -0
  8. package/dist/adapters/browser/playwright.adapter.js.map +1 -0
  9. package/dist/adapters/ha/ha-rest.adapter.d.ts +19 -0
  10. package/dist/adapters/ha/ha-rest.adapter.d.ts.map +1 -0
  11. package/dist/adapters/ha/ha-rest.adapter.js +70 -0
  12. package/dist/adapters/ha/ha-rest.adapter.js.map +1 -0
  13. package/dist/adapters/ha/ha-ws.adapter.d.ts +29 -0
  14. package/dist/adapters/ha/ha-ws.adapter.d.ts.map +1 -0
  15. package/dist/adapters/ha/ha-ws.adapter.js +178 -0
  16. package/dist/adapters/ha/ha-ws.adapter.js.map +1 -0
  17. package/dist/browser/renderer.d.ts +3 -0
  18. package/dist/browser/renderer.d.ts.map +1 -0
  19. package/dist/browser/renderer.js +3 -0
  20. package/dist/browser/renderer.js.map +1 -0
  21. package/dist/clients/addon-client.d.ts +3 -0
  22. package/dist/clients/addon-client.d.ts.map +1 -0
  23. package/dist/clients/addon-client.js +3 -0
  24. package/dist/clients/addon-client.js.map +1 -0
  25. package/dist/clients/ha-rest.d.ts +3 -0
  26. package/dist/clients/ha-rest.d.ts.map +1 -0
  27. package/dist/clients/ha-rest.js +3 -0
  28. package/dist/clients/ha-rest.js.map +1 -0
  29. package/dist/clients/ha-ws.d.ts +3 -0
  30. package/dist/clients/ha-ws.d.ts.map +1 -0
  31. package/dist/clients/ha-ws.js +3 -0
  32. package/dist/clients/ha-ws.js.map +1 -0
  33. package/dist/config.d.ts +2 -0
  34. package/dist/config.d.ts.map +1 -0
  35. package/dist/config.js +2 -0
  36. package/dist/config.js.map +1 -0
  37. package/dist/core/config.d.ts +30 -0
  38. package/dist/core/config.d.ts.map +1 -0
  39. package/dist/core/config.js +27 -0
  40. package/dist/core/config.js.map +1 -0
  41. package/dist/core/errors.d.ts +18 -0
  42. package/dist/core/errors.d.ts.map +1 -0
  43. package/dist/core/errors.js +29 -0
  44. package/dist/core/errors.js.map +1 -0
  45. package/dist/core/logger.d.ts +13 -0
  46. package/dist/core/logger.d.ts.map +1 -0
  47. package/dist/core/logger.js +25 -0
  48. package/dist/core/logger.js.map +1 -0
  49. package/dist/domain/models/automation.d.ts +19 -0
  50. package/dist/domain/models/automation.d.ts.map +1 -0
  51. package/dist/domain/models/automation.js +2 -0
  52. package/dist/domain/models/automation.js.map +1 -0
  53. package/dist/domain/models/dashboard.d.ts +21 -0
  54. package/dist/domain/models/dashboard.d.ts.map +1 -0
  55. package/dist/domain/models/dashboard.js +2 -0
  56. package/dist/domain/models/dashboard.js.map +1 -0
  57. package/dist/domain/models/entity.d.ts +13 -0
  58. package/dist/domain/models/entity.d.ts.map +1 -0
  59. package/dist/domain/models/entity.js +2 -0
  60. package/dist/domain/models/entity.js.map +1 -0
  61. package/dist/domain/models/system.d.ts +35 -0
  62. package/dist/domain/models/system.d.ts.map +1 -0
  63. package/dist/domain/models/system.js +2 -0
  64. package/dist/domain/models/system.js.map +1 -0
  65. package/dist/domain/ports/addon-client.port.d.ts +31 -0
  66. package/dist/domain/ports/addon-client.port.d.ts.map +1 -0
  67. package/dist/domain/ports/addon-client.port.js +2 -0
  68. package/dist/domain/ports/addon-client.port.js.map +1 -0
  69. package/dist/domain/ports/ha-client.port.d.ts +17 -0
  70. package/dist/domain/ports/ha-client.port.d.ts.map +1 -0
  71. package/dist/domain/ports/ha-client.port.js +2 -0
  72. package/dist/domain/ports/ha-client.port.js.map +1 -0
  73. package/dist/domain/ports/renderer.port.d.ts +7 -0
  74. package/dist/domain/ports/renderer.port.d.ts.map +1 -0
  75. package/dist/domain/ports/renderer.port.js +2 -0
  76. package/dist/domain/ports/renderer.port.js.map +1 -0
  77. package/dist/index.d.ts +10 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +87 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/services/automation.service.d.ts +20 -0
  82. package/dist/services/automation.service.d.ts.map +1 -0
  83. package/dist/services/automation.service.js +118 -0
  84. package/dist/services/automation.service.js.map +1 -0
  85. package/dist/services/dashboard.service.d.ts +22 -0
  86. package/dist/services/dashboard.service.d.ts.map +1 -0
  87. package/dist/services/dashboard.service.js +72 -0
  88. package/dist/services/dashboard.service.js.map +1 -0
  89. package/dist/services/system.service.d.ts +19 -0
  90. package/dist/services/system.service.d.ts.map +1 -0
  91. package/dist/services/system.service.js +83 -0
  92. package/dist/services/system.service.js.map +1 -0
  93. package/dist/tools/automation.d.ts +33 -0
  94. package/dist/tools/automation.d.ts.map +1 -0
  95. package/dist/tools/automation.js +246 -0
  96. package/dist/tools/automation.js.map +1 -0
  97. package/dist/tools/dashboard.d.ts +33 -0
  98. package/dist/tools/dashboard.d.ts.map +1 -0
  99. package/dist/tools/dashboard.js +166 -0
  100. package/dist/tools/dashboard.js.map +1 -0
  101. package/dist/tools/system.d.ts +44 -0
  102. package/dist/tools/system.d.ts.map +1 -0
  103. package/dist/tools/system.js +306 -0
  104. package/dist/tools/system.js.map +1 -0
  105. package/dist/tools/types.d.ts +26 -0
  106. package/dist/tools/types.d.ts.map +1 -0
  107. package/dist/tools/types.js +2 -0
  108. package/dist/tools/types.js.map +1 -0
  109. package/package.json +39 -0
  110. package/src/adapters/addon/addon.adapter.ts +113 -0
  111. package/src/adapters/browser/playwright.adapter.ts +186 -0
  112. package/src/adapters/ha/ha-rest.adapter.ts +82 -0
  113. package/src/adapters/ha/ha-ws.adapter.ts +212 -0
  114. package/src/browser/renderer.ts +7 -0
  115. package/src/clients/addon-client.ts +2 -0
  116. package/src/clients/ha-rest.ts +2 -0
  117. package/src/clients/ha-ws.ts +2 -0
  118. package/src/config.ts +1 -0
  119. package/src/core/config.ts +39 -0
  120. package/src/core/errors.ts +28 -0
  121. package/src/core/logger.ts +27 -0
  122. package/src/domain/models/automation.ts +19 -0
  123. package/src/domain/models/dashboard.ts +23 -0
  124. package/src/domain/models/entity.ts +13 -0
  125. package/src/domain/models/system.ts +37 -0
  126. package/src/domain/ports/addon-client.port.ts +35 -0
  127. package/src/domain/ports/ha-client.port.ts +16 -0
  128. package/src/domain/ports/renderer.port.ts +7 -0
  129. package/src/index.ts +103 -0
  130. package/src/services/automation.service.ts +142 -0
  131. package/src/services/dashboard.service.ts +83 -0
  132. package/src/services/system.service.ts +94 -0
  133. package/src/tools/automation.ts +300 -0
  134. package/src/tools/dashboard.ts +206 -0
  135. package/src/tools/system.ts +368 -0
  136. package/src/tools/types.ts +30 -0
  137. package/tests/clients.test.ts +522 -0
  138. package/tests/e2e-smoke.test.ts +620 -0
  139. package/tests/renderer.test.ts +286 -0
  140. package/tests/skills.test.ts +189 -0
  141. package/tests/tools.test.ts +530 -0
  142. package/tsconfig.json +10 -0
@@ -0,0 +1,620 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import http from "node:http";
3
+ import { WebSocketServer, WebSocket } from "ws";
4
+ import { AddressInfo } from "node:net";
5
+
6
+ import { HARestClient } from "../src/clients/ha-rest.js";
7
+ import { HAWsClient } from "../src/clients/ha-ws.js";
8
+ import { AddonClient } from "../src/clients/addon-client.js";
9
+ import { DashboardRenderer } from "../src/browser/renderer.js";
10
+ import { createServer } from "../src/index.js";
11
+ import type { ToolClients } from "../src/tools/types.js";
12
+ import {
13
+ handleSystemHealth,
14
+ handleSystemListEntities,
15
+ handleSystemGetLogs,
16
+ handleSystemRestoreBackup,
17
+ } from "../src/tools/system.js";
18
+ import {
19
+ handleDashboardSaveConfig,
20
+ handleDashboardRenderScreenshot,
21
+ handleDashboardGetConfig,
22
+ } from "../src/tools/dashboard.js";
23
+
24
+ describe("End-to-End Integration Smoke Test Suite", () => {
25
+ let haHttpServer: http.Server;
26
+ let haWss: WebSocketServer;
27
+ let addonHttpServer: http.Server;
28
+
29
+ let haUrl: string;
30
+ let addonUrl: string;
31
+
32
+ const HA_TEST_TOKEN = "e2e-test-ha-token-secret-12345";
33
+ const ADDON_TEST_KEY = "e2e-test-addon-api-key-999";
34
+
35
+ // In-memory mock addon filesystem & snapshots
36
+ const mockFileSystem = new Map<string, string>();
37
+ const mockSnapshots: Array<{
38
+ snapshot_id: string;
39
+ timestamp: string;
40
+ original_file: string;
41
+ backup_file: string;
42
+ label: string;
43
+ file_size_bytes: number;
44
+ savedContent: string;
45
+ }> = [];
46
+
47
+ let toolClients: ToolClients;
48
+ let mcpServerInstance: any;
49
+
50
+ beforeAll(async () => {
51
+ // 1. Initial files in addon storage
52
+ mockFileSystem.set(
53
+ "ui-lovelace.yaml",
54
+ "title: Original Home Dashboard\nviews:\n - title: Main View\n cards:\n - type: entities\n entities:\n - light.living_room\n"
55
+ );
56
+ mockFileSystem.set(
57
+ "automations.yaml",
58
+ "- id: '1700000000001'\n alias: Evening Ambient Lighting\n trigger:\n - platform: sun\n event: sunset\n action:\n - service: light.turn_on\n target:\n entity_id: light.living_room\n"
59
+ );
60
+ mockFileSystem.set("configuration.yaml", "default_config:\nsun:\nfrontend:\n");
61
+
62
+ // 2. Setup Mock Home Assistant HTTP + WebSocket Server
63
+ haHttpServer = http.createServer((req, res) => {
64
+ // Check auth header for REST endpoints
65
+ if (req.url?.startsWith("/api/")) {
66
+ const authHeader = req.headers.authorization;
67
+ if (authHeader !== `Bearer ${HA_TEST_TOKEN}`) {
68
+ res.writeHead(401, { "Content-Type": "application/json" });
69
+ res.end(JSON.stringify({ message: "Unauthorized: Invalid or missing bearer token" }));
70
+ return;
71
+ }
72
+ }
73
+
74
+ // REST /api/
75
+ if (req.url === "/api/" && req.method === "GET") {
76
+ res.writeHead(200, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({ message: "API running.", version: "2026.8.0" }));
78
+ return;
79
+ }
80
+
81
+ // REST /api/states
82
+ if (req.url === "/api/states" && req.method === "GET") {
83
+ res.writeHead(200, { "Content-Type": "application/json" });
84
+ res.end(
85
+ JSON.stringify([
86
+ {
87
+ entity_id: "light.living_room",
88
+ state: "on",
89
+ attributes: {
90
+ friendly_name: "Living Room Main Light",
91
+ brightness: 255,
92
+ supported_color_modes: ["brightness", "color_temp"],
93
+ },
94
+ last_changed: "2026-08-31T08:00:00.000Z",
95
+ last_updated: "2026-08-31T08:00:00.000Z",
96
+ },
97
+ {
98
+ entity_id: "sensor.living_room_temperature",
99
+ state: "22.4",
100
+ attributes: {
101
+ friendly_name: "Living Room Temperature",
102
+ unit_of_measurement: "°C",
103
+ device_class: "temperature",
104
+ },
105
+ last_changed: "2026-08-31T08:00:00.000Z",
106
+ last_updated: "2026-08-31T08:00:00.000Z",
107
+ },
108
+ {
109
+ entity_id: "automation.evening_ambient_lighting",
110
+ state: "on",
111
+ attributes: {
112
+ friendly_name: "Evening Ambient Lighting",
113
+ last_triggered: "2026-08-30T19:30:00.000Z",
114
+ },
115
+ last_changed: "2026-08-30T19:30:00.000Z",
116
+ last_updated: "2026-08-30T19:30:00.000Z",
117
+ },
118
+ ])
119
+ );
120
+ return;
121
+ }
122
+
123
+ // REST /api/services/automation/reload
124
+ if (req.url === "/api/services/automation/reload" && req.method === "POST") {
125
+ res.writeHead(200, { "Content-Type": "application/json" });
126
+ res.end(JSON.stringify([{ success: true }]));
127
+ return;
128
+ }
129
+
130
+ // Lovelace UI dashboard page for Playwright rendering
131
+ if (req.url === "/lovelace/0" || req.url === "/lovelace" || req.url?.startsWith("/lovelace")) {
132
+ res.writeHead(200, { "Content-Type": "text/html" });
133
+ res.end(`
134
+ <!DOCTYPE html>
135
+ <html lang="en">
136
+ <head>
137
+ <meta charset="utf-8" />
138
+ <title>Home Assistant Dashboard</title>
139
+ <style>
140
+ body {
141
+ margin: 0;
142
+ padding: 24px;
143
+ background: #0d1117;
144
+ color: #c9d1d9;
145
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
146
+ }
147
+ home-assistant-main {
148
+ display: block;
149
+ }
150
+ hui-view {
151
+ display: grid;
152
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
153
+ gap: 16px;
154
+ }
155
+ ha-card {
156
+ display: block;
157
+ background: #161b22;
158
+ border: 1px solid #30363d;
159
+ border-radius: 12px;
160
+ padding: 20px;
161
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
162
+ }
163
+ .title {
164
+ font-size: 1.2rem;
165
+ font-weight: 600;
166
+ color: #58a6ff;
167
+ margin-bottom: 8px;
168
+ }
169
+ .state {
170
+ font-size: 1.5rem;
171
+ font-weight: bold;
172
+ color: #3fb950;
173
+ }
174
+ </style>
175
+ </head>
176
+ <body>
177
+ <home-assistant-main>
178
+ <header>
179
+ <h1 id="dash-title">Modern Living Dashboard</h1>
180
+ </header>
181
+ <hui-view>
182
+ <ha-card id="card-light">
183
+ <div class="title">Living Room Light</div>
184
+ <div class="state">ON (100%)</div>
185
+ </ha-card>
186
+ <ha-card id="card-sensor">
187
+ <div class="title">Living Room Temperature</div>
188
+ <div class="state">22.4 °C</div>
189
+ </ha-card>
190
+ </hui-view>
191
+ </home-assistant-main>
192
+ </body>
193
+ </html>
194
+ `);
195
+ return;
196
+ }
197
+
198
+ res.writeHead(404, { "Content-Type": "text/plain" });
199
+ res.end("Not Found");
200
+ });
201
+
202
+ await new Promise<void>((resolve) => {
203
+ haHttpServer.listen(0, "127.0.0.1", () => resolve());
204
+ });
205
+ const haAddr = haHttpServer.address() as AddressInfo;
206
+ haUrl = `http://127.0.0.1:${haAddr.port}`;
207
+
208
+ // WebSocket Server attached to HA HTTP server
209
+ haWss = new WebSocketServer({ server: haHttpServer, path: "/api/websocket" });
210
+ haWss.on("connection", (ws: WebSocket) => {
211
+ ws.send(JSON.stringify({ type: "auth_required", ha_version: "2026.8.0" }));
212
+
213
+ ws.on("message", (raw: string) => {
214
+ try {
215
+ const msg = JSON.parse(raw.toString());
216
+ if (msg.type === "auth") {
217
+ if (msg.access_token === HA_TEST_TOKEN) {
218
+ ws.send(JSON.stringify({ type: "auth_ok", ha_version: "2026.8.0" }));
219
+ } else {
220
+ ws.send(JSON.stringify({ type: "auth_invalid", message: "Invalid token" }));
221
+ ws.close();
222
+ }
223
+ return;
224
+ }
225
+
226
+ if (msg.type === "lovelace/config") {
227
+ ws.send(
228
+ JSON.stringify({
229
+ id: msg.id,
230
+ type: "result",
231
+ success: true,
232
+ result: {
233
+ title: "Modern Living Dashboard",
234
+ views: [
235
+ {
236
+ title: "Main View",
237
+ cards: [
238
+ { type: "light", entity: "light.living_room" },
239
+ { type: "sensor", entity: "sensor.living_room_temperature" },
240
+ ],
241
+ },
242
+ ],
243
+ },
244
+ })
245
+ );
246
+ return;
247
+ }
248
+
249
+ if (msg.type === "lovelace/config/save") {
250
+ ws.send(
251
+ JSON.stringify({
252
+ id: msg.id,
253
+ type: "result",
254
+ success: true,
255
+ result: null,
256
+ })
257
+ );
258
+ return;
259
+ }
260
+
261
+ if (msg.type === "get_states") {
262
+ ws.send(
263
+ JSON.stringify({
264
+ id: msg.id,
265
+ type: "result",
266
+ success: true,
267
+ result: [
268
+ { entity_id: "light.living_room", state: "on" },
269
+ { entity_id: "sensor.living_room_temperature", state: "22.4" },
270
+ ],
271
+ })
272
+ );
273
+ return;
274
+ }
275
+ } catch {
276
+ // ignore
277
+ }
278
+ });
279
+ });
280
+
281
+ // 3. Setup Mock Addon Server (FastAPI replication)
282
+ addonHttpServer = http.createServer(async (req, res) => {
283
+ const authKey = req.headers["x-addon-api-key"];
284
+ if (authKey !== ADDON_TEST_KEY) {
285
+ res.writeHead(401, { "Content-Type": "application/json" });
286
+ res.end(JSON.stringify({ detail: "Invalid or missing X-Addon-API-Key header" }));
287
+ return;
288
+ }
289
+
290
+ const chunks: Buffer[] = [];
291
+ for await (const chunk of req) {
292
+ chunks.push(chunk);
293
+ }
294
+ let body: any = {};
295
+ if (chunks.length > 0) {
296
+ try {
297
+ body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
298
+ } catch {
299
+ body = {};
300
+ }
301
+ }
302
+
303
+ // /api/v1/health
304
+ if (req.url === "/api/v1/health" && req.method === "GET") {
305
+ res.writeHead(200, { "Content-Type": "application/json" });
306
+ res.end(
307
+ JSON.stringify({
308
+ status: "ok",
309
+ version: "0.1.0",
310
+ config_root: "/config",
311
+ snapshots_count: mockSnapshots.length,
312
+ memory_mb: 28.5,
313
+ })
314
+ );
315
+ return;
316
+ }
317
+
318
+ // /api/v1/file/read
319
+ if (req.url === "/api/v1/file/read" && req.method === "POST") {
320
+ const filePath = body.path;
321
+ if (!filePath || !mockFileSystem.has(filePath)) {
322
+ res.writeHead(404, { "Content-Type": "application/json" });
323
+ res.end(JSON.stringify({ detail: `File not found: ${filePath}` }));
324
+ return;
325
+ }
326
+ const content = mockFileSystem.get(filePath)!;
327
+ res.writeHead(200, { "Content-Type": "application/json" });
328
+ res.end(
329
+ JSON.stringify({
330
+ path: filePath,
331
+ content,
332
+ size_bytes: Buffer.byteLength(content, "utf-8"),
333
+ })
334
+ );
335
+ return;
336
+ }
337
+
338
+ // /api/v1/file/write
339
+ if (req.url === "/api/v1/file/write" && req.method === "POST") {
340
+ const filePath = body.path;
341
+ const content = body.content ?? "";
342
+ const label = body.label || "auto-backup";
343
+
344
+ // Snapshot existing file if present
345
+ let snapshotId = "";
346
+ if (mockFileSystem.has(filePath)) {
347
+ const oldContent = mockFileSystem.get(filePath)!;
348
+ const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
349
+ const sanitizedName = filePath.replace(/[^a-zA-Z0-9_]/g, "_");
350
+ snapshotId = `snap_${ts}_${sanitizedName}`;
351
+
352
+ mockSnapshots.push({
353
+ snapshot_id: snapshotId,
354
+ timestamp: new Date().toISOString(),
355
+ original_file: filePath,
356
+ backup_file: `.snapshots/${filePath}.${ts}.bak`,
357
+ label,
358
+ file_size_bytes: Buffer.byteLength(oldContent, "utf-8"),
359
+ savedContent: oldContent,
360
+ });
361
+ }
362
+
363
+ mockFileSystem.set(filePath, content);
364
+
365
+ res.writeHead(200, { "Content-Type": "application/json" });
366
+ res.end(
367
+ JSON.stringify({
368
+ success: true,
369
+ path: filePath,
370
+ snapshot_id: snapshotId || "snap_initial",
371
+ bytes_written: Buffer.byteLength(content, "utf-8"),
372
+ })
373
+ );
374
+ return;
375
+ }
376
+
377
+ // /api/v1/backup/list
378
+ if (req.url === "/api/v1/backup/list" && req.method === "GET") {
379
+ res.writeHead(200, { "Content-Type": "application/json" });
380
+ res.end(
381
+ JSON.stringify(
382
+ mockSnapshots.map((s) => ({
383
+ snapshot_id: s.snapshot_id,
384
+ timestamp: s.timestamp,
385
+ original_file: s.original_file,
386
+ backup_file: s.backup_file,
387
+ label: s.label,
388
+ file_size_bytes: s.file_size_bytes,
389
+ }))
390
+ )
391
+ );
392
+ return;
393
+ }
394
+
395
+ // /api/v1/backup/restore
396
+ if (req.url === "/api/v1/backup/restore" && req.method === "POST") {
397
+ const snap = mockSnapshots.find((s) => s.snapshot_id === body.snapshot_id);
398
+ if (!snap) {
399
+ res.writeHead(404, { "Content-Type": "application/json" });
400
+ res.end(JSON.stringify({ detail: `Snapshot not found: ${body.snapshot_id}` }));
401
+ return;
402
+ }
403
+
404
+ // Restore content
405
+ mockFileSystem.set(snap.original_file, snap.savedContent);
406
+
407
+ res.writeHead(200, { "Content-Type": "application/json" });
408
+ res.end(
409
+ JSON.stringify({
410
+ success: true,
411
+ restored_file: snap.original_file,
412
+ restored_from: snap.backup_file,
413
+ safety_backup: `.snapshots/${snap.original_file}.safety.bak`,
414
+ })
415
+ );
416
+ return;
417
+ }
418
+
419
+ // /api/v1/logs/tail
420
+ if (req.url?.startsWith("/api/v1/logs/tail") && req.method === "GET") {
421
+ res.writeHead(200, { "Content-Type": "application/json" });
422
+ res.end(
423
+ JSON.stringify({
424
+ lines: [
425
+ "2026-08-31 08:00:00.120 INFO (MainThread) [homeassistant.core] Starting Home Assistant 2026.8.0",
426
+ "2026-08-31 08:00:01.450 INFO (MainThread) [homeassistant.components.http] Authenticated client with token: ***REDACTED***",
427
+ "2026-08-31 08:00:02.890 INFO (MainThread) [homeassistant.components.automation] Loaded 1 active automation",
428
+ "2026-08-31 08:00:05.100 INFO (MainThread) [homeassistant.components.lovelace] Dashboard reloaded successfully",
429
+ ],
430
+ count: 4,
431
+ })
432
+ );
433
+ return;
434
+ }
435
+
436
+ res.writeHead(404, { "Content-Type": "application/json" });
437
+ res.end(JSON.stringify({ detail: "Not found" }));
438
+ });
439
+
440
+ await new Promise<void>((resolve) => {
441
+ addonHttpServer.listen(0, "127.0.0.1", () => resolve());
442
+ });
443
+ const addonAddr = addonHttpServer.address() as AddressInfo;
444
+ addonUrl = `http://127.0.0.1:${addonAddr.port}`;
445
+
446
+ // 4. Initialize ToolClients and McpServer
447
+ toolClients = {
448
+ restClient: new HARestClient({ haUrl, haToken: HA_TEST_TOKEN }),
449
+ wsClient: new HAWsClient({ haUrl, haToken: HA_TEST_TOKEN }),
450
+ addonClient: new AddonClient({ addonUrl, addonKey: ADDON_TEST_KEY }),
451
+ renderer: new DashboardRenderer({ haUrl, haToken: HA_TEST_TOKEN }),
452
+ };
453
+
454
+ const created = createServer(toolClients);
455
+ mcpServerInstance = created.server;
456
+ });
457
+
458
+ afterAll(async () => {
459
+ if (toolClients?.renderer) {
460
+ await toolClients.renderer.close();
461
+ }
462
+ if (toolClients?.wsClient) {
463
+ await toolClients.wsClient.disconnect();
464
+ }
465
+ await new Promise<void>((resolve) => haHttpServer.close(() => resolve()));
466
+ await new Promise<void>((resolve) => addonHttpServer.close(() => resolve()));
467
+ });
468
+
469
+ it("Step 1 (Tool: ha_system_health): Verifies connectivity across HA Core API & Addon API", async () => {
470
+ const healthResult = await handleSystemHealth(toolClients);
471
+ expect(healthResult.isError).toBeFalsy();
472
+ expect(healthResult.content).toHaveLength(1);
473
+
474
+ const report = JSON.parse(healthResult.content[0].text);
475
+ expect(report.status).toBe("ok");
476
+ expect(report.homeassistant.api).toBe("ok");
477
+ expect(report.addon.status).toBe("ok");
478
+ expect(report.addon.version).toBe("0.1.0");
479
+ expect(report.addon.config_root).toBe("/config");
480
+ });
481
+
482
+ it("Step 2 (Tool: ha_system_list_entities): Fetches and filters live Home Assistant entities", async () => {
483
+ const listResult = await handleSystemListEntities(toolClients, {
484
+ domain_filter: "light",
485
+ search_query: "living",
486
+ });
487
+ expect(listResult.isError).toBeFalsy();
488
+
489
+ const entities = JSON.parse(listResult.content[0].text);
490
+ expect(Array.isArray(entities)).toBe(true);
491
+ expect(entities).toHaveLength(1);
492
+ expect(entities[0].entity_id).toBe("light.living_room");
493
+ expect(entities[0].friendly_name).toBe("Living Room Main Light");
494
+ expect(entities[0].state).toBe("on");
495
+ });
496
+
497
+ let createdSnapshotId = "";
498
+ const updatedLovelaceYaml = `title: Modern Living Dashboard Updated
499
+ views:
500
+ - title: Living Room Pro
501
+ cards:
502
+ - type: light
503
+ entity: light.living_room
504
+ - type: sensor
505
+ entity: sensor.living_room_temperature
506
+ `;
507
+
508
+ it("Step 3 (Tool: ha_dashboard_save_config): Saves new dashboard YAML and triggers automatic pre-snapshot creation", async () => {
509
+ // Check original content before saving
510
+ const originalConfig = await handleDashboardGetConfig(toolClients, { dashboard_slug: "lovelace" });
511
+ expect(originalConfig.isError).toBeFalsy();
512
+
513
+ // Perform save
514
+ const saveResult = await handleDashboardSaveConfig(toolClients, {
515
+ config_yaml: updatedLovelaceYaml,
516
+ dashboard_slug: "lovelace",
517
+ label: "e2e visual upgrade test",
518
+ });
519
+
520
+ expect(saveResult.isError).toBeFalsy();
521
+ expect(saveResult.content[0].text).toContain("Dashboard configuration successfully saved");
522
+ expect(saveResult.content[0].text).toContain("Snapshot ID: snap_");
523
+
524
+ // Extract created snapshot ID from message or addon snapshot list
525
+ const snapshots = await toolClients.addonClient.listSnapshots();
526
+ expect(snapshots.length).toBeGreaterThan(0);
527
+ const lastSnap = snapshots[snapshots.length - 1];
528
+ createdSnapshotId = lastSnap.snapshot_id;
529
+ expect(createdSnapshotId).toMatch(/^snap_\d+_/);
530
+ expect(lastSnap.original_file).toBe("ui-lovelace.yaml");
531
+ expect(lastSnap.label).toBe("e2e visual upgrade test");
532
+
533
+ // Verify file content in addon was updated
534
+ expect(mockFileSystem.get("ui-lovelace.yaml")).toBe(updatedLovelaceYaml);
535
+ });
536
+
537
+ it("Step 4 (Tool: ha_dashboard_render_screenshot): Renders dashboard in Playwright and returns base64 PNG data", async () => {
538
+ const renderResult = await handleDashboardRenderScreenshot(toolClients, {
539
+ url_path: "lovelace/0",
540
+ device_preset: "desktop",
541
+ dark_mode: true,
542
+ element_selector: "#card-light",
543
+ });
544
+
545
+ expect(renderResult.isError).toBeFalsy();
546
+ expect(renderResult.content).toHaveLength(2);
547
+
548
+ const imageItem = renderResult.content[0] as any;
549
+ expect(imageItem.type).toBe("image");
550
+ expect(imageItem.mimeType).toBe("image/png");
551
+ expect(typeof imageItem.data).toBe("string");
552
+ expect(imageItem.data.length).toBeGreaterThan(100);
553
+
554
+ const textItem = renderResult.content[1];
555
+ expect(textItem.type).toBe("text");
556
+ expect(textItem.text).toContain("Dashboard screenshot rendered");
557
+ expect(textItem.text).toContain("1920x1080");
558
+ expect(textItem.text).toContain("#card-light");
559
+
560
+ // Validate PNG Magic Header from Base64 decoded data
561
+ const imageBuf = Buffer.from(imageItem.data, "base64");
562
+ const pngMagic = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
563
+ expect(imageBuf.subarray(0, 8)).toEqual(pngMagic);
564
+ });
565
+
566
+ it("Step 5 (Tool: ha_system_restore_backup): Performs atomic rollback to the pre-edit snapshot", async () => {
567
+ expect(createdSnapshotId).toBeTruthy();
568
+
569
+ const restoreResult = await handleSystemRestoreBackup(toolClients, {
570
+ snapshot_id: createdSnapshotId,
571
+ });
572
+
573
+ expect(restoreResult.isError).toBeFalsy();
574
+ expect(restoreResult.content[0].text).toContain("Successfully restored backup");
575
+ expect(restoreResult.content[0].text).toContain("ui-lovelace.yaml");
576
+
577
+ // Verify file in addon storage was rolled back to original content
578
+ const currentFile = mockFileSystem.get("ui-lovelace.yaml");
579
+ expect(currentFile).toContain("Original Home Dashboard");
580
+ expect(currentFile).not.toContain("Modern Living Dashboard Updated");
581
+ });
582
+
583
+ it("Step 6 (Tool: ha_system_get_logs): Retrieves sanitized Home Assistant core logs with secrets redacted", async () => {
584
+ const logsResult = await handleSystemGetLogs(toolClients, { lines_count: 50 });
585
+ expect(logsResult.isError).toBeFalsy();
586
+ expect(logsResult.content).toHaveLength(1);
587
+
588
+ const logsText = logsResult.content[0].text;
589
+ expect(logsText).toContain("Starting Home Assistant 2026.8.0");
590
+ expect(logsText).toContain("***REDACTED***");
591
+ expect(logsText).not.toContain(HA_TEST_TOKEN);
592
+ expect(logsText).not.toContain(ADDON_TEST_KEY);
593
+ });
594
+
595
+ it("McpServer Instance Validation: All 12 tools are registered on server instance", () => {
596
+ const registeredTools = (mcpServerInstance as any)._registeredTools;
597
+ expect(registeredTools).toBeDefined();
598
+
599
+ const expectedToolNames = [
600
+ "ha_dashboard_get_config",
601
+ "ha_dashboard_save_config",
602
+ "ha_dashboard_render_screenshot",
603
+ "ha_automation_list",
604
+ "ha_automation_read",
605
+ "ha_automation_write",
606
+ "ha_automation_trigger",
607
+ "ha_system_health",
608
+ "ha_system_list_entities",
609
+ "ha_system_get_logs",
610
+ "ha_system_create_backup",
611
+ "ha_system_restore_backup",
612
+ "ha_system_call_service",
613
+ ];
614
+
615
+ for (const toolName of expectedToolNames) {
616
+ expect(registeredTools[toolName], `McpServer should register tool ${toolName}`).toBeDefined();
617
+ }
618
+ expect(Object.keys(registeredTools).length).toBe(13);
619
+ });
620
+ });