skimpyclaw 0.1.0

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 (219) hide show
  1. package/README.md +230 -0
  2. package/dist/__tests__/agent.test.d.ts +1 -0
  3. package/dist/__tests__/agent.test.js +131 -0
  4. package/dist/__tests__/api.test.d.ts +1 -0
  5. package/dist/__tests__/api.test.js +1227 -0
  6. package/dist/__tests__/audit.test.d.ts +1 -0
  7. package/dist/__tests__/audit.test.js +122 -0
  8. package/dist/__tests__/cache.test.d.ts +1 -0
  9. package/dist/__tests__/cache.test.js +65 -0
  10. package/dist/__tests__/channels.test.d.ts +1 -0
  11. package/dist/__tests__/channels.test.js +85 -0
  12. package/dist/__tests__/cli.integration.test.d.ts +1 -0
  13. package/dist/__tests__/cli.integration.test.js +16 -0
  14. package/dist/__tests__/cli.test.d.ts +1 -0
  15. package/dist/__tests__/cli.test.js +230 -0
  16. package/dist/__tests__/code-agents-executor.test.d.ts +1 -0
  17. package/dist/__tests__/code-agents-executor.test.js +75 -0
  18. package/dist/__tests__/code-agents-orchestrator.test.d.ts +1 -0
  19. package/dist/__tests__/code-agents-orchestrator.test.js +149 -0
  20. package/dist/__tests__/code-agents-parser.test.d.ts +1 -0
  21. package/dist/__tests__/code-agents-parser.test.js +39 -0
  22. package/dist/__tests__/code-agents-utils.test.d.ts +1 -0
  23. package/dist/__tests__/code-agents-utils.test.js +41 -0
  24. package/dist/__tests__/config.test.d.ts +1 -0
  25. package/dist/__tests__/config.test.js +46 -0
  26. package/dist/__tests__/cron.test.d.ts +1 -0
  27. package/dist/__tests__/cron.test.js +66 -0
  28. package/dist/__tests__/dashboard-mode.test.d.ts +1 -0
  29. package/dist/__tests__/dashboard-mode.test.js +145 -0
  30. package/dist/__tests__/dashboard.test.d.ts +1 -0
  31. package/dist/__tests__/dashboard.test.js +43 -0
  32. package/dist/__tests__/doctor.formatters.test.d.ts +1 -0
  33. package/dist/__tests__/doctor.formatters.test.js +65 -0
  34. package/dist/__tests__/doctor.index.test.d.ts +1 -0
  35. package/dist/__tests__/doctor.index.test.js +48 -0
  36. package/dist/__tests__/doctor.runner.test.d.ts +1 -0
  37. package/dist/__tests__/doctor.runner.test.js +204 -0
  38. package/dist/__tests__/exec-approval.test.d.ts +1 -0
  39. package/dist/__tests__/exec-approval.test.js +323 -0
  40. package/dist/__tests__/file-lock.test.d.ts +1 -0
  41. package/dist/__tests__/file-lock.test.js +92 -0
  42. package/dist/__tests__/langfuse.test.d.ts +1 -0
  43. package/dist/__tests__/langfuse.test.js +40 -0
  44. package/dist/__tests__/model-selection.test.d.ts +1 -0
  45. package/dist/__tests__/model-selection.test.js +62 -0
  46. package/dist/__tests__/orchestrator.test.d.ts +1 -0
  47. package/dist/__tests__/orchestrator.test.js +425 -0
  48. package/dist/__tests__/providers-init.test.d.ts +1 -0
  49. package/dist/__tests__/providers-init.test.js +32 -0
  50. package/dist/__tests__/providers-routing.test.d.ts +1 -0
  51. package/dist/__tests__/providers-routing.test.js +25 -0
  52. package/dist/__tests__/providers-utils.test.d.ts +1 -0
  53. package/dist/__tests__/providers-utils.test.js +54 -0
  54. package/dist/__tests__/security.test.d.ts +1 -0
  55. package/dist/__tests__/security.test.js +22 -0
  56. package/dist/__tests__/sessions.test.d.ts +1 -0
  57. package/dist/__tests__/sessions.test.js +147 -0
  58. package/dist/__tests__/setup.test.d.ts +1 -0
  59. package/dist/__tests__/setup.test.js +114 -0
  60. package/dist/__tests__/skills.test.d.ts +1 -0
  61. package/dist/__tests__/skills.test.js +333 -0
  62. package/dist/__tests__/subagent.test.d.ts +1 -0
  63. package/dist/__tests__/subagent.test.js +240 -0
  64. package/dist/__tests__/telegram-utils.test.d.ts +1 -0
  65. package/dist/__tests__/telegram-utils.test.js +22 -0
  66. package/dist/__tests__/telegram.test.d.ts +1 -0
  67. package/dist/__tests__/telegram.test.js +42 -0
  68. package/dist/__tests__/token-efficiency.test.d.ts +1 -0
  69. package/dist/__tests__/token-efficiency.test.js +38 -0
  70. package/dist/__tests__/tool-guard.test.d.ts +1 -0
  71. package/dist/__tests__/tool-guard.test.js +105 -0
  72. package/dist/__tests__/tools.test.d.ts +1 -0
  73. package/dist/__tests__/tools.test.js +589 -0
  74. package/dist/__tests__/usage.test.d.ts +1 -0
  75. package/dist/__tests__/usage.test.js +197 -0
  76. package/dist/__tests__/voice.test.d.ts +1 -0
  77. package/dist/__tests__/voice.test.js +214 -0
  78. package/dist/agent.d.ts +24 -0
  79. package/dist/agent.js +269 -0
  80. package/dist/api.d.ts +3 -0
  81. package/dist/api.js +943 -0
  82. package/dist/audit.d.ts +26 -0
  83. package/dist/audit.js +121 -0
  84. package/dist/cache.d.ts +8 -0
  85. package/dist/cache.js +24 -0
  86. package/dist/channels/telegram/handlers.d.ts +41 -0
  87. package/dist/channels/telegram/handlers.js +498 -0
  88. package/dist/channels/telegram/index.d.ts +14 -0
  89. package/dist/channels/telegram/index.js +326 -0
  90. package/dist/channels/telegram/types.d.ts +26 -0
  91. package/dist/channels/telegram/types.js +31 -0
  92. package/dist/channels/telegram/utils.d.ts +25 -0
  93. package/dist/channels/telegram/utils.js +256 -0
  94. package/dist/channels.d.ts +11 -0
  95. package/dist/channels.js +118 -0
  96. package/dist/cli.d.ts +5 -0
  97. package/dist/cli.js +768 -0
  98. package/dist/code-agents/executor.d.ts +5 -0
  99. package/dist/code-agents/executor.js +463 -0
  100. package/dist/code-agents/index.d.ts +22 -0
  101. package/dist/code-agents/index.js +199 -0
  102. package/dist/code-agents/orchestrator.d.ts +23 -0
  103. package/dist/code-agents/orchestrator.js +403 -0
  104. package/dist/code-agents/parser.d.ts +21 -0
  105. package/dist/code-agents/parser.js +197 -0
  106. package/dist/code-agents/registry.d.ts +27 -0
  107. package/dist/code-agents/registry.js +147 -0
  108. package/dist/code-agents/types.d.ts +66 -0
  109. package/dist/code-agents/types.js +4 -0
  110. package/dist/code-agents/utils.d.ts +36 -0
  111. package/dist/code-agents/utils.js +236 -0
  112. package/dist/config.d.ts +19 -0
  113. package/dist/config.js +123 -0
  114. package/dist/cron.d.ts +49 -0
  115. package/dist/cron.js +400 -0
  116. package/dist/dashboard/assets/index-CZJCvMSN.js +65 -0
  117. package/dist/dashboard/assets/index-EAg6lqF5.css +1 -0
  118. package/dist/dashboard/favicon.svg +3 -0
  119. package/dist/dashboard/index.html +21 -0
  120. package/dist/dashboard-frontend.d.ts +7 -0
  121. package/dist/dashboard-frontend.js +86 -0
  122. package/dist/dashboard.d.ts +8 -0
  123. package/dist/dashboard.js +4071 -0
  124. package/dist/digests.d.ts +36 -0
  125. package/dist/digests.js +338 -0
  126. package/dist/discord.d.ts +8 -0
  127. package/dist/discord.js +828 -0
  128. package/dist/doctor/checks.d.ts +18 -0
  129. package/dist/doctor/checks.js +368 -0
  130. package/dist/doctor/formatters.d.ts +3 -0
  131. package/dist/doctor/formatters.js +44 -0
  132. package/dist/doctor/index.d.ts +8 -0
  133. package/dist/doctor/index.js +7 -0
  134. package/dist/doctor/runner.d.ts +3 -0
  135. package/dist/doctor/runner.js +109 -0
  136. package/dist/doctor/types.d.ts +20 -0
  137. package/dist/doctor/types.js +1 -0
  138. package/dist/exec-approval.d.ts +101 -0
  139. package/dist/exec-approval.js +432 -0
  140. package/dist/file-lock.d.ts +34 -0
  141. package/dist/file-lock.js +81 -0
  142. package/dist/gateway.d.ts +8 -0
  143. package/dist/gateway.js +114 -0
  144. package/dist/heartbeat.d.ts +4 -0
  145. package/dist/heartbeat.js +101 -0
  146. package/dist/index.d.ts +1 -0
  147. package/dist/index.js +75 -0
  148. package/dist/langfuse.d.ts +34 -0
  149. package/dist/langfuse.js +145 -0
  150. package/dist/mcp-context-a8c.d.ts +13 -0
  151. package/dist/mcp-context-a8c.js +34 -0
  152. package/dist/model-selection.d.ts +18 -0
  153. package/dist/model-selection.js +50 -0
  154. package/dist/orchestrator.d.ts +15 -0
  155. package/dist/orchestrator.js +676 -0
  156. package/dist/providers/anthropic.d.ts +7 -0
  157. package/dist/providers/anthropic.js +319 -0
  158. package/dist/providers/codex.d.ts +17 -0
  159. package/dist/providers/codex.js +508 -0
  160. package/dist/providers/content.d.ts +21 -0
  161. package/dist/providers/content.js +55 -0
  162. package/dist/providers/index.d.ts +13 -0
  163. package/dist/providers/index.js +138 -0
  164. package/dist/providers/observability.d.ts +19 -0
  165. package/dist/providers/observability.js +94 -0
  166. package/dist/providers/openai.d.ts +10 -0
  167. package/dist/providers/openai.js +310 -0
  168. package/dist/providers/tool-guard.d.ts +30 -0
  169. package/dist/providers/tool-guard.js +89 -0
  170. package/dist/providers/types.d.ts +34 -0
  171. package/dist/providers/types.js +2 -0
  172. package/dist/providers/utils.d.ts +65 -0
  173. package/dist/providers/utils.js +199 -0
  174. package/dist/security.d.ts +8 -0
  175. package/dist/security.js +113 -0
  176. package/dist/service.d.ts +8 -0
  177. package/dist/service.js +38 -0
  178. package/dist/sessions.d.ts +35 -0
  179. package/dist/sessions.js +142 -0
  180. package/dist/setup.d.ts +36 -0
  181. package/dist/setup.js +821 -0
  182. package/dist/skills-types.d.ts +65 -0
  183. package/dist/skills-types.js +2 -0
  184. package/dist/skills.d.ts +32 -0
  185. package/dist/skills.js +260 -0
  186. package/dist/subagent.d.ts +19 -0
  187. package/dist/subagent.js +376 -0
  188. package/dist/telegram.d.ts +2 -0
  189. package/dist/telegram.js +11 -0
  190. package/dist/tools/bash-tool.d.ts +3 -0
  191. package/dist/tools/bash-tool.js +59 -0
  192. package/dist/tools/browser-tool.d.ts +3 -0
  193. package/dist/tools/browser-tool.js +265 -0
  194. package/dist/tools/definitions.d.ts +432 -0
  195. package/dist/tools/definitions.js +181 -0
  196. package/dist/tools/execute-context.d.ts +26 -0
  197. package/dist/tools/execute-context.js +1 -0
  198. package/dist/tools/file-tools.d.ts +8 -0
  199. package/dist/tools/file-tools.js +67 -0
  200. package/dist/tools/path-utils.d.ts +1 -0
  201. package/dist/tools/path-utils.js +8 -0
  202. package/dist/tools.d.ts +24 -0
  203. package/dist/tools.js +281 -0
  204. package/dist/types.d.ts +259 -0
  205. package/dist/types.js +2 -0
  206. package/dist/usage.d.ts +76 -0
  207. package/dist/usage.js +150 -0
  208. package/dist/voice.d.ts +37 -0
  209. package/dist/voice.js +461 -0
  210. package/package.json +70 -0
  211. package/templates/AGENTS.md +38 -0
  212. package/templates/BOOT.md +23 -0
  213. package/templates/BOOTSTRAP.md +26 -0
  214. package/templates/HEARTBEAT.md +5 -0
  215. package/templates/IDENTITY.md +5 -0
  216. package/templates/MEMORY.md +24 -0
  217. package/templates/SOUL.md +92 -0
  218. package/templates/TOOLS.md +30 -0
  219. package/templates/USER.md +31 -0
