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.
- package/dist/adapters/addon/addon.adapter.d.ts +22 -0
- package/dist/adapters/addon/addon.adapter.d.ts.map +1 -0
- package/dist/adapters/addon/addon.adapter.js +92 -0
- package/dist/adapters/addon/addon.adapter.js.map +1 -0
- package/dist/adapters/browser/playwright.adapter.d.ts +27 -0
- package/dist/adapters/browser/playwright.adapter.d.ts.map +1 -0
- package/dist/adapters/browser/playwright.adapter.js +156 -0
- package/dist/adapters/browser/playwright.adapter.js.map +1 -0
- package/dist/adapters/ha/ha-rest.adapter.d.ts +19 -0
- package/dist/adapters/ha/ha-rest.adapter.d.ts.map +1 -0
- package/dist/adapters/ha/ha-rest.adapter.js +70 -0
- package/dist/adapters/ha/ha-rest.adapter.js.map +1 -0
- package/dist/adapters/ha/ha-ws.adapter.d.ts +29 -0
- package/dist/adapters/ha/ha-ws.adapter.d.ts.map +1 -0
- package/dist/adapters/ha/ha-ws.adapter.js +178 -0
- package/dist/adapters/ha/ha-ws.adapter.js.map +1 -0
- package/dist/browser/renderer.d.ts +3 -0
- package/dist/browser/renderer.d.ts.map +1 -0
- package/dist/browser/renderer.js +3 -0
- package/dist/browser/renderer.js.map +1 -0
- package/dist/clients/addon-client.d.ts +3 -0
- package/dist/clients/addon-client.d.ts.map +1 -0
- package/dist/clients/addon-client.js +3 -0
- package/dist/clients/addon-client.js.map +1 -0
- package/dist/clients/ha-rest.d.ts +3 -0
- package/dist/clients/ha-rest.d.ts.map +1 -0
- package/dist/clients/ha-rest.js +3 -0
- package/dist/clients/ha-rest.js.map +1 -0
- package/dist/clients/ha-ws.d.ts +3 -0
- package/dist/clients/ha-ws.d.ts.map +1 -0
- package/dist/clients/ha-ws.js +3 -0
- package/dist/clients/ha-ws.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/core/config.d.ts +30 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +27 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/errors.d.ts +18 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/logger.d.ts +13 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +25 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/domain/models/automation.d.ts +19 -0
- package/dist/domain/models/automation.d.ts.map +1 -0
- package/dist/domain/models/automation.js +2 -0
- package/dist/domain/models/automation.js.map +1 -0
- package/dist/domain/models/dashboard.d.ts +21 -0
- package/dist/domain/models/dashboard.d.ts.map +1 -0
- package/dist/domain/models/dashboard.js +2 -0
- package/dist/domain/models/dashboard.js.map +1 -0
- package/dist/domain/models/entity.d.ts +13 -0
- package/dist/domain/models/entity.d.ts.map +1 -0
- package/dist/domain/models/entity.js +2 -0
- package/dist/domain/models/entity.js.map +1 -0
- package/dist/domain/models/system.d.ts +35 -0
- package/dist/domain/models/system.d.ts.map +1 -0
- package/dist/domain/models/system.js +2 -0
- package/dist/domain/models/system.js.map +1 -0
- package/dist/domain/ports/addon-client.port.d.ts +31 -0
- package/dist/domain/ports/addon-client.port.d.ts.map +1 -0
- package/dist/domain/ports/addon-client.port.js +2 -0
- package/dist/domain/ports/addon-client.port.js.map +1 -0
- package/dist/domain/ports/ha-client.port.d.ts +17 -0
- package/dist/domain/ports/ha-client.port.d.ts.map +1 -0
- package/dist/domain/ports/ha-client.port.js +2 -0
- package/dist/domain/ports/ha-client.port.js.map +1 -0
- package/dist/domain/ports/renderer.port.d.ts +7 -0
- package/dist/domain/ports/renderer.port.d.ts.map +1 -0
- package/dist/domain/ports/renderer.port.js +2 -0
- package/dist/domain/ports/renderer.port.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/services/automation.service.d.ts +20 -0
- package/dist/services/automation.service.d.ts.map +1 -0
- package/dist/services/automation.service.js +118 -0
- package/dist/services/automation.service.js.map +1 -0
- package/dist/services/dashboard.service.d.ts +22 -0
- package/dist/services/dashboard.service.d.ts.map +1 -0
- package/dist/services/dashboard.service.js +72 -0
- package/dist/services/dashboard.service.js.map +1 -0
- package/dist/services/system.service.d.ts +19 -0
- package/dist/services/system.service.d.ts.map +1 -0
- package/dist/services/system.service.js +83 -0
- package/dist/services/system.service.js.map +1 -0
- package/dist/tools/automation.d.ts +33 -0
- package/dist/tools/automation.d.ts.map +1 -0
- package/dist/tools/automation.js +246 -0
- package/dist/tools/automation.js.map +1 -0
- package/dist/tools/dashboard.d.ts +33 -0
- package/dist/tools/dashboard.d.ts.map +1 -0
- package/dist/tools/dashboard.js +166 -0
- package/dist/tools/dashboard.js.map +1 -0
- package/dist/tools/system.d.ts +44 -0
- package/dist/tools/system.d.ts.map +1 -0
- package/dist/tools/system.js +306 -0
- package/dist/tools/system.js.map +1 -0
- package/dist/tools/types.d.ts +26 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/package.json +39 -0
- package/src/adapters/addon/addon.adapter.ts +113 -0
- package/src/adapters/browser/playwright.adapter.ts +186 -0
- package/src/adapters/ha/ha-rest.adapter.ts +82 -0
- package/src/adapters/ha/ha-ws.adapter.ts +212 -0
- package/src/browser/renderer.ts +7 -0
- package/src/clients/addon-client.ts +2 -0
- package/src/clients/ha-rest.ts +2 -0
- package/src/clients/ha-ws.ts +2 -0
- package/src/config.ts +1 -0
- package/src/core/config.ts +39 -0
- package/src/core/errors.ts +28 -0
- package/src/core/logger.ts +27 -0
- package/src/domain/models/automation.ts +19 -0
- package/src/domain/models/dashboard.ts +23 -0
- package/src/domain/models/entity.ts +13 -0
- package/src/domain/models/system.ts +37 -0
- package/src/domain/ports/addon-client.port.ts +35 -0
- package/src/domain/ports/ha-client.port.ts +16 -0
- package/src/domain/ports/renderer.port.ts +7 -0
- package/src/index.ts +103 -0
- package/src/services/automation.service.ts +142 -0
- package/src/services/dashboard.service.ts +83 -0
- package/src/services/system.service.ts +94 -0
- package/src/tools/automation.ts +300 -0
- package/src/tools/dashboard.ts +206 -0
- package/src/tools/system.ts +368 -0
- package/src/tools/types.ts +30 -0
- package/tests/clients.test.ts +522 -0
- package/tests/e2e-smoke.test.ts +620 -0
- package/tests/renderer.test.ts +286 -0
- package/tests/skills.test.ts +189 -0
- package/tests/tools.test.ts +530 -0
- 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
|
+
});
|