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.
- package/README.md +230 -0
- package/dist/__tests__/agent.test.d.ts +1 -0
- package/dist/__tests__/agent.test.js +131 -0
- package/dist/__tests__/api.test.d.ts +1 -0
- package/dist/__tests__/api.test.js +1227 -0
- package/dist/__tests__/audit.test.d.ts +1 -0
- package/dist/__tests__/audit.test.js +122 -0
- package/dist/__tests__/cache.test.d.ts +1 -0
- package/dist/__tests__/cache.test.js +65 -0
- package/dist/__tests__/channels.test.d.ts +1 -0
- package/dist/__tests__/channels.test.js +85 -0
- package/dist/__tests__/cli.integration.test.d.ts +1 -0
- package/dist/__tests__/cli.integration.test.js +16 -0
- package/dist/__tests__/cli.test.d.ts +1 -0
- package/dist/__tests__/cli.test.js +230 -0
- package/dist/__tests__/code-agents-executor.test.d.ts +1 -0
- package/dist/__tests__/code-agents-executor.test.js +75 -0
- package/dist/__tests__/code-agents-orchestrator.test.d.ts +1 -0
- package/dist/__tests__/code-agents-orchestrator.test.js +149 -0
- package/dist/__tests__/code-agents-parser.test.d.ts +1 -0
- package/dist/__tests__/code-agents-parser.test.js +39 -0
- package/dist/__tests__/code-agents-utils.test.d.ts +1 -0
- package/dist/__tests__/code-agents-utils.test.js +41 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +46 -0
- package/dist/__tests__/cron.test.d.ts +1 -0
- package/dist/__tests__/cron.test.js +66 -0
- package/dist/__tests__/dashboard-mode.test.d.ts +1 -0
- package/dist/__tests__/dashboard-mode.test.js +145 -0
- package/dist/__tests__/dashboard.test.d.ts +1 -0
- package/dist/__tests__/dashboard.test.js +43 -0
- package/dist/__tests__/doctor.formatters.test.d.ts +1 -0
- package/dist/__tests__/doctor.formatters.test.js +65 -0
- package/dist/__tests__/doctor.index.test.d.ts +1 -0
- package/dist/__tests__/doctor.index.test.js +48 -0
- package/dist/__tests__/doctor.runner.test.d.ts +1 -0
- package/dist/__tests__/doctor.runner.test.js +204 -0
- package/dist/__tests__/exec-approval.test.d.ts +1 -0
- package/dist/__tests__/exec-approval.test.js +323 -0
- package/dist/__tests__/file-lock.test.d.ts +1 -0
- package/dist/__tests__/file-lock.test.js +92 -0
- package/dist/__tests__/langfuse.test.d.ts +1 -0
- package/dist/__tests__/langfuse.test.js +40 -0
- package/dist/__tests__/model-selection.test.d.ts +1 -0
- package/dist/__tests__/model-selection.test.js +62 -0
- package/dist/__tests__/orchestrator.test.d.ts +1 -0
- package/dist/__tests__/orchestrator.test.js +425 -0
- package/dist/__tests__/providers-init.test.d.ts +1 -0
- package/dist/__tests__/providers-init.test.js +32 -0
- package/dist/__tests__/providers-routing.test.d.ts +1 -0
- package/dist/__tests__/providers-routing.test.js +25 -0
- package/dist/__tests__/providers-utils.test.d.ts +1 -0
- package/dist/__tests__/providers-utils.test.js +54 -0
- package/dist/__tests__/security.test.d.ts +1 -0
- package/dist/__tests__/security.test.js +22 -0
- package/dist/__tests__/sessions.test.d.ts +1 -0
- package/dist/__tests__/sessions.test.js +147 -0
- package/dist/__tests__/setup.test.d.ts +1 -0
- package/dist/__tests__/setup.test.js +114 -0
- package/dist/__tests__/skills.test.d.ts +1 -0
- package/dist/__tests__/skills.test.js +333 -0
- package/dist/__tests__/subagent.test.d.ts +1 -0
- package/dist/__tests__/subagent.test.js +240 -0
- package/dist/__tests__/telegram-utils.test.d.ts +1 -0
- package/dist/__tests__/telegram-utils.test.js +22 -0
- package/dist/__tests__/telegram.test.d.ts +1 -0
- package/dist/__tests__/telegram.test.js +42 -0
- package/dist/__tests__/token-efficiency.test.d.ts +1 -0
- package/dist/__tests__/token-efficiency.test.js +38 -0
- package/dist/__tests__/tool-guard.test.d.ts +1 -0
- package/dist/__tests__/tool-guard.test.js +105 -0
- package/dist/__tests__/tools.test.d.ts +1 -0
- package/dist/__tests__/tools.test.js +589 -0
- package/dist/__tests__/usage.test.d.ts +1 -0
- package/dist/__tests__/usage.test.js +197 -0
- package/dist/__tests__/voice.test.d.ts +1 -0
- package/dist/__tests__/voice.test.js +214 -0
- package/dist/agent.d.ts +24 -0
- package/dist/agent.js +269 -0
- package/dist/api.d.ts +3 -0
- package/dist/api.js +943 -0
- package/dist/audit.d.ts +26 -0
- package/dist/audit.js +121 -0
- package/dist/cache.d.ts +8 -0
- package/dist/cache.js +24 -0
- package/dist/channels/telegram/handlers.d.ts +41 -0
- package/dist/channels/telegram/handlers.js +498 -0
- package/dist/channels/telegram/index.d.ts +14 -0
- package/dist/channels/telegram/index.js +326 -0
- package/dist/channels/telegram/types.d.ts +26 -0
- package/dist/channels/telegram/types.js +31 -0
- package/dist/channels/telegram/utils.d.ts +25 -0
- package/dist/channels/telegram/utils.js +256 -0
- package/dist/channels.d.ts +11 -0
- package/dist/channels.js +118 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +768 -0
- package/dist/code-agents/executor.d.ts +5 -0
- package/dist/code-agents/executor.js +463 -0
- package/dist/code-agents/index.d.ts +22 -0
- package/dist/code-agents/index.js +199 -0
- package/dist/code-agents/orchestrator.d.ts +23 -0
- package/dist/code-agents/orchestrator.js +403 -0
- package/dist/code-agents/parser.d.ts +21 -0
- package/dist/code-agents/parser.js +197 -0
- package/dist/code-agents/registry.d.ts +27 -0
- package/dist/code-agents/registry.js +147 -0
- package/dist/code-agents/types.d.ts +66 -0
- package/dist/code-agents/types.js +4 -0
- package/dist/code-agents/utils.d.ts +36 -0
- package/dist/code-agents/utils.js +236 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +123 -0
- package/dist/cron.d.ts +49 -0
- package/dist/cron.js +400 -0
- package/dist/dashboard/assets/index-CZJCvMSN.js +65 -0
- package/dist/dashboard/assets/index-EAg6lqF5.css +1 -0
- package/dist/dashboard/favicon.svg +3 -0
- package/dist/dashboard/index.html +21 -0
- package/dist/dashboard-frontend.d.ts +7 -0
- package/dist/dashboard-frontend.js +86 -0
- package/dist/dashboard.d.ts +8 -0
- package/dist/dashboard.js +4071 -0
- package/dist/digests.d.ts +36 -0
- package/dist/digests.js +338 -0
- package/dist/discord.d.ts +8 -0
- package/dist/discord.js +828 -0
- package/dist/doctor/checks.d.ts +18 -0
- package/dist/doctor/checks.js +368 -0
- package/dist/doctor/formatters.d.ts +3 -0
- package/dist/doctor/formatters.js +44 -0
- package/dist/doctor/index.d.ts +8 -0
- package/dist/doctor/index.js +7 -0
- package/dist/doctor/runner.d.ts +3 -0
- package/dist/doctor/runner.js +109 -0
- package/dist/doctor/types.d.ts +20 -0
- package/dist/doctor/types.js +1 -0
- package/dist/exec-approval.d.ts +101 -0
- package/dist/exec-approval.js +432 -0
- package/dist/file-lock.d.ts +34 -0
- package/dist/file-lock.js +81 -0
- package/dist/gateway.d.ts +8 -0
- package/dist/gateway.js +114 -0
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.js +101 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +75 -0
- package/dist/langfuse.d.ts +34 -0
- package/dist/langfuse.js +145 -0
- package/dist/mcp-context-a8c.d.ts +13 -0
- package/dist/mcp-context-a8c.js +34 -0
- package/dist/model-selection.d.ts +18 -0
- package/dist/model-selection.js +50 -0
- package/dist/orchestrator.d.ts +15 -0
- package/dist/orchestrator.js +676 -0
- package/dist/providers/anthropic.d.ts +7 -0
- package/dist/providers/anthropic.js +319 -0
- package/dist/providers/codex.d.ts +17 -0
- package/dist/providers/codex.js +508 -0
- package/dist/providers/content.d.ts +21 -0
- package/dist/providers/content.js +55 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.js +138 -0
- package/dist/providers/observability.d.ts +19 -0
- package/dist/providers/observability.js +94 -0
- package/dist/providers/openai.d.ts +10 -0
- package/dist/providers/openai.js +310 -0
- package/dist/providers/tool-guard.d.ts +30 -0
- package/dist/providers/tool-guard.js +89 -0
- package/dist/providers/types.d.ts +34 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/utils.d.ts +65 -0
- package/dist/providers/utils.js +199 -0
- package/dist/security.d.ts +8 -0
- package/dist/security.js +113 -0
- package/dist/service.d.ts +8 -0
- package/dist/service.js +38 -0
- package/dist/sessions.d.ts +35 -0
- package/dist/sessions.js +142 -0
- package/dist/setup.d.ts +36 -0
- package/dist/setup.js +821 -0
- package/dist/skills-types.d.ts +65 -0
- package/dist/skills-types.js +2 -0
- package/dist/skills.d.ts +32 -0
- package/dist/skills.js +260 -0
- package/dist/subagent.d.ts +19 -0
- package/dist/subagent.js +376 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +11 -0
- package/dist/tools/bash-tool.d.ts +3 -0
- package/dist/tools/bash-tool.js +59 -0
- package/dist/tools/browser-tool.d.ts +3 -0
- package/dist/tools/browser-tool.js +265 -0
- package/dist/tools/definitions.d.ts +432 -0
- package/dist/tools/definitions.js +181 -0
- package/dist/tools/execute-context.d.ts +26 -0
- package/dist/tools/execute-context.js +1 -0
- package/dist/tools/file-tools.d.ts +8 -0
- package/dist/tools/file-tools.js +67 -0
- package/dist/tools/path-utils.d.ts +1 -0
- package/dist/tools/path-utils.js +8 -0
- package/dist/tools.d.ts +24 -0
- package/dist/tools.js +281 -0
- package/dist/types.d.ts +259 -0
- package/dist/types.js +2 -0
- package/dist/usage.d.ts +76 -0
- package/dist/usage.js +150 -0
- package/dist/voice.d.ts +37 -0
- package/dist/voice.js +461 -0
- package/package.json +70 -0
- package/templates/AGENTS.md +38 -0
- package/templates/BOOT.md +23 -0
- package/templates/BOOTSTRAP.md +26 -0
- package/templates/HEARTBEAT.md +5 -0
- package/templates/IDENTITY.md +5 -0
- package/templates/MEMORY.md +24 -0
- package/templates/SOUL.md +92 -0
- package/templates/TOOLS.md +30 -0
- 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
|
+
});
|