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,530 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import {
|
|
4
|
+
handleDashboardGetConfig,
|
|
5
|
+
handleDashboardSaveConfig,
|
|
6
|
+
handleDashboardRenderScreenshot,
|
|
7
|
+
registerDashboardTools,
|
|
8
|
+
} from "../src/tools/dashboard.js";
|
|
9
|
+
import {
|
|
10
|
+
handleAutomationList,
|
|
11
|
+
handleAutomationRead,
|
|
12
|
+
handleAutomationWrite,
|
|
13
|
+
handleAutomationTrigger,
|
|
14
|
+
registerAutomationTools,
|
|
15
|
+
} from "../src/tools/automation.js";
|
|
16
|
+
import {
|
|
17
|
+
handleSystemHealth,
|
|
18
|
+
handleSystemListEntities,
|
|
19
|
+
handleSystemGetLogs,
|
|
20
|
+
handleSystemCreateBackup,
|
|
21
|
+
handleSystemRestoreBackup,
|
|
22
|
+
registerSystemTools,
|
|
23
|
+
} from "../src/tools/system.js";
|
|
24
|
+
import { createServer } from "../src/index.js";
|
|
25
|
+
import type { ToolClients } from "../src/tools/types.js";
|
|
26
|
+
|
|
27
|
+
describe("MCP Tools Suite", () => {
|
|
28
|
+
let mockClients: ToolClients;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockClients = {
|
|
32
|
+
restClient: {
|
|
33
|
+
haUrl: "http://127.0.0.1:8123",
|
|
34
|
+
haToken: "test-ha-token",
|
|
35
|
+
checkApi: vi.fn().mockResolvedValue({ message: "API running." }),
|
|
36
|
+
getStates: vi.fn().mockResolvedValue([
|
|
37
|
+
{
|
|
38
|
+
entity_id: "light.living_room",
|
|
39
|
+
state: "on",
|
|
40
|
+
attributes: { friendly_name: "Living Room Light", brightness: 255 },
|
|
41
|
+
last_changed: "2026-08-31T08:00:00.000Z",
|
|
42
|
+
last_updated: "2026-08-31T08:00:00.000Z",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
entity_id: "sensor.temperature",
|
|
46
|
+
state: "21.5",
|
|
47
|
+
attributes: { friendly_name: "Living Room Temp", unit_of_measurement: "°C" },
|
|
48
|
+
last_changed: "2026-08-31T08:00:00.000Z",
|
|
49
|
+
last_updated: "2026-08-31T08:00:00.000Z",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
entity_id: "automation.evening_lights",
|
|
53
|
+
state: "on",
|
|
54
|
+
attributes: {
|
|
55
|
+
friendly_name: "Turn On Evening Lights",
|
|
56
|
+
last_triggered: "2026-08-30T19:00:00.000Z",
|
|
57
|
+
},
|
|
58
|
+
last_changed: "2026-08-30T19:00:00.000Z",
|
|
59
|
+
last_updated: "2026-08-30T19:00:00.000Z",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
entity_id: "script.notify_all",
|
|
63
|
+
state: "off",
|
|
64
|
+
attributes: { friendly_name: "Send Notification to All" },
|
|
65
|
+
last_changed: "2026-08-30T19:00:00.000Z",
|
|
66
|
+
last_updated: "2026-08-30T19:00:00.000Z",
|
|
67
|
+
},
|
|
68
|
+
]),
|
|
69
|
+
callService: vi.fn().mockResolvedValue([{ success: true }]),
|
|
70
|
+
} as any,
|
|
71
|
+
|
|
72
|
+
wsClient: {
|
|
73
|
+
haUrl: "http://127.0.0.1:8123",
|
|
74
|
+
haToken: "test-ha-token",
|
|
75
|
+
isConnected: true,
|
|
76
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
78
|
+
getLovelaceConfig: vi.fn().mockResolvedValue({
|
|
79
|
+
title: "Home",
|
|
80
|
+
views: [{ title: "Main", cards: [] }],
|
|
81
|
+
}),
|
|
82
|
+
saveLovelaceConfig: vi.fn().mockResolvedValue(null),
|
|
83
|
+
fetchStates: vi.fn().mockResolvedValue([]),
|
|
84
|
+
sendMessage: vi.fn().mockResolvedValue({}),
|
|
85
|
+
} as any,
|
|
86
|
+
|
|
87
|
+
addonClient: {
|
|
88
|
+
addonUrl: "http://127.0.0.1:8099",
|
|
89
|
+
addonKey: "test-addon-key",
|
|
90
|
+
checkHealth: vi.fn().mockResolvedValue({
|
|
91
|
+
status: "ok",
|
|
92
|
+
version: "1.0.0",
|
|
93
|
+
config_root: "/config",
|
|
94
|
+
snapshots_count: 5,
|
|
95
|
+
memory_mb: 28.4,
|
|
96
|
+
}),
|
|
97
|
+
readFile: vi.fn().mockImplementation(async (filePath: string) => {
|
|
98
|
+
if (filePath === "automations.yaml") {
|
|
99
|
+
return {
|
|
100
|
+
path: "automations.yaml",
|
|
101
|
+
content: `- id: '1700000000001'\n alias: Turn On Evening Lights\n trigger:\n - platform: sun\n event: sunset\n action:\n - service: light.turn_on\n target:\n entity_id: light.living_room\n\n- id: '1700000000002'\n alias: Morning Coffee\n trigger:\n - platform: time\n at: '07:00:00'\n action:\n - service: switch.turn_on\n target:\n entity_id: switch.coffee_maker\n`,
|
|
102
|
+
size_bytes: 350,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (filePath === "configuration.yaml") {
|
|
106
|
+
return {
|
|
107
|
+
path: "configuration.yaml",
|
|
108
|
+
content: "default_config:\nsun:\n",
|
|
109
|
+
size_bytes: 20,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (filePath === "ui-lovelace.yaml") {
|
|
113
|
+
return {
|
|
114
|
+
path: "ui-lovelace.yaml",
|
|
115
|
+
content: "title: Fallback Lovelace\nviews: []\n",
|
|
116
|
+
size_bytes: 35,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`File not found: ${filePath}`);
|
|
120
|
+
}),
|
|
121
|
+
writeFile: vi.fn().mockResolvedValue({
|
|
122
|
+
success: true,
|
|
123
|
+
path: "automations.yaml",
|
|
124
|
+
snapshot_id: "snap_20260831_120000_automations_yaml",
|
|
125
|
+
bytes_written: 400,
|
|
126
|
+
}),
|
|
127
|
+
listSnapshots: vi.fn().mockResolvedValue([
|
|
128
|
+
{
|
|
129
|
+
snapshot_id: "snap_20260831_120000_automations_yaml",
|
|
130
|
+
timestamp: "2026-08-31T12:00:00Z",
|
|
131
|
+
original_file: "automations.yaml",
|
|
132
|
+
backup_file: ".snapshots/automations.yaml.20260831_120000.bak",
|
|
133
|
+
label: "pre-edit",
|
|
134
|
+
file_size_bytes: 350,
|
|
135
|
+
},
|
|
136
|
+
]),
|
|
137
|
+
restoreSnapshot: vi.fn().mockResolvedValue({
|
|
138
|
+
success: true,
|
|
139
|
+
restored_file: "automations.yaml",
|
|
140
|
+
restored_from: ".snapshots/automations.yaml.20260831_120000.bak",
|
|
141
|
+
safety_backup: ".snapshots/automations.yaml.safety.bak",
|
|
142
|
+
}),
|
|
143
|
+
getLogs: vi.fn().mockResolvedValue({
|
|
144
|
+
lines: [
|
|
145
|
+
"2026-08-31 08:00:00 INFO (MainThread) [homeassistant.core] Starting Home Assistant",
|
|
146
|
+
"2026-08-31 08:00:01 INFO (MainThread) [homeassistant.components.automation] Loaded 2 automations",
|
|
147
|
+
],
|
|
148
|
+
count: 2,
|
|
149
|
+
}),
|
|
150
|
+
} as any,
|
|
151
|
+
|
|
152
|
+
renderer: {
|
|
153
|
+
isInitialized: true,
|
|
154
|
+
initBrowser: vi.fn().mockResolvedValue(undefined),
|
|
155
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
156
|
+
captureDashboard: vi.fn().mockResolvedValue({
|
|
157
|
+
imageBuffer: Buffer.from("fake-png-bytes"),
|
|
158
|
+
base64Png: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
159
|
+
width: 1920,
|
|
160
|
+
height: 1080,
|
|
161
|
+
url: "http://127.0.0.1:8123/lovelace/0",
|
|
162
|
+
}),
|
|
163
|
+
} as any,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("Dashboard Tools", () => {
|
|
168
|
+
it("ha_dashboard_get_config should return config via WebSocket", async () => {
|
|
169
|
+
const res = await handleDashboardGetConfig(mockClients, { dashboard_slug: "lovelace" });
|
|
170
|
+
expect(res.isError).toBeFalsy();
|
|
171
|
+
expect(res.content).toHaveLength(1);
|
|
172
|
+
expect(res.content[0].type).toBe("text");
|
|
173
|
+
expect(res.content[0].text).toContain("Main");
|
|
174
|
+
expect(mockClients.wsClient.getLovelaceConfig).toHaveBeenCalledWith("lovelace");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("ha_dashboard_get_config should fall back to AddonClient when WS client is unavailable", async () => {
|
|
178
|
+
(mockClients.wsClient.getLovelaceConfig as any).mockRejectedValueOnce(
|
|
179
|
+
new Error("WS connection refused")
|
|
180
|
+
);
|
|
181
|
+
const res = await handleDashboardGetConfig(mockClients, {});
|
|
182
|
+
expect(res.isError).toBeFalsy();
|
|
183
|
+
expect(res.content[0].text).toContain("Fallback Lovelace");
|
|
184
|
+
expect(mockClients.addonClient.readFile).toHaveBeenCalledWith("ui-lovelace.yaml");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("ha_dashboard_get_config should return error when both WS and file read fail", async () => {
|
|
188
|
+
(mockClients.wsClient.getLovelaceConfig as any).mockRejectedValueOnce(
|
|
189
|
+
new Error("WS error")
|
|
190
|
+
);
|
|
191
|
+
(mockClients.addonClient.readFile as any).mockRejectedValueOnce(
|
|
192
|
+
new Error("File not found")
|
|
193
|
+
);
|
|
194
|
+
const res = await handleDashboardGetConfig(mockClients, { dashboard_slug: "unknown" });
|
|
195
|
+
expect(res.isError).toBe(true);
|
|
196
|
+
expect(res.content[0].text).toContain("Failed to get dashboard configuration");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("ha_dashboard_save_config should save config and create snapshot", async () => {
|
|
200
|
+
const yamlContent = "title: Updated Dashboard\nviews: []\n";
|
|
201
|
+
const res = await handleDashboardSaveConfig(mockClients, {
|
|
202
|
+
config_yaml: yamlContent,
|
|
203
|
+
dashboard_slug: "lovelace",
|
|
204
|
+
label: "test update",
|
|
205
|
+
});
|
|
206
|
+
expect(res.isError).toBeFalsy();
|
|
207
|
+
expect(res.content[0].text).toContain("saved");
|
|
208
|
+
expect(mockClients.addonClient.writeFile).toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("ha_dashboard_save_config should also save to WS if config is valid JSON", async () => {
|
|
212
|
+
const jsonContent = JSON.stringify({ title: "JSON Dashboard", views: [] });
|
|
213
|
+
const res = await handleDashboardSaveConfig(mockClients, {
|
|
214
|
+
config_yaml: jsonContent,
|
|
215
|
+
dashboard_slug: "lovelace",
|
|
216
|
+
});
|
|
217
|
+
expect(res.isError).toBeFalsy();
|
|
218
|
+
expect(mockClients.wsClient.saveLovelaceConfig).toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("ha_dashboard_save_config should handle write error gracefully", async () => {
|
|
222
|
+
(mockClients.addonClient.writeFile as any).mockRejectedValueOnce(
|
|
223
|
+
new Error("Invalid YAML syntax")
|
|
224
|
+
);
|
|
225
|
+
const res = await handleDashboardSaveConfig(mockClients, {
|
|
226
|
+
config_yaml: "invalid: [yaml",
|
|
227
|
+
});
|
|
228
|
+
expect(res.isError).toBe(true);
|
|
229
|
+
expect(res.content[0].text).toContain("Invalid YAML syntax");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("ha_dashboard_render_screenshot should return image and metadata", async () => {
|
|
233
|
+
const res = await handleDashboardRenderScreenshot(mockClients, {
|
|
234
|
+
url_path: "lovelace/0",
|
|
235
|
+
device_preset: "desktop",
|
|
236
|
+
dark_mode: true,
|
|
237
|
+
element_selector: "#card-light",
|
|
238
|
+
});
|
|
239
|
+
expect(res.isError).toBeFalsy();
|
|
240
|
+
expect(res.content).toHaveLength(2);
|
|
241
|
+
expect(res.content[0].type).toBe("image");
|
|
242
|
+
expect((res.content[0] as any).mimeType).toBe("image/png");
|
|
243
|
+
expect((res.content[0] as any).data).toBeDefined();
|
|
244
|
+
expect(res.content[1].type).toBe("text");
|
|
245
|
+
expect(res.content[1].text).toContain("1920x1080");
|
|
246
|
+
expect(res.content[1].text).toContain("#card-light");
|
|
247
|
+
expect(mockClients.renderer.captureDashboard).toHaveBeenCalledWith({
|
|
248
|
+
urlPath: "lovelace/0",
|
|
249
|
+
devicePreset: "desktop",
|
|
250
|
+
darkMode: true,
|
|
251
|
+
elementSelector: "#card-light",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("ha_dashboard_render_screenshot should handle renderer error gracefully", async () => {
|
|
256
|
+
(mockClients.renderer.captureDashboard as any).mockRejectedValueOnce(
|
|
257
|
+
new Error("Navigation timeout")
|
|
258
|
+
);
|
|
259
|
+
const res = await handleDashboardRenderScreenshot(mockClients, {
|
|
260
|
+
url_path: "lovelace/nonexistent",
|
|
261
|
+
});
|
|
262
|
+
expect(res.isError).toBe(true);
|
|
263
|
+
expect(res.content[0].text).toContain("Navigation timeout");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("Automation Tools", () => {
|
|
268
|
+
it("ha_automation_list should list all automations by default", async () => {
|
|
269
|
+
const res = await handleAutomationList(mockClients, {});
|
|
270
|
+
expect(res.isError).toBeFalsy();
|
|
271
|
+
const parsed = JSON.parse(res.content[0].text);
|
|
272
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
273
|
+
expect(parsed).toHaveLength(1);
|
|
274
|
+
expect(parsed[0].entity_id).toBe("automation.evening_lights");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("ha_automation_list should filter by domain (e.g. script)", async () => {
|
|
278
|
+
const res = await handleAutomationList(mockClients, { domain: "script" });
|
|
279
|
+
expect(res.isError).toBeFalsy();
|
|
280
|
+
const parsed = JSON.parse(res.content[0].text);
|
|
281
|
+
expect(parsed).toHaveLength(1);
|
|
282
|
+
expect(parsed[0].entity_id).toBe("script.notify_all");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("ha_automation_list should handle API error gracefully", async () => {
|
|
286
|
+
(mockClients.restClient.getStates as any).mockRejectedValueOnce(
|
|
287
|
+
new Error("HA REST API error")
|
|
288
|
+
);
|
|
289
|
+
const res = await handleAutomationList(mockClients, {});
|
|
290
|
+
expect(res.isError).toBe(true);
|
|
291
|
+
expect(res.content[0].text).toContain("Failed to list automations");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("ha_automation_read should find and return target automation by id", async () => {
|
|
295
|
+
const res = await handleAutomationRead(mockClients, { automation_id: "1700000000001" });
|
|
296
|
+
expect(res.isError).toBeFalsy();
|
|
297
|
+
expect(res.content[0].text).toContain("Turn On Evening Lights");
|
|
298
|
+
expect(res.content[0].text).toContain("sunset");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("ha_automation_read should find target automation by alias", async () => {
|
|
302
|
+
const res = await handleAutomationRead(mockClients, { automation_id: "Morning Coffee" });
|
|
303
|
+
expect(res.isError).toBeFalsy();
|
|
304
|
+
expect(res.content[0].text).toContain("switch.coffee_maker");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("ha_automation_read should return error if automation not found", async () => {
|
|
308
|
+
const res = await handleAutomationRead(mockClients, { automation_id: "nonexistent_id" });
|
|
309
|
+
expect(res.isError).toBe(true);
|
|
310
|
+
expect(res.content[0].text).toContain("not found");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("ha_automation_read should handle file read error gracefully", async () => {
|
|
314
|
+
(mockClients.addonClient.readFile as any).mockRejectedValueOnce(
|
|
315
|
+
new Error("Permission denied")
|
|
316
|
+
);
|
|
317
|
+
const res = await handleAutomationRead(mockClients, { automation_id: "1700000000001" });
|
|
318
|
+
expect(res.isError).toBe(true);
|
|
319
|
+
expect(res.content[0].text).toContain("Permission denied");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("ha_automation_write should update an existing automation and reload", async () => {
|
|
323
|
+
const updatedBlock = `- id: '1700000000001'\n alias: Turn On Evening Lights Updated\n trigger:\n - platform: sun\n event: sunset\n offset: '-00:30:00'\n action:\n - service: light.turn_on\n target:\n entity_id: light.living_room\n`;
|
|
324
|
+
const res = await handleAutomationWrite(mockClients, {
|
|
325
|
+
automation_id: "1700000000001",
|
|
326
|
+
yaml_code: updatedBlock,
|
|
327
|
+
label: "update offset",
|
|
328
|
+
});
|
|
329
|
+
expect(res.isError).toBeFalsy();
|
|
330
|
+
expect(res.content[0].text).toContain("successfully saved");
|
|
331
|
+
expect(mockClients.addonClient.writeFile).toHaveBeenCalledWith(
|
|
332
|
+
"automations.yaml",
|
|
333
|
+
expect.stringContaining("Turn On Evening Lights Updated"),
|
|
334
|
+
expect.objectContaining({ validateYaml: true, label: "update offset" })
|
|
335
|
+
);
|
|
336
|
+
expect(mockClients.restClient.callService).toHaveBeenCalledWith("automation", "reload");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("ha_automation_write should append new automation if id not found", async () => {
|
|
340
|
+
const newBlock = `- id: '1700000000003'\n alias: Night Lights\n trigger: []\n action: []\n`;
|
|
341
|
+
const res = await handleAutomationWrite(mockClients, {
|
|
342
|
+
automation_id: "1700000000003",
|
|
343
|
+
yaml_code: newBlock,
|
|
344
|
+
});
|
|
345
|
+
expect(res.isError).toBeFalsy();
|
|
346
|
+
expect(mockClients.addonClient.writeFile).toHaveBeenCalledWith(
|
|
347
|
+
"automations.yaml",
|
|
348
|
+
expect.stringContaining("Night Lights"),
|
|
349
|
+
expect.anything()
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("ha_automation_write should handle service reload error gracefully", async () => {
|
|
354
|
+
(mockClients.restClient.callService as any).mockRejectedValueOnce(
|
|
355
|
+
new Error("Reload service unavailable")
|
|
356
|
+
);
|
|
357
|
+
const res = await handleAutomationWrite(mockClients, {
|
|
358
|
+
automation_id: "1700000000001",
|
|
359
|
+
yaml_code: `- id: '1700000000001'\n alias: Test\n`,
|
|
360
|
+
});
|
|
361
|
+
expect(res.isError).toBeFalsy();
|
|
362
|
+
expect(res.content[0].text).toContain("service reload failed");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("ha_automation_write should handle file write failure", async () => {
|
|
366
|
+
(mockClients.addonClient.writeFile as any).mockRejectedValueOnce(
|
|
367
|
+
new Error("Invalid YAML structure")
|
|
368
|
+
);
|
|
369
|
+
const res = await handleAutomationWrite(mockClients, {
|
|
370
|
+
automation_id: "1700000000001",
|
|
371
|
+
yaml_code: "invalid",
|
|
372
|
+
});
|
|
373
|
+
expect(res.isError).toBe(true);
|
|
374
|
+
expect(res.content[0].text).toContain("Failed to write automation");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("ha_automation_trigger should trigger automation entity", async () => {
|
|
378
|
+
const res = await handleAutomationTrigger(mockClients, {
|
|
379
|
+
entity_id: "automation.evening_lights",
|
|
380
|
+
});
|
|
381
|
+
expect(res.isError).toBeFalsy();
|
|
382
|
+
expect(res.content[0].text).toContain("Successfully triggered");
|
|
383
|
+
expect(mockClients.restClient.callService).toHaveBeenCalledWith("automation", "trigger", {
|
|
384
|
+
entity_id: "automation.evening_lights",
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("ha_automation_trigger should handle trigger error gracefully", async () => {
|
|
389
|
+
(mockClients.restClient.callService as any).mockRejectedValueOnce(
|
|
390
|
+
new Error("Entity not found")
|
|
391
|
+
);
|
|
392
|
+
const res = await handleAutomationTrigger(mockClients, {
|
|
393
|
+
entity_id: "automation.nonexistent",
|
|
394
|
+
});
|
|
395
|
+
expect(res.isError).toBe(true);
|
|
396
|
+
expect(res.content[0].text).toContain("Failed to trigger");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("System Tools", () => {
|
|
401
|
+
it("ha_system_health should return combined health report", async () => {
|
|
402
|
+
const res = await handleSystemHealth(mockClients);
|
|
403
|
+
expect(res.isError).toBeFalsy();
|
|
404
|
+
const report = JSON.parse(res.content[0].text);
|
|
405
|
+
expect(report.status).toBe("ok");
|
|
406
|
+
expect(report.addon.version).toBe("1.0.0");
|
|
407
|
+
expect(report.homeassistant.api).toBe("ok");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("ha_system_health should report degraded status when HA API fails", async () => {
|
|
411
|
+
(mockClients.restClient.checkApi as any).mockRejectedValueOnce(
|
|
412
|
+
new Error("Connection refused")
|
|
413
|
+
);
|
|
414
|
+
const res = await handleSystemHealth(mockClients);
|
|
415
|
+
expect(res.isError).toBeFalsy();
|
|
416
|
+
const report = JSON.parse(res.content[0].text);
|
|
417
|
+
expect(report.status).toBe("degraded");
|
|
418
|
+
expect(report.homeassistant.api).toBe("unreachable");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("ha_system_list_entities should filter by domain and search query", async () => {
|
|
422
|
+
const res = await handleSystemListEntities(mockClients, {
|
|
423
|
+
domain_filter: "light",
|
|
424
|
+
search_query: "living",
|
|
425
|
+
});
|
|
426
|
+
expect(res.isError).toBeFalsy();
|
|
427
|
+
const entities = JSON.parse(res.content[0].text);
|
|
428
|
+
expect(entities).toHaveLength(1);
|
|
429
|
+
expect(entities[0].entity_id).toBe("light.living_room");
|
|
430
|
+
expect(entities[0].friendly_name).toBe("Living Room Light");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("ha_system_list_entities should handle error gracefully", async () => {
|
|
434
|
+
(mockClients.restClient.getStates as any).mockRejectedValueOnce(
|
|
435
|
+
new Error("Unauthorized")
|
|
436
|
+
);
|
|
437
|
+
const res = await handleSystemListEntities(mockClients, {});
|
|
438
|
+
expect(res.isError).toBe(true);
|
|
439
|
+
expect(res.content[0].text).toContain("Failed to list entities");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("ha_system_get_logs should return tailed log lines", async () => {
|
|
443
|
+
const res = await handleSystemGetLogs(mockClients, { lines_count: 50 });
|
|
444
|
+
expect(res.isError).toBeFalsy();
|
|
445
|
+
expect(res.content[0].text).toContain("Starting Home Assistant");
|
|
446
|
+
expect(mockClients.addonClient.getLogs).toHaveBeenCalledWith(50);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("ha_system_get_logs should handle logs error gracefully", async () => {
|
|
450
|
+
(mockClients.addonClient.getLogs as any).mockRejectedValueOnce(
|
|
451
|
+
new Error("Log file locked")
|
|
452
|
+
);
|
|
453
|
+
const res = await handleSystemGetLogs(mockClients, {});
|
|
454
|
+
expect(res.isError).toBe(true);
|
|
455
|
+
expect(res.content[0].text).toContain("Failed to retrieve logs");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("ha_system_create_backup should create snapshot and return snapshot ID", async () => {
|
|
459
|
+
const res = await handleSystemCreateBackup(mockClients, { label: "manual backup before update" });
|
|
460
|
+
expect(res.isError).toBeFalsy();
|
|
461
|
+
expect(res.content[0].text).toContain("Backup created");
|
|
462
|
+
expect(mockClients.addonClient.writeFile).toHaveBeenCalled();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("ha_system_create_backup should handle backup creation error", async () => {
|
|
466
|
+
(mockClients.addonClient.writeFile as any).mockRejectedValueOnce(
|
|
467
|
+
new Error("Disk full")
|
|
468
|
+
);
|
|
469
|
+
const res = await handleSystemCreateBackup(mockClients, { label: "backup fail" });
|
|
470
|
+
expect(res.isError).toBe(true);
|
|
471
|
+
expect(res.content[0].text).toContain("Failed to create backup snapshot");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("ha_system_restore_backup should restore snapshot", async () => {
|
|
475
|
+
const res = await handleSystemRestoreBackup(mockClients, {
|
|
476
|
+
snapshot_id: "snap_20260831_120000_automations_yaml",
|
|
477
|
+
});
|
|
478
|
+
expect(res.isError).toBeFalsy();
|
|
479
|
+
expect(res.content[0].text).toContain("Successfully restored");
|
|
480
|
+
expect(mockClients.addonClient.restoreSnapshot).toHaveBeenCalledWith(
|
|
481
|
+
"snap_20260831_120000_automations_yaml"
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("ha_system_restore_backup should handle restore failure", async () => {
|
|
486
|
+
(mockClients.addonClient.restoreSnapshot as any).mockRejectedValueOnce(
|
|
487
|
+
new Error("Snapshot not found")
|
|
488
|
+
);
|
|
489
|
+
const res = await handleSystemRestoreBackup(mockClients, {
|
|
490
|
+
snapshot_id: "invalid_snap",
|
|
491
|
+
});
|
|
492
|
+
expect(res.isError).toBe(true);
|
|
493
|
+
expect(res.content[0].text).toContain("Failed to restore backup snapshot");
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe("MCP Server Tool Registration & Factory", () => {
|
|
498
|
+
it("should register all tools on McpServer instance", () => {
|
|
499
|
+
const server = new McpServer({ name: "ha-ai-mcp", version: "0.1.0" });
|
|
500
|
+
registerDashboardTools(server, mockClients);
|
|
501
|
+
registerAutomationTools(server, mockClients);
|
|
502
|
+
registerSystemTools(server, mockClients);
|
|
503
|
+
|
|
504
|
+
// Verify internal tool registrations
|
|
505
|
+
const registeredTools = (server as any)._registeredTools;
|
|
506
|
+
expect(registeredTools).toBeDefined();
|
|
507
|
+
expect(registeredTools["ha_dashboard_get_config"]).toBeDefined();
|
|
508
|
+
expect(registeredTools["ha_dashboard_save_config"]).toBeDefined();
|
|
509
|
+
expect(registeredTools["ha_dashboard_render_screenshot"]).toBeDefined();
|
|
510
|
+
expect(registeredTools["ha_automation_list"]).toBeDefined();
|
|
511
|
+
expect(registeredTools["ha_automation_read"]).toBeDefined();
|
|
512
|
+
expect(registeredTools["ha_automation_write"]).toBeDefined();
|
|
513
|
+
expect(registeredTools["ha_automation_trigger"]).toBeDefined();
|
|
514
|
+
expect(registeredTools["ha_system_health"]).toBeDefined();
|
|
515
|
+
expect(registeredTools["ha_system_list_entities"]).toBeDefined();
|
|
516
|
+
expect(registeredTools["ha_system_get_logs"]).toBeDefined();
|
|
517
|
+
expect(registeredTools["ha_system_create_backup"]).toBeDefined();
|
|
518
|
+
expect(registeredTools["ha_system_restore_backup"]).toBeDefined();
|
|
519
|
+
expect(registeredTools["ha_system_call_service"]).toBeDefined();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("createServer factory should initialize server and clients properly", () => {
|
|
523
|
+
const { server, clients } = createServer(mockClients);
|
|
524
|
+
expect(server).toBeDefined();
|
|
525
|
+
expect(clients).toBe(mockClients);
|
|
526
|
+
const registeredTools = (server as any)._registeredTools;
|
|
527
|
+
expect(Object.keys(registeredTools).length).toBe(13);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
});
|