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,286 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { AddressInfo } from "node:net";
|
|
4
|
+
import {
|
|
5
|
+
DashboardRenderer,
|
|
6
|
+
DEVICE_PRESETS,
|
|
7
|
+
resolveViewport,
|
|
8
|
+
resolveUrl,
|
|
9
|
+
RendererConfig,
|
|
10
|
+
RenderOptions,
|
|
11
|
+
} from "../src/browser/renderer.js";
|
|
12
|
+
|
|
13
|
+
describe("DashboardRenderer - Helpers & Configuration", () => {
|
|
14
|
+
const config: RendererConfig = {
|
|
15
|
+
haUrl: "http://localhost:8123",
|
|
16
|
+
haToken: "test-ha-token-12345",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("resolveViewport", () => {
|
|
20
|
+
it("should default to desktop dimensions (1920x1080)", () => {
|
|
21
|
+
const vp = resolveViewport({});
|
|
22
|
+
expect(vp).toEqual({ width: 1920, height: 1080 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should resolve device presets correctly", () => {
|
|
26
|
+
expect(resolveViewport({ devicePreset: "desktop" })).toEqual(DEVICE_PRESETS.desktop);
|
|
27
|
+
expect(resolveViewport({ devicePreset: "desktop" })).toEqual({ width: 1920, height: 1080 });
|
|
28
|
+
|
|
29
|
+
expect(resolveViewport({ devicePreset: "tablet" })).toEqual(DEVICE_PRESETS.tablet);
|
|
30
|
+
expect(resolveViewport({ devicePreset: "tablet" })).toEqual({ width: 768, height: 1024 });
|
|
31
|
+
|
|
32
|
+
expect(resolveViewport({ devicePreset: "mobile" })).toEqual(DEVICE_PRESETS.mobile);
|
|
33
|
+
expect(resolveViewport({ devicePreset: "mobile" })).toEqual({ width: 375, height: 812 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should prioritize custom viewport over device preset", () => {
|
|
37
|
+
const customVp = { width: 1440, height: 900 };
|
|
38
|
+
const vp = resolveViewport({ devicePreset: "mobile", viewport: customVp });
|
|
39
|
+
expect(vp).toEqual(customVp);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolveUrl", () => {
|
|
44
|
+
it("should resolve relative urlPath without leading slash", () => {
|
|
45
|
+
const url = resolveUrl("http://localhost:8123", "lovelace/0");
|
|
46
|
+
expect(url).toBe("http://localhost:8123/lovelace/0");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should resolve relative urlPath with leading slash", () => {
|
|
50
|
+
const url = resolveUrl("http://localhost:8123", "/dashboard-energy");
|
|
51
|
+
expect(url).toBe("http://localhost:8123/dashboard-energy");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should handle trailing slashes in base haUrl", () => {
|
|
55
|
+
const url = resolveUrl("http://localhost:8123///", "/lovelace/1");
|
|
56
|
+
expect(url).toBe("http://localhost:8123/lovelace/1");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should handle empty urlPath", () => {
|
|
60
|
+
const url = resolveUrl("http://localhost:8123", "");
|
|
61
|
+
expect(url).toBe("http://localhost:8123/");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should preserve full URLs if provided in urlPath", () => {
|
|
65
|
+
const url = resolveUrl("http://localhost:8123", "https://other-ha-domain.com/lovelace/0");
|
|
66
|
+
expect(url).toBe("https://other-ha-domain.com/lovelace/0");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("DashboardRenderer - Browser Lifecycle & Rendering", () => {
|
|
72
|
+
let server: http.Server;
|
|
73
|
+
let serverUrl: string;
|
|
74
|
+
let renderer: DashboardRenderer;
|
|
75
|
+
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
server = http.createServer((req, res) => {
|
|
78
|
+
if (req.url === "/dashboard-test" || req.url?.startsWith("/dashboard-test")) {
|
|
79
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
80
|
+
res.end(`
|
|
81
|
+
<!DOCTYPE html>
|
|
82
|
+
<html>
|
|
83
|
+
<head>
|
|
84
|
+
<meta charset="utf-8" />
|
|
85
|
+
<title>Home Assistant Test Dashboard</title>
|
|
86
|
+
<style>
|
|
87
|
+
body {
|
|
88
|
+
margin: 0;
|
|
89
|
+
padding: 20px;
|
|
90
|
+
background-color: #f5f5f5;
|
|
91
|
+
color: #333333;
|
|
92
|
+
font-family: sans-serif;
|
|
93
|
+
}
|
|
94
|
+
@media (prefers-color-scheme: dark) {
|
|
95
|
+
body {
|
|
96
|
+
background-color: #111111;
|
|
97
|
+
color: #eeeeee;
|
|
98
|
+
}
|
|
99
|
+
ha-card {
|
|
100
|
+
background-color: #222222;
|
|
101
|
+
color: #ffffff;
|
|
102
|
+
border: 1px solid #444444;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
home-assistant-main {
|
|
106
|
+
display: block;
|
|
107
|
+
}
|
|
108
|
+
hui-view {
|
|
109
|
+
display: grid;
|
|
110
|
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
111
|
+
gap: 16px;
|
|
112
|
+
}
|
|
113
|
+
ha-card {
|
|
114
|
+
display: block;
|
|
115
|
+
background: #ffffff;
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
118
|
+
padding: 16px;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
121
|
+
</head>
|
|
122
|
+
<body>
|
|
123
|
+
<home-assistant-main>
|
|
124
|
+
<div id="auth-status">Checking...</div>
|
|
125
|
+
<hui-view>
|
|
126
|
+
<ha-card id="card-light">
|
|
127
|
+
<h2>Living Room Light</h2>
|
|
128
|
+
<p>State: ON</p>
|
|
129
|
+
</ha-card>
|
|
130
|
+
<ha-card id="card-sensor">
|
|
131
|
+
<h2>Temperature</h2>
|
|
132
|
+
<p>21.5 °C</p>
|
|
133
|
+
</ha-card>
|
|
134
|
+
</hui-view>
|
|
135
|
+
</home-assistant-main>
|
|
136
|
+
<script>
|
|
137
|
+
const tokens = localStorage.getItem("hassTokens");
|
|
138
|
+
const authEl = document.getElementById("auth-status");
|
|
139
|
+
if (tokens) {
|
|
140
|
+
const parsed = JSON.parse(tokens);
|
|
141
|
+
authEl.innerText = "Authenticated: " + parsed.access_token;
|
|
142
|
+
} else {
|
|
143
|
+
authEl.innerText = "Unauthenticated";
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
</body>
|
|
147
|
+
</html>
|
|
148
|
+
`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (req.url === "/slow-page") {
|
|
153
|
+
// Delayed response
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
156
|
+
res.end("<html><body><h1>Slow</h1></body></html>");
|
|
157
|
+
}, 3000);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
162
|
+
res.end("Not Found");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await new Promise<void>((resolve) => {
|
|
166
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
167
|
+
});
|
|
168
|
+
const addr = server.address() as AddressInfo;
|
|
169
|
+
serverUrl = `http://127.0.0.1:${addr.port}`;
|
|
170
|
+
|
|
171
|
+
renderer = new DashboardRenderer({
|
|
172
|
+
haUrl: serverUrl,
|
|
173
|
+
haToken: "test-auth-token-xyz-987",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
afterEach(async () => {
|
|
178
|
+
if (renderer) {
|
|
179
|
+
await renderer.close();
|
|
180
|
+
}
|
|
181
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should initialize browser and close cleanly", async () => {
|
|
185
|
+
expect(renderer.isInitialized).toBe(false);
|
|
186
|
+
await renderer.initBrowser(true);
|
|
187
|
+
expect(renderer.isInitialized).toBe(true);
|
|
188
|
+
await renderer.close();
|
|
189
|
+
expect(renderer.isInitialized).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should capture full dashboard screenshot with desktop preset", async () => {
|
|
193
|
+
const result = await renderer.captureDashboard({
|
|
194
|
+
urlPath: "/dashboard-test",
|
|
195
|
+
devicePreset: "desktop",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result).toBeDefined();
|
|
199
|
+
expect(result.url).toBe(`${serverUrl}/dashboard-test`);
|
|
200
|
+
expect(result.width).toBe(1920);
|
|
201
|
+
expect(result.height).toBe(1080);
|
|
202
|
+
expect(result.imageBuffer).toBeInstanceOf(Buffer);
|
|
203
|
+
expect(result.imageBuffer.length).toBeGreaterThan(1000);
|
|
204
|
+
|
|
205
|
+
// PNG Header verification: 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A
|
|
206
|
+
const pngMagic = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
207
|
+
expect(result.imageBuffer.subarray(0, 8)).toEqual(pngMagic);
|
|
208
|
+
|
|
209
|
+
expect(result.base64Png).toBeDefined();
|
|
210
|
+
expect(result.base64Png.length).toBeGreaterThan(1000);
|
|
211
|
+
expect(Buffer.from(result.base64Png, "base64")).toEqual(result.imageBuffer);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should capture dashboard with tablet and mobile presets", async () => {
|
|
215
|
+
const tabletResult = await renderer.captureDashboard({
|
|
216
|
+
urlPath: "dashboard-test",
|
|
217
|
+
devicePreset: "tablet",
|
|
218
|
+
});
|
|
219
|
+
expect(tabletResult.width).toBe(768);
|
|
220
|
+
expect(tabletResult.height).toBe(1024);
|
|
221
|
+
|
|
222
|
+
const mobileResult = await renderer.captureDashboard({
|
|
223
|
+
urlPath: "dashboard-test",
|
|
224
|
+
devicePreset: "mobile",
|
|
225
|
+
});
|
|
226
|
+
expect(mobileResult.width).toBe(375);
|
|
227
|
+
expect(mobileResult.height).toBe(812);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should inject Home Assistant auth tokens into browser localStorage", async () => {
|
|
231
|
+
await renderer.initBrowser(true);
|
|
232
|
+
const result = await renderer.captureDashboard({
|
|
233
|
+
urlPath: "/dashboard-test",
|
|
234
|
+
});
|
|
235
|
+
expect(result.imageBuffer.length).toBeGreaterThan(1000);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should capture a specific element when elementSelector is provided", async () => {
|
|
239
|
+
const cardResult = await renderer.captureDashboard({
|
|
240
|
+
urlPath: "/dashboard-test",
|
|
241
|
+
elementSelector: "#card-light",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(cardResult).toBeDefined();
|
|
245
|
+
expect(cardResult.imageBuffer).toBeInstanceOf(Buffer);
|
|
246
|
+
expect(cardResult.imageBuffer.length).toBeGreaterThan(500);
|
|
247
|
+
// Element screenshot will typically be smaller in bytes than full screen
|
|
248
|
+
expect(cardResult.base64Png).toBe(cardResult.imageBuffer.toString("base64"));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("should support dark mode color scheme emulation", async () => {
|
|
252
|
+
const darkResult = await renderer.captureDashboard({
|
|
253
|
+
urlPath: "/dashboard-test",
|
|
254
|
+
darkMode: true,
|
|
255
|
+
});
|
|
256
|
+
expect(darkResult.imageBuffer.length).toBeGreaterThan(1000);
|
|
257
|
+
|
|
258
|
+
const lightResult = await renderer.captureDashboard({
|
|
259
|
+
urlPath: "/dashboard-test",
|
|
260
|
+
darkMode: false,
|
|
261
|
+
});
|
|
262
|
+
expect(lightResult.imageBuffer.length).toBeGreaterThan(1000);
|
|
263
|
+
|
|
264
|
+
// Dark and light screenshots should have different buffer outputs due to different background colors
|
|
265
|
+
expect(darkResult.imageBuffer.equals(lightResult.imageBuffer)).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should throw a clear error when elementSelector is not found", async () => {
|
|
269
|
+
await expect(
|
|
270
|
+
renderer.captureDashboard({
|
|
271
|
+
urlPath: "/dashboard-test",
|
|
272
|
+
elementSelector: "#non-existent-card-selector-12345",
|
|
273
|
+
timeoutMs: 1500,
|
|
274
|
+
})
|
|
275
|
+
).rejects.toThrow(/not found|timeout/i);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should throw a clear error when navigation times out", async () => {
|
|
279
|
+
await expect(
|
|
280
|
+
renderer.captureDashboard({
|
|
281
|
+
urlPath: "/slow-page",
|
|
282
|
+
timeoutMs: 500,
|
|
283
|
+
})
|
|
284
|
+
).rejects.toThrow(/timeout/i);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const skillsRoot = path.resolve(__dirname, "../../skills");
|
|
9
|
+
|
|
10
|
+
interface ParsedSkill {
|
|
11
|
+
name?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
rawFrontmatter: string;
|
|
14
|
+
body: string;
|
|
15
|
+
headings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseSkillFile(filePath: string): ParsedSkill {
|
|
19
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
20
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
21
|
+
if (!fmMatch) {
|
|
22
|
+
throw new Error(`File ${filePath} is missing valid YAML frontmatter`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rawFrontmatter = fmMatch[1];
|
|
26
|
+
const body = fmMatch[2];
|
|
27
|
+
|
|
28
|
+
let name: string | undefined;
|
|
29
|
+
let description: string | undefined;
|
|
30
|
+
|
|
31
|
+
const lines = rawFrontmatter.split(/\r?\n/);
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
if (line.startsWith("name:")) {
|
|
35
|
+
name = line.replace(/^name:\s*/, "").trim().replace(/^['"]|['"]$/g, "");
|
|
36
|
+
} else if (line.startsWith("description:")) {
|
|
37
|
+
let desc = line.replace(/^description:\s*/, "").trim();
|
|
38
|
+
if (desc.startsWith(">") || desc.startsWith("|")) {
|
|
39
|
+
const descLines: string[] = [];
|
|
40
|
+
while (i + 1 < lines.length && (lines[i + 1].startsWith(" ") || lines[i + 1].trim() === "")) {
|
|
41
|
+
i++;
|
|
42
|
+
descLines.push(lines[i].trim());
|
|
43
|
+
}
|
|
44
|
+
desc = descLines.join(" ");
|
|
45
|
+
} else {
|
|
46
|
+
desc = desc.replace(/^['"]|['"]$/g, "");
|
|
47
|
+
}
|
|
48
|
+
description = desc;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const headings = Array.from(body.matchAll(/^#{1,4}\s+(.+)$/gm)).map((m) => m[1].trim());
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
description,
|
|
57
|
+
rawFrontmatter,
|
|
58
|
+
body,
|
|
59
|
+
headings,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("AI Skill Pack (`skills/`) Specification & Validation", () => {
|
|
64
|
+
const expectedSkills = [
|
|
65
|
+
{
|
|
66
|
+
dir: "ha-dashboard-designer",
|
|
67
|
+
name: "ha-dashboard-designer",
|
|
68
|
+
requiredTools: [
|
|
69
|
+
"ha_system_list_entities",
|
|
70
|
+
"ha_dashboard_get_config",
|
|
71
|
+
"ha_dashboard_save_config",
|
|
72
|
+
"ha_dashboard_render_screenshot",
|
|
73
|
+
],
|
|
74
|
+
requiredStepKeywords: [
|
|
75
|
+
"Query available entities",
|
|
76
|
+
"Inspect existing dashboard",
|
|
77
|
+
"layout",
|
|
78
|
+
"Save Lovelace",
|
|
79
|
+
"screenshot",
|
|
80
|
+
"Visual Inspection Loop",
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
dir: "ha-automation-builder",
|
|
85
|
+
name: "ha-automation-builder",
|
|
86
|
+
requiredTools: [
|
|
87
|
+
"ha_automation_list",
|
|
88
|
+
"ha_automation_read",
|
|
89
|
+
"ha_automation_write",
|
|
90
|
+
"ha_automation_trigger",
|
|
91
|
+
"ha_system_get_logs",
|
|
92
|
+
],
|
|
93
|
+
requiredStepKeywords: [
|
|
94
|
+
"Discover entity",
|
|
95
|
+
"Read existing automations",
|
|
96
|
+
"Construct safe YAML",
|
|
97
|
+
"Write automation",
|
|
98
|
+
"Test trigger",
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
dir: "ha-troubleshooter",
|
|
103
|
+
name: "ha-troubleshooter",
|
|
104
|
+
requiredTools: [
|
|
105
|
+
"ha_system_health",
|
|
106
|
+
"ha_system_get_logs",
|
|
107
|
+
"ha_system_restore_backup",
|
|
108
|
+
"ha_system_create_backup",
|
|
109
|
+
],
|
|
110
|
+
requiredStepKeywords: [
|
|
111
|
+
"Healthcheck",
|
|
112
|
+
"logs",
|
|
113
|
+
"rollback",
|
|
114
|
+
"Verify",
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
dir: "ha-device-controller",
|
|
119
|
+
name: "ha-device-controller",
|
|
120
|
+
requiredTools: [
|
|
121
|
+
"ha_system_list_entities",
|
|
122
|
+
"ha_system_call_service",
|
|
123
|
+
"ha_system_get_logs",
|
|
124
|
+
],
|
|
125
|
+
requiredStepKeywords: [
|
|
126
|
+
"Discover entity",
|
|
127
|
+
"Service call",
|
|
128
|
+
"Verify post-execution",
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
it("should have the skills directory present", () => {
|
|
134
|
+
expect(fs.existsSync(skillsRoot)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
for (const skill of expectedSkills) {
|
|
138
|
+
describe(`Skill: ${skill.name}`, () => {
|
|
139
|
+
const skillPath = path.join(skillsRoot, skill.dir, "SKILL.md");
|
|
140
|
+
|
|
141
|
+
it("should exist as SKILL.md in the respective directory", () => {
|
|
142
|
+
expect(fs.existsSync(skillPath)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should parse valid YAML frontmatter with correct name and non-empty description", () => {
|
|
146
|
+
const parsed = parseSkillFile(skillPath);
|
|
147
|
+
expect(parsed.name).toBe(skill.name);
|
|
148
|
+
expect(parsed.description).toBeDefined();
|
|
149
|
+
expect(parsed.description!.length).toBeGreaterThan(10);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should contain standard required markdown sections (Overview, Workflow, Tools, Safety)", () => {
|
|
153
|
+
const parsed = parseSkillFile(skillPath);
|
|
154
|
+
const headingTexts = parsed.headings.map((h) => h.toLowerCase());
|
|
155
|
+
|
|
156
|
+
const hasOverview = headingTexts.some((h) => h.includes("overview"));
|
|
157
|
+
const hasWorkflow = headingTexts.some((h) => h.includes("workflow") || h.includes("steps") || h.includes("standard operating procedure"));
|
|
158
|
+
const hasTools = headingTexts.some((h) => h.includes("tool") || h.includes("mcp"));
|
|
159
|
+
const hasSafety = headingTexts.some((h) => h.includes("safety") || h.includes("rules") || h.includes("best practice"));
|
|
160
|
+
|
|
161
|
+
expect(hasOverview, `Skill ${skill.name} missing Overview section`).toBe(true);
|
|
162
|
+
expect(hasWorkflow, `Skill ${skill.name} missing Workflow section`).toBe(true);
|
|
163
|
+
expect(hasTools, `Skill ${skill.name} missing Tools section`).toBe(true);
|
|
164
|
+
expect(hasSafety, `Skill ${skill.name} missing Safety Rules section`).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should reference all required MCP tools in its body or tool section", () => {
|
|
168
|
+
const parsed = parseSkillFile(skillPath);
|
|
169
|
+
for (const toolName of skill.requiredTools) {
|
|
170
|
+
expect(
|
|
171
|
+
parsed.body.includes(toolName),
|
|
172
|
+
`Skill ${skill.name} must reference tool '${toolName}'`
|
|
173
|
+
).toBe(true);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should cover all required workflow step keywords", () => {
|
|
178
|
+
const parsed = parseSkillFile(skillPath);
|
|
179
|
+
const lowerBody = parsed.body.toLowerCase();
|
|
180
|
+
for (const kw of skill.requiredStepKeywords) {
|
|
181
|
+
expect(
|
|
182
|
+
lowerBody.includes(kw.toLowerCase()),
|
|
183
|
+
`Skill ${skill.name} should include step content mentioning '${kw}'`
|
|
184
|
+
).toBe(true);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|