@@ -0,0 +1,1227 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
2
+ import { mkdirSync, writeFileSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import Fastify from 'fastify';
6
+ // --- Build a temp directory to act as ~/.skimpyclaw ---
7
+ const TEST_ROOT = join(tmpdir(), `skimpyclaw-test-${Date.now()}`);
8
+ const SESSIONS_DIR = join(TEST_ROOT, 'sessions');
9
+ const LOGS_DIR = join(TEST_ROOT, 'logs');
10
+ const AGENT_DIR = join(TEST_ROOT, 'agents', 'default');
11
+ const MEMORY_DIR = join(AGENT_DIR, 'memory', 'logs');
12
+ const CONFIG_PATH = join(TEST_ROOT, 'config.json');
13
+ const TODO_PATH = join(TEST_ROOT, 'TODO.md');
14
+ const SKILLS_DIR = join(TEST_ROOT, 'skills');
15
+ const TEST_CONFIG = {
16
+ gateway: { port: 18790, mode: 'local' },
17
+ agents: {
18
+ default: 'default',
19
+ list: {
20
+ default: {
21
+ identity: { name: 'TestBot', emoji: '🤖' },
22
+ model: 'claude-sonnet-4-20250514',
23
+ thinking: 'none',
24
+ },
25
+ },
26
+ },
27
+ models: {
28
+ providers: {
29
+ anthropic: { apiKey: 'sk-ant-test-secret-key' },
30
+ },
31
+ aliases: { fast: 'claude-haiku-4-20250414', smart: 'claude-sonnet-4-20250514' },
32
+ },
33
+ channels: {
34
+ telegram: { enabled: false, token: 'tg-secret-token', allowFrom: [] },
35
+ discord: { enabled: false, token: 'discord-secret-token', allowFrom: [] },
36
+ },
37
+ cron: {
38
+ jobs: [
39
+ {
40
+ id: 'daily-check',
41
+ name: 'Daily Check',
42
+ schedule: { kind: 'cron', expr: '0 9 * * *', tz: 'America/Chicago' },
43
+ payload: { kind: 'agentTurn', message: 'Good morning' },
44
+ model: 'claude-sonnet-4-20250514',
45
+ },
46
+ ],
47
+ },
48
+ heartbeat: { intervalMs: 60000, prompt: 'heartbeat' },
49
+ dashboard: { token: 'test-dashboard-token-123' },
50
+ skills: { enabled: true, directory: SKILLS_DIR, entries: {} },
51
+ };
52
+ const AUTH_HEADERS = { authorization: 'Bearer test-dashboard-token-123' };
53
+ const mockCodeAgents = vi.hoisted(() => ({
54
+ list: [
55
+ {
56
+ id: 'ca-1',
57
+ agent: 'claude',
58
+ status: 'running',
59
+ task: 'test task',
60
+ startedAt: '2026-02-21T10:00:00Z',
61
+ },
62
+ ],
63
+ }));
64
+ const mockApprovalsState = vi.hoisted(() => ({
65
+ items: [
66
+ {
67
+ id: 'ap-1',
68
+ command: 'npm test',
69
+ status: 'pending',
70
+ tier: 1,
71
+ createdAt: '2026-02-21T10:00:00Z',
72
+ expiresAt: '2026-02-21T10:10:00Z',
73
+ },
74
+ {
75
+ id: 'ap-2',
76
+ command: 'rm -rf /tmp/x',
77
+ status: 'denied',
78
+ tier: 3,
79
+ createdAt: '2026-02-21T10:00:00Z',
80
+ expiresAt: '2026-02-21T10:10:00Z',
81
+ },
82
+ ],
83
+ }));
84
+ const mockDigestsState = vi.hoisted(() => ({
85
+ items: [
86
+ {
87
+ id: 'dg-1',
88
+ jobId: 'daily-check',
89
+ jobName: 'Daily Check',
90
+ createdAt: '2026-02-21T10:00:00Z',
91
+ summary: 'Digest summary',
92
+ articles: [
93
+ { id: 'a-1', title: 'Article 1', source: 'HN', url: 'https://example.com/1', read: false },
94
+ ],
95
+ },
96
+ ],
97
+ }));
98
+ // --- Mock modules before importing api.ts ---
99
+ // Mock config.ts
100
+ vi.mock('../config.js', () => ({
101
+ loadConfig: () => {
102
+ // Read from disk to get fresh state (mirrors real behavior)
103
+ const { readFileSync } = require('fs');
104
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
105
+ },
106
+ loadRawConfig: () => {
107
+ // Read from disk without env expansion
108
+ const { readFileSync } = require('fs');
109
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
110
+ },
111
+ getConfigPath: () => CONFIG_PATH,
112
+ saveConfig: (config) => {
113
+ const { writeFileSync } = require('fs');
114
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
115
+ },
116
+ getSessionsDir: () => SESSIONS_DIR,
117
+ getLogsDir: () => LOGS_DIR,
118
+ getAgentDir: (agentId) => join(TEST_ROOT, 'agents', agentId),
119
+ listMemoryFiles: (agentId) => {
120
+ const { existsSync, readdirSync, statSync } = require('fs');
121
+ const memDir = join(TEST_ROOT, 'agents', agentId, 'memory', 'logs');
122
+ if (!existsSync(memDir))
123
+ return [];
124
+ return readdirSync(memDir)
125
+ .filter((f) => f.endsWith('.md'))
126
+ .map((name) => {
127
+ const stat = statSync(join(memDir, name));
128
+ return { name, date: stat.mtime.toISOString(), size: stat.size };
129
+ });
130
+ },
131
+ readMemoryFile: (agentId, filename) => {
132
+ const { readFileSync, existsSync } = require('fs');
133
+ const { basename } = require('path');
134
+ if (filename.includes('..') || filename !== basename(filename)) {
135
+ throw new Error('Invalid filename');
136
+ }
137
+ const filePath = join(TEST_ROOT, 'agents', agentId, 'memory', 'logs', filename);
138
+ if (!existsSync(filePath)) {
139
+ throw new Error('File not found');
140
+ }
141
+ return readFileSync(filePath, 'utf-8');
142
+ },
143
+ }));
144
+ // Mock gateway.ts
145
+ let mockCurrentModel = 'claude-sonnet-4-20250514';
146
+ let mockLastMessage = undefined;
147
+ const mockSetGatewayConfig = vi.fn();
148
+ vi.mock('../gateway.js', () => ({
149
+ getCurrentModel: () => mockCurrentModel,
150
+ setCurrentModel: (m) => { mockCurrentModel = m; },
151
+ getLastMessage: () => mockLastMessage,
152
+ setGatewayConfig: (...args) => mockSetGatewayConfig(...args),
153
+ }));
154
+ // Mock cron.ts
155
+ vi.mock('../cron.js', () => ({
156
+ getCronJobs: () => [
157
+ { id: 'daily-check', name: 'Daily Check', nextRun: new Date('2026-02-06T09:00:00Z') },
158
+ ],
159
+ getCronJobDetails: (config) => config.cron.jobs.map((j) => ({
160
+ id: j.id,
161
+ name: j.name,
162
+ schedule: { kind: j.schedule.kind, expr: j.schedule.expr, tz: j.schedule.tz },
163
+ payload: { kind: j.payload.kind, message: j.payload.message },
164
+ model: j.model,
165
+ nextRun: new Date('2026-02-06T09:00:00Z'),
166
+ })),
167
+ runCronJob: async (id, config) => {
168
+ const job = config.cron.jobs.find((j) => j.id === id);
169
+ if (!job)
170
+ throw new Error(`Cron job not found: ${id}`);
171
+ },
172
+ initCron: (...args) => mockInitCron(...args),
173
+ }));
174
+ // Mock agent.ts
175
+ const mockInitProviders = vi.fn();
176
+ vi.mock('../agent.js', () => ({
177
+ TEMPLATE_FILES: ['SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md', 'BOOT.md', 'HEARTBEAT.md', 'MEMORY.md'],
178
+ getAgentTemplateContent: (agentId, name) => {
179
+ const TEMPLATE_FILES = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md', 'BOOT.md', 'HEARTBEAT.md', 'MEMORY.md'];
180
+ if (!TEMPLATE_FILES.includes(name))
181
+ return null;
182
+ const { existsSync, readFileSync } = require('fs');
183
+ const filePath = join(TEST_ROOT, 'agents', agentId, name);
184
+ if (!existsSync(filePath))
185
+ return null;
186
+ return readFileSync(filePath, 'utf-8');
187
+ },
188
+ saveAgentTemplate: (agentId, name, content) => {
189
+ const TEMPLATE_FILES = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md', 'BOOT.md', 'HEARTBEAT.md', 'MEMORY.md'];
190
+ if (!TEMPLATE_FILES.includes(name)) {
191
+ throw new Error(`Invalid template name: ${name}. Must be one of: ${TEMPLATE_FILES.join(', ')}`);
192
+ }
193
+ const { writeFileSync } = require('fs');
194
+ writeFileSync(join(TEST_ROOT, 'agents', agentId, name), content, 'utf-8');
195
+ },
196
+ initProviders: (...args) => mockInitProviders(...args),
197
+ runAgentTurn: vi.fn().mockResolvedValue('ok'),
198
+ }));
199
+ // Mock cron.ts additions for reload
200
+ const mockInitCron = vi.fn();
201
+ // Mock heartbeat.ts for reload
202
+ const mockInitHeartbeat = vi.fn();
203
+ const mockStopHeartbeat = vi.fn();
204
+ vi.mock('../heartbeat.js', () => ({
205
+ initHeartbeat: (...args) => mockInitHeartbeat(...args),
206
+ stopHeartbeat: () => mockStopHeartbeat(),
207
+ }));
208
+ // Mock channels.ts for reload
209
+ const mockInitActiveChannel = vi.fn().mockResolvedValue('telegram');
210
+ const mockStopActiveChannel = vi.fn().mockResolvedValue(undefined);
211
+ const mockStartActiveChannel = vi.fn().mockResolvedValue(undefined);
212
+ vi.mock('../channels.js', () => ({
213
+ initActiveChannel: (...args) => mockInitActiveChannel(...args),
214
+ stopActiveChannel: () => mockStopActiveChannel(),
215
+ startActiveChannel: () => mockStartActiveChannel(),
216
+ getActiveChannelId: () => 'telegram',
217
+ sendActiveChannelProactiveMessage: vi.fn().mockResolvedValue(true),
218
+ }));
219
+ // Mock tools.ts for reload
220
+ const mockSetCodeAgentConfig = vi.fn();
221
+ vi.mock('../tools.js', () => ({
222
+ setCodeAgentConfig: (...args) => mockSetCodeAgentConfig(...args),
223
+ getAllCodeAgents: () => mockCodeAgents.list,
224
+ getCodeAgent: (id) => mockCodeAgents.list.find((a) => a.id === id) || null,
225
+ cancelCodeAgent: (id) => {
226
+ const agent = mockCodeAgents.list.find((a) => a.id === id);
227
+ if (!agent)
228
+ return null;
229
+ agent.status = 'cancelled';
230
+ return agent;
231
+ },
232
+ }));
233
+ vi.mock('../exec-approval.js', () => ({
234
+ listApprovals: (opts) => {
235
+ const all = mockApprovalsState.items;
236
+ if (opts?.includeResolved)
237
+ return all.slice(0, opts.limit || all.length);
238
+ return all.filter((a) => a.status === 'pending');
239
+ },
240
+ getApproval: (id) => mockApprovalsState.items.find((a) => a.id === id) || null,
241
+ approveRequest: (id) => {
242
+ const item = mockApprovalsState.items.find((a) => a.id === id);
243
+ if (!item || item.status !== 'pending')
244
+ return false;
245
+ item.status = 'approved';
246
+ return true;
247
+ },
248
+ denyRequest: (id) => {
249
+ const item = mockApprovalsState.items.find((a) => a.id === id);
250
+ if (!item || item.status !== 'pending')
251
+ return false;
252
+ item.status = 'denied';
253
+ return true;
254
+ },
255
+ }));
256
+ vi.mock('../digests.js', () => ({
257
+ getDigests: () => mockDigestsState.items.map((d) => ({
258
+ id: d.id,
259
+ jobId: d.jobId,
260
+ jobName: d.jobName,
261
+ createdAt: d.createdAt,
262
+ articleCount: d.articles.length,
263
+ preview: d.articles.slice(0, 3).map((a) => a.title),
264
+ })),
265
+ getDigest: (id) => mockDigestsState.items.find((d) => d.id === id) || null,
266
+ deleteDigest: (id) => {
267
+ const idx = mockDigestsState.items.findIndex((d) => d.id === id);
268
+ if (idx < 0)
269
+ return false;
270
+ mockDigestsState.items.splice(idx, 1);
271
+ return true;
272
+ },
273
+ updateArticleReadStatus: (digestId, articleId, read) => {
274
+ const digest = mockDigestsState.items.find((d) => d.id === digestId);
275
+ if (!digest)
276
+ return false;
277
+ const article = digest.articles.find((a) => a.id === articleId);
278
+ if (!article)
279
+ return false;
280
+ article.read = read;
281
+ return true;
282
+ },
283
+ }));
284
+ // Mock security.ts - only need redactSecrets
285
+ function redactSecretsImpl(obj) {
286
+ const SECRET_KEYS = ['apikey', 'token', 'password', 'secret', 'key'];
287
+ const redacted = {};
288
+ for (const [key, value] of Object.entries(obj)) {
289
+ if (SECRET_KEYS.some(s => key.toLowerCase().includes(s))) {
290
+ redacted[key] = '[REDACTED]';
291
+ }
292
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
293
+ redacted[key] = redactSecretsImpl(value);
294
+ }
295
+ else {
296
+ redacted[key] = value;
297
+ }
298
+ }
299
+ return redacted;
300
+ }
301
+ vi.mock('../security.js', () => ({
302
+ redactSecrets: redactSecretsImpl,
303
+ }));
304
+ // Mock doctor/runner.ts for health endpoint
305
+ vi.mock('../doctor/runner.js', () => ({
306
+ runDoctor: async () => ({
307
+ report: {
308
+ ok: true,
309
+ exitCode: 0,
310
+ startedAt: new Date().toISOString(),
311
+ finishedAt: new Date().toISOString(),
312
+ checks: [
313
+ { name: 'node_version', category: 'environment', ok: true, detail: 'v20.11.0' },
314
+ { name: 'config_json_valid', category: 'configuration', ok: true, detail: 'ok' },
315
+ ],
316
+ },
317
+ exitCode: 0,
318
+ }),
319
+ }));
320
+ // Mock usage.ts
321
+ vi.mock('../usage.js', () => ({
322
+ getUsageSummary: () => ({
323
+ today: { totalCost: 0.42, totalInputTokens: 10000, totalOutputTokens: 5000, totalCalls: 3, byModel: { 'claude-sonnet-4-5': { calls: 3, inputTokens: 10000, outputTokens: 5000, cost: 0.42 } } },
324
+ week: { totalCost: 2.50, totalInputTokens: 50000, totalOutputTokens: 25000, totalCalls: 15, byModel: {} },
325
+ month: { totalCost: 8.00, totalInputTokens: 200000, totalOutputTokens: 100000, totalCalls: 50, byModel: {} },
326
+ }),
327
+ readUsageRecords: (opts) => ({
328
+ records: [
329
+ { id: 'test-1', timestamp: '2026-02-21T10:00:00Z', model: 'claude-sonnet-4-5', provider: 'anthropic', inputTokens: 1000, outputTokens: 500, totalTokens: 1500, inputCost: 0.003, outputCost: 0.0075, totalCost: 0.0105, trigger: 'telegram' },
330
+ ],
331
+ total: 1,
332
+ }),
333
+ }));
334
+ import { registerDashboardAPI } from '../api.js';
335
+ let app;
336
+ /** Helper that injects with auth header by default */
337
+ function inject(opts) {
338
+ opts.headers = { ...AUTH_HEADERS, ...(opts.headers || {}) };
339
+ return app.inject(opts);
340
+ }
341
+ // --- Setup & Teardown ---
342
+ beforeAll(async () => {
343
+ process.env.SKIMPYCLAW_TODO_PATH = TODO_PATH;
344
+ // Create directory structure
345
+ mkdirSync(SESSIONS_DIR, { recursive: true });
346
+ mkdirSync(LOGS_DIR, { recursive: true });
347
+ mkdirSync(AGENT_DIR, { recursive: true });
348
+ mkdirSync(MEMORY_DIR, { recursive: true });
349
+ // Seed session files
350
+ writeFileSync(join(SESSIONS_DIR, 'session-1.json'), JSON.stringify({
351
+ id: 'session-1',
352
+ agentId: 'default',
353
+ model: 'claude-sonnet-4-20250514',
354
+ createdAt: '2026-02-01T10:00:00Z',
355
+ updatedAt: '2026-02-01T11:00:00Z',
356
+ turns: [
357
+ { role: 'user', content: 'Hello', timestamp: '2026-02-01T10:00:00Z' },
358
+ { role: 'assistant', content: 'Hi there', timestamp: '2026-02-01T10:01:00Z' },
359
+ ],
360
+ }));
361
+ writeFileSync(join(SESSIONS_DIR, 'session-2.json'), JSON.stringify({
362
+ id: 'session-2',
363
+ agentId: 'default',
364
+ model: 'claude-sonnet-4-20250514',
365
+ createdAt: '2026-02-02T10:00:00Z',
366
+ updatedAt: '2026-02-02T11:00:00Z',
367
+ turns: [],
368
+ }));
369
+ // Seed memory files
370
+ writeFileSync(join(MEMORY_DIR, '2026-02-01.md'), '# Memory for Feb 1\nSome notes.');
371
+ writeFileSync(join(MEMORY_DIR, '2026-02-02.md'), '# Memory for Feb 2\nMore notes.');
372
+ // Seed MEMORY.md (curated)
373
+ writeFileSync(join(AGENT_DIR, 'MEMORY.md'), '# Curated Memory\nKey insights.');
374
+ // Seed template files
375
+ writeFileSync(join(AGENT_DIR, 'SOUL.md'), 'You are a helpful assistant.');
376
+ writeFileSync(join(AGENT_DIR, 'IDENTITY.md'), 'Name: TestBot');
377
+ // Seed skills
378
+ mkdirSync(join(SKILLS_DIR, 'test-skill'), { recursive: true });
379
+ writeFileSync(join(SKILLS_DIR, 'test-skill', 'SKILL.md'), [
380
+ '---',
381
+ 'name: test-skill',
382
+ 'description: A test skill for API tests',
383
+ 'emoji: "🧪"',
384
+ 'tags: ["test"]',
385
+ 'priority: 10',
386
+ '---',
387
+ '',
388
+ '# Test Skill',
389
+ '',
390
+ 'This is a test skill body.',
391
+ ].join('\n'));
392
+ mkdirSync(join(SKILLS_DIR, 'disabled-skill'), { recursive: true });
393
+ writeFileSync(join(SKILLS_DIR, 'disabled-skill', 'SKILL.md'), [
394
+ '---',
395
+ 'name: disabled-skill',
396
+ 'description: A disabled test skill',
397
+ 'enabled: false',
398
+ '---',
399
+ '',
400
+ 'Disabled skill body.',
401
+ ].join('\n'));
402
+ // Seed log files
403
+ writeFileSync(join(LOGS_DIR, 'app.log'), 'line1\nline2\nline3\nline4\nline5\n');
404
+ writeFileSync(join(LOGS_DIR, 'error.log'), 'error1\nerror2\n');
405
+ // Seed TODO file
406
+ writeFileSync(TODO_PATH, [
407
+ '# TODO',
408
+ '',
409
+ '- [ ] Ship dashboard todo tracking',
410
+ '- [x] Existing done task',
411
+ '- [ ] Add e2e tests',
412
+ '',
413
+ ].join('\n'));
414
+ // Seed config file
415
+ writeFileSync(CONFIG_PATH, JSON.stringify(TEST_CONFIG, null, 2));
416
+ // Create Fastify app and register routes
417
+ app = Fastify();
418
+ registerDashboardAPI(app, TEST_CONFIG);
419
+ await app.ready();
420
+ });
421
+ afterAll(async () => {
422
+ await app.close();
423
+ delete process.env.SKIMPYCLAW_TODO_PATH;
424
+ rmSync(TEST_ROOT, { recursive: true, force: true });
425
+ });
426
+ beforeEach(() => {
427
+ // Reset mutable state
428
+ mockCurrentModel = 'claude-sonnet-4-20250514';
429
+ mockLastMessage = undefined;
430
+ mockCodeAgents.list = [
431
+ {
432
+ id: 'ca-1',
433
+ agent: 'claude',
434
+ status: 'running',
435
+ task: 'test task',
436
+ startedAt: '2026-02-21T10:00:00Z',
437
+ },
438
+ ];
439
+ mockApprovalsState.items = [
440
+ {
441
+ id: 'ap-1',
442
+ command: 'npm test',
443
+ status: 'pending',
444
+ tier: 1,
445
+ createdAt: '2026-02-21T10:00:00Z',
446
+ expiresAt: '2026-02-21T10:10:00Z',
447
+ },
448
+ {
449
+ id: 'ap-2',
450
+ command: 'rm -rf /tmp/x',
451
+ status: 'denied',
452
+ tier: 3,
453
+ createdAt: '2026-02-21T10:00:00Z',
454
+ expiresAt: '2026-02-21T10:10:00Z',
455
+ },
456
+ ];
457
+ mockDigestsState.items = [
458
+ {
459
+ id: 'dg-1',
460
+ jobId: 'daily-check',
461
+ jobName: 'Daily Check',
462
+ createdAt: '2026-02-21T10:00:00Z',
463
+ summary: 'Digest summary',
464
+ articles: [
465
+ { id: 'a-1', title: 'Article 1', source: 'HN', url: 'https://example.com/1', read: false },
466
+ ],
467
+ },
468
+ ];
469
+ // Re-seed config in case a test modified it
470
+ writeFileSync(CONFIG_PATH, JSON.stringify(TEST_CONFIG, null, 2));
471
+ // Re-seed TODO file in case a test modified it
472
+ writeFileSync(TODO_PATH, [
473
+ '# TODO',
474
+ '',
475
+ '- [ ] Ship dashboard todo tracking',
476
+ '- [x] Existing done task',
477
+ '- [ ] Add e2e tests',
478
+ '',
479
+ ].join('\n'));
480
+ });
481
+ // ===== TESTS =====
482
+ describe('Status endpoint', () => {
483
+ it('GET /api/dashboard/status returns correct shape', async () => {
484
+ const res = await inject({ method: 'GET', url: '/api/dashboard/status' });
485
+ expect(res.statusCode).toBe(200);
486
+ const body = res.json();
487
+ expect(body).toHaveProperty('uptime');
488
+ expect(body).toHaveProperty('model');
489
+ expect(body).toHaveProperty('agent', 'default');
490
+ expect(body).toHaveProperty('cronJobs');
491
+ expect(Array.isArray(body.cronJobs)).toBe(true);
492
+ expect(body.cronJobs[0]).toHaveProperty('id', 'daily-check');
493
+ });
494
+ });
495
+ describe('Sessions endpoints', () => {
496
+ it('GET /api/dashboard/sessions lists sessions', async () => {
497
+ const res = await inject({ method: 'GET', url: '/api/dashboard/sessions' });
498
+ expect(res.statusCode).toBe(200);
499
+ const body = res.json();
500
+ expect(body.sessions).toHaveLength(2);
501
+ // Should be sorted newest first
502
+ expect(body.sessions[0].id).toBe('session-2');
503
+ expect(body.sessions[1].id).toBe('session-1');
504
+ expect(body.sessions[1]).toHaveProperty('turnCount', 2);
505
+ });
506
+ it('GET /api/dashboard/sessions/:id returns specific session', async () => {
507
+ const res = await inject({ method: 'GET', url: '/api/dashboard/sessions/session-1' });
508
+ expect(res.statusCode).toBe(200);
509
+ const body = res.json();
510
+ expect(body.session.id).toBe('session-1');
511
+ expect(body.session.turns).toHaveLength(2);
512
+ });
513
+ it('GET /api/dashboard/sessions/:id returns 404 for missing session', async () => {
514
+ const res = await inject({ method: 'GET', url: '/api/dashboard/sessions/nonexistent' });
515
+ expect(res.statusCode).toBe(404);
516
+ expect(res.json()).toHaveProperty('error', 'Session not found');
517
+ });
518
+ it('GET /api/dashboard/sessions/:id returns 400 for path traversal', async () => {
519
+ const res = await inject({ method: 'GET', url: '/api/dashboard/sessions/..%2F..%2Fetc%2Fpasswd' });
520
+ expect(res.statusCode).toBe(400);
521
+ expect(res.json()).toHaveProperty('error', 'Invalid session id');
522
+ });
523
+ });
524
+ describe('Memory endpoints', () => {
525
+ it('GET /api/dashboard/memory/:agentId lists memory files', async () => {
526
+ const res = await inject({ method: 'GET', url: '/api/dashboard/memory/default' });
527
+ expect(res.statusCode).toBe(200);
528
+ const body = res.json();
529
+ expect(body.files).toHaveLength(2);
530
+ expect(body.files[0]).toHaveProperty('name');
531
+ expect(body.files[0]).toHaveProperty('size');
532
+ });
533
+ it('GET /api/dashboard/memory/:agentId/:filename returns file content', async () => {
534
+ const res = await inject({ method: 'GET', url: '/api/dashboard/memory/default/2026-02-01.md' });
535
+ expect(res.statusCode).toBe(200);
536
+ const body = res.json();
537
+ expect(body.content).toContain('Memory for Feb 1');
538
+ });
539
+ it('GET /api/dashboard/memory/:agentId/curated returns MEMORY.md', async () => {
540
+ const res = await inject({ method: 'GET', url: '/api/dashboard/memory/default/curated' });
541
+ expect(res.statusCode).toBe(200);
542
+ const body = res.json();
543
+ expect(body.content).toContain('Curated Memory');
544
+ });
545
+ it('GET /api/dashboard/memory/:agentId/:filename returns 400 for path traversal', async () => {
546
+ const res = await inject({ method: 'GET', url: '/api/dashboard/memory/default/..%2F..%2Fetc%2Fpasswd' });
547
+ expect(res.statusCode).toBe(400);
548
+ expect(res.json()).toHaveProperty('error', 'Invalid filename');
549
+ });
550
+ it('GET /api/dashboard/memory/:agentId/:filename returns 404 for missing file', async () => {
551
+ const res = await inject({ method: 'GET', url: '/api/dashboard/memory/default/nonexistent.md' });
552
+ expect(res.statusCode).toBe(404);
553
+ });
554
+ it('returns empty list for agent with no memory', async () => {
555
+ const res = await inject({ method: 'GET', url: '/api/dashboard/memory/nonexistent-agent' });
556
+ expect(res.statusCode).toBe(200);
557
+ expect(res.json().files).toEqual([]);
558
+ });
559
+ });
560
+ describe('Cron endpoints', () => {
561
+ it('GET /api/dashboard/cron lists jobs', async () => {
562
+ const res = await inject({ method: 'GET', url: '/api/dashboard/cron' });
563
+ expect(res.statusCode).toBe(200);
564
+ const body = res.json();
565
+ expect(body.jobs).toHaveLength(1);
566
+ expect(body.jobs[0]).toHaveProperty('id', 'daily-check');
567
+ expect(body.jobs[0]).toHaveProperty('name', 'Daily Check');
568
+ expect(body.jobs[0]).toHaveProperty('schedule');
569
+ expect(body.jobs[0]).toHaveProperty('payload');
570
+ });
571
+ it('POST /api/dashboard/cron/:id/run triggers job', async () => {
572
+ const res = await inject({ method: 'POST', url: '/api/dashboard/cron/daily-check/run' });
573
+ expect(res.statusCode).toBe(200);
574
+ expect(res.json()).toEqual({ status: 'triggered', id: 'daily-check' });
575
+ });
576
+ it('POST /api/dashboard/cron/:id/run returns 404 for missing job', async () => {
577
+ const res = await inject({ method: 'POST', url: '/api/dashboard/cron/nonexistent/run' });
578
+ expect(res.statusCode).toBe(404);
579
+ expect(res.json()).toHaveProperty('error');
580
+ });
581
+ });
582
+ describe('Model endpoints', () => {
583
+ it('GET /api/dashboard/model returns current model and aliases', async () => {
584
+ const res = await inject({ method: 'GET', url: '/api/dashboard/model' });
585
+ expect(res.statusCode).toBe(200);
586
+ const body = res.json();
587
+ expect(body).toHaveProperty('current', 'claude-sonnet-4-20250514');
588
+ expect(body).toHaveProperty('aliases');
589
+ expect(body.aliases).toHaveProperty('fast');
590
+ expect(body.aliases).toHaveProperty('smart');
591
+ expect(body).toHaveProperty('agents');
592
+ expect(body.agents.default).toBe('claude-sonnet-4-20250514');
593
+ });
594
+ it('POST /api/dashboard/model switches model', async () => {
595
+ const res = await inject({
596
+ method: 'POST',
597
+ url: '/api/dashboard/model',
598
+ payload: { model: 'claude-haiku-4-20250414' },
599
+ });
600
+ expect(res.statusCode).toBe(200);
601
+ expect(res.json()).toEqual({ model: 'claude-haiku-4-20250414' });
602
+ expect(mockCurrentModel).toBe('claude-haiku-4-20250414');
603
+ });
604
+ it('POST /api/dashboard/model resolves aliases before switching', async () => {
605
+ const res = await inject({
606
+ method: 'POST',
607
+ url: '/api/dashboard/model',
608
+ payload: { model: 'fast' },
609
+ });
610
+ expect(res.statusCode).toBe(200);
611
+ expect(res.json()).toEqual({ model: 'claude-haiku-4-20250414' });
612
+ expect(mockCurrentModel).toBe('claude-haiku-4-20250414');
613
+ });
614
+ it('POST /api/dashboard/model rejects empty model', async () => {
615
+ const res = await inject({
616
+ method: 'POST',
617
+ url: '/api/dashboard/model',
618
+ payload: {},
619
+ });
620
+ expect(res.statusCode).toBe(400);
621
+ expect(res.json()).toHaveProperty('error', 'model required');
622
+ });
623
+ it('POST /api/dashboard/model rejects unknown aliases', async () => {
624
+ const res = await inject({
625
+ method: 'POST',
626
+ url: '/api/dashboard/model',
627
+ payload: { model: 'unknown_alias' },
628
+ });
629
+ expect(res.statusCode).toBe(400);
630
+ expect(res.json()).toHaveProperty('error', 'Unknown model alias: "unknown_alias"');
631
+ });
632
+ it('POST /api/dashboard/model rejects malformed provider/model selections', async () => {
633
+ const res = await inject({
634
+ method: 'POST',
635
+ url: '/api/dashboard/model',
636
+ payload: { model: 'anthropic/' },
637
+ });
638
+ expect(res.statusCode).toBe(400);
639
+ expect(res.json()).toHaveProperty('error', 'Invalid model selection: "anthropic/". Use alias, provider/model, or model-id.');
640
+ });
641
+ });
642
+ describe('Templates endpoints', () => {
643
+ it('GET /api/dashboard/templates/:agentId lists templates', async () => {
644
+ const res = await inject({ method: 'GET', url: '/api/dashboard/templates/default' });
645
+ expect(res.statusCode).toBe(200);
646
+ const body = res.json();
647
+ expect(body.templates).toHaveLength(7);
648
+ const soul = body.templates.find((t) => t.name === 'SOUL.md');
649
+ expect(soul).toBeDefined();
650
+ expect(soul.exists).toBe(true);
651
+ expect(soul.size).toBeGreaterThan(0);
652
+ const tools = body.templates.find((t) => t.name === 'TOOLS.md');
653
+ expect(tools.exists).toBe(false);
654
+ });
655
+ it('GET /api/dashboard/templates/:agentId/:name returns template content', async () => {
656
+ const res = await inject({ method: 'GET', url: '/api/dashboard/templates/default/SOUL.md' });
657
+ expect(res.statusCode).toBe(200);
658
+ const body = res.json();
659
+ expect(body.name).toBe('SOUL.md');
660
+ expect(body.content).toBe('You are a helpful assistant.');
661
+ });
662
+ it('GET /api/dashboard/templates/:agentId/:name returns 404 for missing template', async () => {
663
+ const res = await inject({ method: 'GET', url: '/api/dashboard/templates/default/TOOLS.md' });
664
+ expect(res.statusCode).toBe(404);
665
+ });
666
+ it('GET /api/dashboard/templates/:agentId/:name returns 404 for invalid template name', async () => {
667
+ const res = await inject({ method: 'GET', url: '/api/dashboard/templates/default/EVIL.md' });
668
+ expect(res.statusCode).toBe(404);
669
+ });
670
+ it('PUT /api/dashboard/templates/:agentId/:name saves template', async () => {
671
+ const res = await inject({
672
+ method: 'PUT',
673
+ url: '/api/dashboard/templates/default/TOOLS.md',
674
+ payload: { content: 'New tools content' },
675
+ });
676
+ expect(res.statusCode).toBe(200);
677
+ expect(res.json()).toEqual({ saved: true });
678
+ });
679
+ it('PUT /api/dashboard/templates/:agentId/:name rejects invalid template name', async () => {
680
+ const res = await inject({
681
+ method: 'PUT',
682
+ url: '/api/dashboard/templates/default/EVIL.md',
683
+ payload: { content: 'hack' },
684
+ });
685
+ expect(res.statusCode).toBe(400);
686
+ expect(res.json()).toHaveProperty('error');
687
+ });
688
+ it('PUT /api/dashboard/templates/:agentId/:name rejects missing content', async () => {
689
+ const res = await inject({
690
+ method: 'PUT',
691
+ url: '/api/dashboard/templates/default/SOUL.md',
692
+ payload: {},
693
+ });
694
+ expect(res.statusCode).toBe(400);
695
+ expect(res.json()).toHaveProperty('error', 'content required');
696
+ });
697
+ });
698
+ describe('Logs endpoints', () => {
699
+ it('GET /api/dashboard/logs lists log files', async () => {
700
+ const res = await inject({ method: 'GET', url: '/api/dashboard/logs' });
701
+ expect(res.statusCode).toBe(200);
702
+ const body = res.json();
703
+ expect(body.files).toHaveLength(2);
704
+ expect(body.files[0]).toHaveProperty('name');
705
+ expect(body.files[0]).toHaveProperty('size');
706
+ expect(body.files[0]).toHaveProperty('modified');
707
+ });
708
+ it('GET /api/dashboard/logs/:filename returns log content', async () => {
709
+ const res = await inject({ method: 'GET', url: '/api/dashboard/logs/app.log' });
710
+ expect(res.statusCode).toBe(200);
711
+ const body = res.json();
712
+ expect(body.content).toContain('line1');
713
+ expect(body).toHaveProperty('lines');
714
+ });
715
+ it('GET /api/dashboard/logs/:filename?tail=2 returns last 2 lines', async () => {
716
+ const res = await inject({ method: 'GET', url: '/api/dashboard/logs/app.log?tail=2' });
717
+ expect(res.statusCode).toBe(200);
718
+ const body = res.json();
719
+ // "line1\nline2\nline3\nline4\nline5\n" split by \n = ["line1","line2","line3","line4","line5",""]
720
+ // tail 2 = ["line5", ""]
721
+ const lines = body.content.split('\n');
722
+ expect(lines.length).toBeLessThanOrEqual(2);
723
+ });
724
+ it('GET /api/dashboard/logs/:filename returns 400 for path traversal', async () => {
725
+ const res = await inject({ method: 'GET', url: '/api/dashboard/logs/..%2F..%2Fetc%2Fpasswd' });
726
+ expect(res.statusCode).toBe(400);
727
+ expect(res.json()).toHaveProperty('error', 'Invalid filename');
728
+ });
729
+ it('GET /api/dashboard/logs/:filename returns 404 for missing file', async () => {
730
+ const res = await inject({ method: 'GET', url: '/api/dashboard/logs/nonexistent.log' });
731
+ expect(res.statusCode).toBe(404);
732
+ });
733
+ });
734
+ describe('Config endpoints', () => {
735
+ it('GET /api/dashboard/config returns redacted config', async () => {
736
+ const res = await inject({ method: 'GET', url: '/api/dashboard/config' });
737
+ expect(res.statusCode).toBe(200);
738
+ const body = res.json();
739
+ expect(body).toHaveProperty('config');
740
+ // Check that secrets are redacted
741
+ const anthropicProvider = body.config.models?.providers?.anthropic;
742
+ if (anthropicProvider) {
743
+ expect(anthropicProvider.apiKey).toBe('[REDACTED]');
744
+ }
745
+ const telegram = body.config.channels?.telegram;
746
+ if (telegram) {
747
+ expect(telegram.token).toBe('[REDACTED]');
748
+ }
749
+ const discord = body.config.channels?.discord;
750
+ if (discord) {
751
+ expect(discord.token).toBe('[REDACTED]');
752
+ }
753
+ });
754
+ it('PUT /api/dashboard/config saves valid config', async () => {
755
+ const validConfig = { ...TEST_CONFIG };
756
+ const res = await inject({
757
+ method: 'PUT',
758
+ url: '/api/dashboard/config',
759
+ payload: { config: validConfig },
760
+ });
761
+ expect(res.statusCode).toBe(200);
762
+ expect(res.json()).toEqual({ saved: true, restartRequired: true });
763
+ });
764
+ it('PUT /api/dashboard/config rejects missing config', async () => {
765
+ const res = await inject({
766
+ method: 'PUT',
767
+ url: '/api/dashboard/config',
768
+ payload: {},
769
+ });
770
+ expect(res.statusCode).toBe(400);
771
+ expect(res.json()).toHaveProperty('error', 'config object required');
772
+ });
773
+ it('PUT /api/dashboard/config rejects config missing required sections', async () => {
774
+ const res = await inject({
775
+ method: 'PUT',
776
+ url: '/api/dashboard/config',
777
+ payload: { config: { gateway: { port: 1 } } },
778
+ });
779
+ expect(res.statusCode).toBe(400);
780
+ expect(res.json().error).toContain('Missing required config sections');
781
+ });
782
+ it('PUT /api/dashboard/config preserves real secrets when [REDACTED] values are sent', async () => {
783
+ // Send config with redacted secrets
784
+ const configWithRedacted = JSON.parse(JSON.stringify(TEST_CONFIG));
785
+ configWithRedacted.models.providers.anthropic.apiKey = '[REDACTED]';
786
+ configWithRedacted.channels.telegram.token = '[REDACTED]';
787
+ configWithRedacted.channels.discord.token = '[REDACTED]';
788
+ const res = await inject({
789
+ method: 'PUT',
790
+ url: '/api/dashboard/config',
791
+ payload: { config: configWithRedacted },
792
+ });
793
+ expect(res.statusCode).toBe(200);
794
+ // Read saved config and verify secrets were preserved
795
+ const { readFileSync } = require('fs');
796
+ const saved = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
797
+ expect(saved.models.providers.anthropic.apiKey).toBe('sk-ant-test-secret-key');
798
+ expect(saved.channels.telegram.token).toBe('tg-secret-token');
799
+ expect(saved.channels.discord.token).toBe('discord-secret-token');
800
+ });
801
+ });
802
+ describe('TODO endpoints', () => {
803
+ it('GET /api/dashboard/todos returns checklist items and summary', async () => {
804
+ const res = await inject({ method: 'GET', url: '/api/dashboard/todos' });
805
+ expect(res.statusCode).toBe(200);
806
+ const body = res.json();
807
+ expect(body.total).toBe(3);
808
+ expect(body.completed).toBe(1);
809
+ expect(body.remaining).toBe(2);
810
+ expect(body.items).toHaveLength(3);
811
+ expect(body.items[0]).toMatchObject({
812
+ id: 0,
813
+ text: 'Ship dashboard todo tracking',
814
+ completed: false,
815
+ });
816
+ });
817
+ it('PUT /api/dashboard/todos/:id toggles completion state', async () => {
818
+ const res = await inject({
819
+ method: 'PUT',
820
+ url: '/api/dashboard/todos/0',
821
+ payload: { completed: true },
822
+ });
823
+ expect(res.statusCode).toBe(200);
824
+ const body = res.json();
825
+ expect(body.updated).toBe(true);
826
+ expect(body.item).toMatchObject({ id: 0, completed: true });
827
+ expect(body.completed).toBe(2);
828
+ const verify = await inject({ method: 'GET', url: '/api/dashboard/todos' });
829
+ const verifyBody = verify.json();
830
+ expect(verifyBody.items.find((i) => i.id === 0).completed).toBe(true);
831
+ });
832
+ it('PUT /api/dashboard/todos/:id returns 404 for missing item', async () => {
833
+ const res = await inject({
834
+ method: 'PUT',
835
+ url: '/api/dashboard/todos/999',
836
+ payload: { completed: true },
837
+ });
838
+ expect(res.statusCode).toBe(404);
839
+ expect(res.json()).toHaveProperty('error', 'TODO item not found');
840
+ });
841
+ });
842
+ describe('Authentication', () => {
843
+ it('returns 401 when no auth header is provided', async () => {
844
+ const res = await app.inject({ method: 'GET', url: '/api/dashboard/status' });
845
+ expect(res.statusCode).toBe(401);
846
+ expect(res.json()).toHaveProperty('error');
847
+ });
848
+ it('returns 401 when wrong token is provided', async () => {
849
+ const res = await app.inject({
850
+ method: 'GET',
851
+ url: '/api/dashboard/status',
852
+ headers: { authorization: 'Bearer wrong-token' },
853
+ });
854
+ expect(res.statusCode).toBe(401);
855
+ expect(res.json()).toHaveProperty('error');
856
+ });
857
+ it('returns 401 when auth header format is wrong', async () => {
858
+ const res = await app.inject({
859
+ method: 'GET',
860
+ url: '/api/dashboard/status',
861
+ headers: { authorization: 'Basic dGVzdDp0ZXN0' },
862
+ });
863
+ expect(res.statusCode).toBe(401);
864
+ expect(res.json()).toHaveProperty('error');
865
+ });
866
+ it('returns 200 with correct token', async () => {
867
+ const res = await inject({ method: 'GET', url: '/api/dashboard/status' });
868
+ expect(res.statusCode).toBe(200);
869
+ });
870
+ });
871
+ describe('Health endpoint', () => {
872
+ it('GET /api/dashboard/health returns doctor check results', async () => {
873
+ const res = await inject({ method: 'GET', url: '/api/dashboard/health' });
874
+ expect(res.statusCode).toBe(200);
875
+ const body = res.json();
876
+ expect(body).toHaveProperty('ok', true);
877
+ expect(body).toHaveProperty('checks');
878
+ expect(Array.isArray(body.checks)).toBe(true);
879
+ expect(body.checks.length).toBeGreaterThan(0);
880
+ expect(body.checks[0]).toHaveProperty('name');
881
+ expect(body.checks[0]).toHaveProperty('ok');
882
+ expect(body.checks[0]).toHaveProperty('detail');
883
+ });
884
+ it('GET /api/dashboard/health returns features summary', async () => {
885
+ const res = await inject({ method: 'GET', url: '/api/dashboard/health' });
886
+ expect(res.statusCode).toBe(200);
887
+ const body = res.json();
888
+ expect(body).toHaveProperty('features');
889
+ expect(body.features).toHaveProperty('telegram');
890
+ expect(body.features).toHaveProperty('discord');
891
+ expect(body.features).toHaveProperty('browser');
892
+ expect(body.features).toHaveProperty('voice');
893
+ });
894
+ it('GET /api/dashboard/health returns env var status', async () => {
895
+ const res = await inject({ method: 'GET', url: '/api/dashboard/health' });
896
+ expect(res.statusCode).toBe(200);
897
+ const body = res.json();
898
+ expect(body).toHaveProperty('envVars');
899
+ expect(Array.isArray(body.envVars)).toBe(true);
900
+ });
901
+ });
902
+ describe('Doctor endpoint', () => {
903
+ it('GET /api/dashboard/doctor returns full report', async () => {
904
+ const res = await inject({ method: 'GET', url: '/api/dashboard/doctor' });
905
+ expect(res.statusCode).toBe(200);
906
+ const body = res.json();
907
+ expect(body).toHaveProperty('report');
908
+ expect(body.report).toHaveProperty('ok', true);
909
+ expect(body.report).toHaveProperty('exitCode', 0);
910
+ expect(body.report).toHaveProperty('startedAt');
911
+ expect(body.report).toHaveProperty('finishedAt');
912
+ expect(body.report).toHaveProperty('checks');
913
+ expect(Array.isArray(body.report.checks)).toBe(true);
914
+ expect(body.report.checks.length).toBeGreaterThan(0);
915
+ });
916
+ it('GET /api/dashboard/doctor checks include category', async () => {
917
+ const res = await inject({ method: 'GET', url: '/api/dashboard/doctor' });
918
+ const body = res.json();
919
+ for (const check of body.report.checks) {
920
+ expect(check).toHaveProperty('name');
921
+ expect(check).toHaveProperty('category');
922
+ expect(check).toHaveProperty('ok');
923
+ expect(check).toHaveProperty('detail');
924
+ }
925
+ });
926
+ it('GET /api/dashboard/doctor requires auth', async () => {
927
+ const res = await app.inject({ method: 'GET', url: '/api/dashboard/doctor' });
928
+ expect(res.statusCode).toBe(401);
929
+ });
930
+ });
931
+ describe('Reload endpoint', () => {
932
+ beforeEach(() => {
933
+ mockInitProviders.mockClear();
934
+ mockInitCron.mockClear();
935
+ mockInitHeartbeat.mockClear();
936
+ mockStopHeartbeat.mockClear();
937
+ mockInitActiveChannel.mockClear();
938
+ mockStopActiveChannel.mockClear();
939
+ mockStartActiveChannel.mockClear();
940
+ mockSetCodeAgentConfig.mockClear();
941
+ mockSetGatewayConfig.mockClear();
942
+ });
943
+ it('POST /api/dashboard/reload reloads config and returns reloaded:true', async () => {
944
+ const res = await inject({ method: 'POST', url: '/api/dashboard/reload' });
945
+ expect(res.statusCode).toBe(200);
946
+ const body = res.json();
947
+ expect(body.reloaded).toBe(true);
948
+ expect(body).toHaveProperty('timestamp');
949
+ expect(mockSetGatewayConfig).toHaveBeenCalledOnce();
950
+ expect(mockInitProviders).toHaveBeenCalledOnce();
951
+ expect(mockSetCodeAgentConfig).toHaveBeenCalledOnce();
952
+ expect(mockInitCron).toHaveBeenCalledOnce();
953
+ expect(mockStopHeartbeat).toHaveBeenCalledOnce();
954
+ expect(mockInitHeartbeat).toHaveBeenCalledOnce();
955
+ expect(mockStopActiveChannel).toHaveBeenCalledOnce();
956
+ expect(mockInitActiveChannel).toHaveBeenCalledOnce();
957
+ expect(mockStartActiveChannel).toHaveBeenCalledOnce();
958
+ });
959
+ it('POST /api/dashboard/reload requires auth', async () => {
960
+ const res = await app.inject({ method: 'POST', url: '/api/dashboard/reload' });
961
+ expect(res.statusCode).toBe(401);
962
+ });
963
+ it('POST /api/dashboard/reload returns 500 when initProviders throws', async () => {
964
+ mockInitProviders.mockImplementationOnce(() => {
965
+ throw new Error('provider init failed');
966
+ });
967
+ const res = await inject({ method: 'POST', url: '/api/dashboard/reload' });
968
+ expect(res.statusCode).toBe(500);
969
+ const body = res.json();
970
+ expect(body).toHaveProperty('error');
971
+ expect(body.error).toContain('provider init failed');
972
+ });
973
+ it('POST /api/dashboard/reload updates runtime config for auth checks', async () => {
974
+ // Update config file with new token
975
+ const newConfig = structuredClone(TEST_CONFIG);
976
+ newConfig.dashboard.token = 'new-secret-token';
977
+ const { writeFileSync } = require('fs');
978
+ writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 2));
979
+ // Reload config
980
+ const reloadRes = await inject({ method: 'POST', url: '/api/dashboard/reload' });
981
+ expect(reloadRes.statusCode).toBe(200);
982
+ // Old token should now fail
983
+ const oldTokenRes = await app.inject({
984
+ method: 'GET',
985
+ url: '/api/dashboard/status',
986
+ headers: { authorization: 'Bearer test-dashboard-token-123' },
987
+ });
988
+ expect(oldTokenRes.statusCode).toBe(401);
989
+ // New token should work
990
+ const newTokenRes = await app.inject({
991
+ method: 'GET',
992
+ url: '/api/dashboard/status',
993
+ headers: { authorization: 'Bearer new-secret-token' },
994
+ });
995
+ expect(newTokenRes.statusCode).toBe(200);
996
+ // Restore config for other tests
997
+ writeFileSync(CONFIG_PATH, JSON.stringify(TEST_CONFIG, null, 2));
998
+ const resetRes = await app.inject({
999
+ method: 'POST',
1000
+ url: '/api/dashboard/reload',
1001
+ headers: { authorization: 'Bearer new-secret-token' },
1002
+ });
1003
+ expect(resetRes.statusCode).toBe(200);
1004
+ });
1005
+ it('POST /api/dashboard/reload updates runtime config for status endpoint', async () => {
1006
+ // Update config with new agent name
1007
+ const newConfig = structuredClone(TEST_CONFIG);
1008
+ newConfig.agents.default = 'updated-agent';
1009
+ const { writeFileSync } = require('fs');
1010
+ writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 2));
1011
+ // Reload config
1012
+ const reloadRes = await inject({ method: 'POST', url: '/api/dashboard/reload' });
1013
+ expect(reloadRes.statusCode).toBe(200);
1014
+ // Status should reflect new agent name
1015
+ const statusRes = await inject({ method: 'GET', url: '/api/dashboard/status' });
1016
+ expect(statusRes.statusCode).toBe(200);
1017
+ expect(statusRes.json().agent).toBe('updated-agent');
1018
+ // Restore config for other tests
1019
+ writeFileSync(CONFIG_PATH, JSON.stringify(TEST_CONFIG, null, 2));
1020
+ });
1021
+ });
1022
+ describe('Usage endpoints', () => {
1023
+ it('GET /api/dashboard/usage returns summary with three periods', async () => {
1024
+ const res = await inject({ method: 'GET', url: '/api/dashboard/usage' });
1025
+ expect(res.statusCode).toBe(200);
1026
+ const body = res.json();
1027
+ expect(body).toHaveProperty('today');
1028
+ expect(body).toHaveProperty('week');
1029
+ expect(body).toHaveProperty('month');
1030
+ expect(body.today.totalCost).toBe(0.42);
1031
+ expect(body.today.totalCalls).toBe(3);
1032
+ });
1033
+ it('GET /api/dashboard/usage requires auth', async () => {
1034
+ const res = await app.inject({ method: 'GET', url: '/api/dashboard/usage' });
1035
+ expect(res.statusCode).toBe(401);
1036
+ });
1037
+ it('GET /api/dashboard/usage/records returns paginated records', async () => {
1038
+ const res = await inject({ method: 'GET', url: '/api/dashboard/usage/records?limit=10' });
1039
+ expect(res.statusCode).toBe(200);
1040
+ const body = res.json();
1041
+ expect(body).toHaveProperty('records');
1042
+ expect(body).toHaveProperty('total');
1043
+ expect(Array.isArray(body.records)).toBe(true);
1044
+ expect(body.records[0]).toHaveProperty('model');
1045
+ expect(body.records[0]).toHaveProperty('totalCost');
1046
+ });
1047
+ });
1048
+ describe('Conversations endpoints', () => {
1049
+ it('GET /api/dashboard/conversations lists jsonl conversations', async () => {
1050
+ writeFileSync(join(SESSIONS_DIR, 'telegram-12345.jsonl'), [
1051
+ JSON.stringify({ ts: '2026-02-01T10:00:00Z', user: 'hello' }),
1052
+ JSON.stringify({ ts: '2026-02-01T10:01:00Z', assistant: 'hi' }),
1053
+ ].join('\n'), 'utf-8');
1054
+ const res = await inject({ method: 'GET', url: '/api/dashboard/conversations' });
1055
+ expect(res.statusCode).toBe(200);
1056
+ const body = res.json();
1057
+ expect(Array.isArray(body.conversations)).toBe(true);
1058
+ expect(body.conversations[0]).toHaveProperty('id', 'telegram-12345');
1059
+ });
1060
+ it('GET /api/dashboard/conversations/:id returns messages', async () => {
1061
+ writeFileSync(join(SESSIONS_DIR, 'discord-abc.jsonl'), [
1062
+ JSON.stringify({ ts: '2026-02-01T10:00:00Z', user: 'u1', assistant: 'a1' }),
1063
+ JSON.stringify({ ts: '2026-02-01T10:02:00Z', user: 'u2' }),
1064
+ ].join('\n'), 'utf-8');
1065
+ const res = await inject({ method: 'GET', url: '/api/dashboard/conversations/discord-abc?limit=10&offset=0' });
1066
+ expect(res.statusCode).toBe(200);
1067
+ const body = res.json();
1068
+ expect(body).toHaveProperty('id', 'discord-abc');
1069
+ expect(Array.isArray(body.messages)).toBe(true);
1070
+ expect(body.total).toBeGreaterThan(0);
1071
+ });
1072
+ it('GET /api/dashboard/conversations/:id rejects invalid ids', async () => {
1073
+ const res = await inject({ method: 'GET', url: '/api/dashboard/conversations/..%2Fbad' });
1074
+ expect(res.statusCode).toBe(400);
1075
+ });
1076
+ });
1077
+ describe('Messages endpoints', () => {
1078
+ it('POST /api/dashboard/messages/send sends proactive message', async () => {
1079
+ const res = await inject({
1080
+ method: 'POST',
1081
+ url: '/api/dashboard/messages/send',
1082
+ payload: { message: 'Hello channel' },
1083
+ });
1084
+ expect(res.statusCode).toBe(200);
1085
+ expect(res.json()).toHaveProperty('sent', true);
1086
+ });
1087
+ it('POST /api/dashboard/messages/send rejects empty message', async () => {
1088
+ const res = await inject({
1089
+ method: 'POST',
1090
+ url: '/api/dashboard/messages/send',
1091
+ payload: {},
1092
+ });
1093
+ expect(res.statusCode).toBe(400);
1094
+ expect(res.json()).toHaveProperty('error', 'message required');
1095
+ });
1096
+ it('POST /api/dashboard/messages/agent runs agent turn', async () => {
1097
+ const res = await inject({
1098
+ method: 'POST',
1099
+ url: '/api/dashboard/messages/agent',
1100
+ payload: { message: 'ping' },
1101
+ });
1102
+ expect(res.statusCode).toBe(200);
1103
+ expect(res.json()).toHaveProperty('ok', true);
1104
+ expect(res.json()).toHaveProperty('response', 'ok');
1105
+ });
1106
+ });
1107
+ describe('Cron prompt-file endpoint', () => {
1108
+ it('GET /api/dashboard/cron/prompt-file rejects missing path', async () => {
1109
+ const res = await inject({ method: 'GET', url: '/api/dashboard/cron/prompt-file' });
1110
+ expect(res.statusCode).toBe(400);
1111
+ expect(res.json()).toHaveProperty('error', 'path required');
1112
+ });
1113
+ it('GET /api/dashboard/cron/prompt-file rejects invalid path traversal', async () => {
1114
+ const res = await inject({ method: 'GET', url: '/api/dashboard/cron/prompt-file?path=../../etc/passwd' });
1115
+ expect(res.statusCode).toBe(400);
1116
+ });
1117
+ });
1118
+ describe('Restart endpoint', () => {
1119
+ it('POST /api/dashboard/restart returns restarting true', async () => {
1120
+ vi.useFakeTimers();
1121
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
1122
+ const res = await inject({ method: 'POST', url: '/api/dashboard/restart' });
1123
+ expect(res.statusCode).toBe(200);
1124
+ expect(res.json()).toEqual({ restarting: true });
1125
+ vi.runOnlyPendingTimers();
1126
+ expect(exitSpy).toHaveBeenCalledWith(0);
1127
+ exitSpy.mockRestore();
1128
+ vi.useRealTimers();
1129
+ });
1130
+ });
1131
+ describe('Code Agents endpoints', () => {
1132
+ it('GET /api/dashboard/code-agents returns agents', async () => {
1133
+ const res = await inject({ method: 'GET', url: '/api/dashboard/code-agents' });
1134
+ expect(res.statusCode).toBe(200);
1135
+ expect(res.json().agents).toHaveLength(1);
1136
+ });
1137
+ it('GET /api/dashboard/code-agents/:id returns one agent', async () => {
1138
+ const res = await inject({ method: 'GET', url: '/api/dashboard/code-agents/ca-1' });
1139
+ expect(res.statusCode).toBe(200);
1140
+ expect(res.json()).toHaveProperty('id', 'ca-1');
1141
+ });
1142
+ it('POST /api/dashboard/code-agents/:id/cancel cancels agent', async () => {
1143
+ const res = await inject({ method: 'POST', url: '/api/dashboard/code-agents/ca-1/cancel' });
1144
+ expect(res.statusCode).toBe(200);
1145
+ expect(res.json()).toHaveProperty('cancelled', true);
1146
+ });
1147
+ });
1148
+ describe('Approvals endpoints', () => {
1149
+ it('GET /api/dashboard/approvals returns pending and recent', async () => {
1150
+ const res = await inject({ method: 'GET', url: '/api/dashboard/approvals' });
1151
+ expect(res.statusCode).toBe(200);
1152
+ expect(res.json()).toHaveProperty('pending');
1153
+ expect(res.json()).toHaveProperty('recent');
1154
+ });
1155
+ it('POST /api/dashboard/approvals/:id/approve approves pending request', async () => {
1156
+ const res = await inject({ method: 'POST', url: '/api/dashboard/approvals/ap-1/approve' });
1157
+ expect(res.statusCode).toBe(200);
1158
+ expect(res.json()).toHaveProperty('approved', true);
1159
+ });
1160
+ it('POST /api/dashboard/approvals/:id/deny returns 400 when already resolved', async () => {
1161
+ const res = await inject({ method: 'POST', url: '/api/dashboard/approvals/ap-2/deny' });
1162
+ expect(res.statusCode).toBe(400);
1163
+ });
1164
+ });
1165
+ describe('Digests endpoints', () => {
1166
+ it('GET /api/dashboard/digests returns digest list', async () => {
1167
+ const res = await inject({ method: 'GET', url: '/api/dashboard/digests' });
1168
+ expect(res.statusCode).toBe(200);
1169
+ expect(res.json().digests).toHaveLength(1);
1170
+ });
1171
+ it('GET /api/dashboard/digests/:id returns one digest', async () => {
1172
+ const res = await inject({ method: 'GET', url: '/api/dashboard/digests/dg-1' });
1173
+ expect(res.statusCode).toBe(200);
1174
+ expect(res.json()).toHaveProperty('id', 'dg-1');
1175
+ });
1176
+ it('POST /api/dashboard/digests/:digestId/articles/:articleId/read updates read flag', async () => {
1177
+ const res = await inject({
1178
+ method: 'POST',
1179
+ url: '/api/dashboard/digests/dg-1/articles/a-1/read',
1180
+ payload: { read: true },
1181
+ });
1182
+ expect(res.statusCode).toBe(200);
1183
+ expect(res.json()).toEqual({ updated: true, read: true });
1184
+ });
1185
+ it('DELETE /api/dashboard/digests/:id deletes digest', async () => {
1186
+ const res = await inject({ method: 'DELETE', url: '/api/dashboard/digests/dg-1' });
1187
+ expect(res.statusCode).toBe(200);
1188
+ expect(res.json()).toEqual({ deleted: true });
1189
+ });
1190
+ });
1191
+ describe('Skills endpoints', () => {
1192
+ it('GET /api/dashboard/skills returns skill list', async () => {
1193
+ const res = await inject({ method: 'GET', url: '/api/dashboard/skills' });
1194
+ expect(res.statusCode).toBe(200);
1195
+ expect(Array.isArray(res.json().skills)).toBe(true);
1196
+ expect(res.json().skills.some((s) => s.name === 'test-skill')).toBe(true);
1197
+ });
1198
+ it('GET /api/dashboard/skills/:name returns skill details', async () => {
1199
+ const res = await inject({ method: 'GET', url: '/api/dashboard/skills/test-skill' });
1200
+ expect(res.statusCode).toBe(200);
1201
+ expect(res.json()).toHaveProperty('name', 'test-skill');
1202
+ expect(res.json()).toHaveProperty('rawContent');
1203
+ });
1204
+ it('PUT /api/dashboard/skills/:name updates enabled state', async () => {
1205
+ const res = await inject({
1206
+ method: 'PUT',
1207
+ url: '/api/dashboard/skills/test-skill',
1208
+ payload: { enabled: false },
1209
+ });
1210
+ expect(res.statusCode).toBe(200);
1211
+ expect(res.json()).toHaveProperty('updated', true);
1212
+ });
1213
+ it('POST /api/dashboard/skills creates skill', async () => {
1214
+ const res = await inject({
1215
+ method: 'POST',
1216
+ url: '/api/dashboard/skills',
1217
+ payload: { name: 'new-skill', content: '---\nname: new-skill\ndescription: New\n---\n\nBody' },
1218
+ });
1219
+ expect(res.statusCode).toBe(200);
1220
+ expect(res.json()).toHaveProperty('created', true);
1221
+ });
1222
+ it('DELETE /api/dashboard/skills/:name deletes skill', async () => {
1223
+ const res = await inject({ method: 'DELETE', url: '/api/dashboard/skills/disabled-skill' });
1224
+ expect(res.statusCode).toBe(200);
1225
+ expect(res.json()).toHaveProperty('deleted', true);
1226
+ });
1227
+ });