rufloui 0.3.2 → 0.3.35
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/TESTS.md +91 -0
- package/package.json +2 -2
- package/src/backend/__tests__/e2e-workflows.test.ts +438 -0
- package/src/backend/__tests__/server-integration.test.ts +444 -0
- package/src/backend/__tests__/server-utils.test.ts +200 -0
- package/src/backend/__tests__/webhook-gitlab.test.ts +605 -0
- package/src/backend/server.ts +301 -14
- package/src/backend/webhook-github.ts +0 -1
- package/src/backend/webhook-gitlab.ts +313 -0
- package/src/frontend/__tests__/api.test.ts +375 -0
- package/src/frontend/__tests__/components.test.tsx +195 -0
- package/src/frontend/__tests__/store.test.ts +295 -0
- package/src/frontend/api.ts +8 -1
- package/src/frontend/pages/TasksPanel.tsx +59 -2
- package/src/frontend/pages/WebhooksPanel.tsx +282 -116
- package/src/frontend/types.ts +12 -1
- package/vitest.config.ts +1 -0
- package/frontend +0 -0
- package/release-notes.md +0 -27
- package/{ +0 -0
- package/{, +0 -0
- package/{,+ +0 -0
- /package/{Webhooks) → {}),} +0 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import os from 'os'
|
|
6
|
+
|
|
7
|
+
// ── Persistence layer tests ─────────────────────────────────────────────
|
|
8
|
+
// These test the save/load cycle for .ruflo/state.json without starting the server.
|
|
9
|
+
// We replicate the core persistence logic from server.ts.
|
|
10
|
+
|
|
11
|
+
interface PersistedState {
|
|
12
|
+
tasks: Array<[string, unknown]>
|
|
13
|
+
workflows: Array<[string, unknown]>
|
|
14
|
+
sessions: Array<[string, unknown]>
|
|
15
|
+
agents: Array<[string, { id: string; name: string; type: string }]>
|
|
16
|
+
terminatedAgents: string[]
|
|
17
|
+
agentActivity: Array<[string, unknown]>
|
|
18
|
+
swarmConfig: {
|
|
19
|
+
id: string; topology: string; strategy: string; maxAgents: number
|
|
20
|
+
createdAt: string; shutdown: boolean
|
|
21
|
+
}
|
|
22
|
+
perfHistory: Array<{ timestamp: string; latency: number; throughput: number }>
|
|
23
|
+
lastPerfMetrics: unknown
|
|
24
|
+
benchmarkHasRun: boolean
|
|
25
|
+
currentSwarmAgentIds: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createTestPersistDir(): string {
|
|
29
|
+
const dir = path.join(os.tmpdir(), `ruflo-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
31
|
+
return dir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanupDir(dir: string) {
|
|
35
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveToDisk(persistDir: string, state: PersistedState) {
|
|
39
|
+
if (!fs.existsSync(persistDir)) fs.mkdirSync(persistDir, { recursive: true })
|
|
40
|
+
const target = path.join(persistDir, 'state.json')
|
|
41
|
+
const tmp = target + '.tmp'
|
|
42
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2))
|
|
43
|
+
fs.renameSync(tmp, target)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadFromDisk(persistDir: string): PersistedState | null {
|
|
47
|
+
const filePath = path.join(persistDir, 'state.json')
|
|
48
|
+
const tmpPath = filePath + '.tmp'
|
|
49
|
+
// Recovery from interrupted write
|
|
50
|
+
if (!fs.existsSync(filePath) && fs.existsSync(tmpPath)) {
|
|
51
|
+
try { fs.renameSync(tmpPath, filePath) } catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
if (!fs.existsSync(filePath)) return null
|
|
54
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Persistence layer (save/load from .ruflo/state.json)', () => {
|
|
58
|
+
let testDir: string
|
|
59
|
+
|
|
60
|
+
beforeEach(() => { testDir = createTestPersistDir() })
|
|
61
|
+
afterEach(() => { cleanupDir(testDir) })
|
|
62
|
+
|
|
63
|
+
it('saves and loads tasks', () => {
|
|
64
|
+
const state: PersistedState = {
|
|
65
|
+
tasks: [['t1', { id: 't1', title: 'Test', status: 'pending' }]],
|
|
66
|
+
workflows: [], sessions: [], agents: [], terminatedAgents: [],
|
|
67
|
+
agentActivity: [],
|
|
68
|
+
swarmConfig: { id: '', topology: 'mesh', strategy: 'round-robin', maxAgents: 8, createdAt: '', shutdown: true },
|
|
69
|
+
perfHistory: [], lastPerfMetrics: null, benchmarkHasRun: false, currentSwarmAgentIds: [],
|
|
70
|
+
}
|
|
71
|
+
saveToDisk(testDir, state)
|
|
72
|
+
const loaded = loadFromDisk(testDir)
|
|
73
|
+
expect(loaded).not.toBeNull()
|
|
74
|
+
expect(loaded!.tasks).toHaveLength(1)
|
|
75
|
+
expect(loaded!.tasks[0][0]).toBe('t1')
|
|
76
|
+
expect((loaded!.tasks[0][1] as any).title).toBe('Test')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('saves and loads agents and terminated agents', () => {
|
|
80
|
+
const state: PersistedState = {
|
|
81
|
+
tasks: [], workflows: [], sessions: [],
|
|
82
|
+
agents: [
|
|
83
|
+
['2025-01-01T00:00:00', { id: 'a1', name: 'coder-1', type: 'coder' }],
|
|
84
|
+
['2025-01-01T01:00:00', { id: 'a2', name: 'tester-1', type: 'tester' }],
|
|
85
|
+
],
|
|
86
|
+
terminatedAgents: ['2025-01-01T00:00:00'],
|
|
87
|
+
agentActivity: [['a1', { status: 'idle', lastSeen: '2025-01-01' }]],
|
|
88
|
+
swarmConfig: { id: 's1', topology: 'hierarchical', strategy: 'specialized', maxAgents: 4, createdAt: '2025-01-01', shutdown: false },
|
|
89
|
+
perfHistory: [], lastPerfMetrics: null, benchmarkHasRun: false, currentSwarmAgentIds: ['a1', 'a2'],
|
|
90
|
+
}
|
|
91
|
+
saveToDisk(testDir, state)
|
|
92
|
+
const loaded = loadFromDisk(testDir)
|
|
93
|
+
expect(loaded!.agents).toHaveLength(2)
|
|
94
|
+
expect(loaded!.terminatedAgents).toEqual(['2025-01-01T00:00:00'])
|
|
95
|
+
expect(loaded!.currentSwarmAgentIds).toEqual(['a1', 'a2'])
|
|
96
|
+
expect(loaded!.swarmConfig.id).toBe('s1')
|
|
97
|
+
expect(loaded!.swarmConfig.shutdown).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('saves and loads workflows and sessions', () => {
|
|
101
|
+
const state: PersistedState = {
|
|
102
|
+
tasks: [],
|
|
103
|
+
workflows: [['w1', { id: 'w1', name: 'deploy', status: 'completed', steps: [] }]],
|
|
104
|
+
sessions: [['s1', { id: 's1', name: 'morning', status: 'saved' }]],
|
|
105
|
+
agents: [], terminatedAgents: [], agentActivity: [],
|
|
106
|
+
swarmConfig: { id: '', topology: 'mesh', strategy: '', maxAgents: 8, createdAt: '', shutdown: true },
|
|
107
|
+
perfHistory: [{ timestamp: '2025-01-01T00:00:00Z', latency: 50, throughput: 100 }],
|
|
108
|
+
lastPerfMetrics: { some: 'data' },
|
|
109
|
+
benchmarkHasRun: true, currentSwarmAgentIds: [],
|
|
110
|
+
}
|
|
111
|
+
saveToDisk(testDir, state)
|
|
112
|
+
const loaded = loadFromDisk(testDir)
|
|
113
|
+
expect(loaded!.workflows).toHaveLength(1)
|
|
114
|
+
expect(loaded!.sessions).toHaveLength(1)
|
|
115
|
+
expect(loaded!.perfHistory).toHaveLength(1)
|
|
116
|
+
expect(loaded!.benchmarkHasRun).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('returns null when no state file exists', () => {
|
|
120
|
+
const emptyDir = createTestPersistDir()
|
|
121
|
+
const loaded = loadFromDisk(emptyDir)
|
|
122
|
+
expect(loaded).toBeNull()
|
|
123
|
+
cleanupDir(emptyDir)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('recovers from .tmp file when main state.json is missing', () => {
|
|
127
|
+
const state: PersistedState = {
|
|
128
|
+
tasks: [['t1', { id: 't1', title: 'Recovered' }]],
|
|
129
|
+
workflows: [], sessions: [], agents: [], terminatedAgents: [],
|
|
130
|
+
agentActivity: [],
|
|
131
|
+
swarmConfig: { id: '', topology: 'mesh', strategy: '', maxAgents: 8, createdAt: '', shutdown: true },
|
|
132
|
+
perfHistory: [], lastPerfMetrics: null, benchmarkHasRun: false, currentSwarmAgentIds: [],
|
|
133
|
+
}
|
|
134
|
+
// Write only the .tmp file (simulating crash during write)
|
|
135
|
+
const tmpPath = path.join(testDir, 'state.json.tmp')
|
|
136
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2))
|
|
137
|
+
|
|
138
|
+
const loaded = loadFromDisk(testDir)
|
|
139
|
+
expect(loaded).not.toBeNull()
|
|
140
|
+
expect((loaded!.tasks[0][1] as any).title).toBe('Recovered')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('atomic write creates .tmp then renames', () => {
|
|
144
|
+
const state: PersistedState = {
|
|
145
|
+
tasks: [], workflows: [], sessions: [], agents: [], terminatedAgents: [],
|
|
146
|
+
agentActivity: [],
|
|
147
|
+
swarmConfig: { id: '', topology: 'mesh', strategy: '', maxAgents: 8, createdAt: '', shutdown: true },
|
|
148
|
+
perfHistory: [], lastPerfMetrics: null, benchmarkHasRun: false, currentSwarmAgentIds: [],
|
|
149
|
+
}
|
|
150
|
+
saveToDisk(testDir, state)
|
|
151
|
+
// After save, .tmp should NOT exist (renamed to state.json)
|
|
152
|
+
expect(fs.existsSync(path.join(testDir, 'state.json.tmp'))).toBe(false)
|
|
153
|
+
expect(fs.existsSync(path.join(testDir, 'state.json'))).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('handles corrupted JSON gracefully', () => {
|
|
157
|
+
fs.writeFileSync(path.join(testDir, 'state.json'), 'not valid json{{{')
|
|
158
|
+
expect(() => loadFromDisk(testDir)).toThrow()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ── parseCliTable additional edge cases ─────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function parseCliTable(raw: string): Record<string, string>[] {
|
|
165
|
+
const lines = raw.replace(/\r/g, '').split('\n')
|
|
166
|
+
const dataLines = lines.filter(l => l.trim().startsWith('|') && !l.match(/^[|+\-─\s]+$/))
|
|
167
|
+
if (dataLines.length < 2) return []
|
|
168
|
+
const splitRow = (line: string) =>
|
|
169
|
+
line.split('|').slice(1, -1).map(c => c.trim().replace(/\.{3}$/, ''))
|
|
170
|
+
const headers = splitRow(dataLines[0]).map(h => h.toLowerCase().replace(/\s+/g, '_'))
|
|
171
|
+
return dataLines.slice(1).map(line => {
|
|
172
|
+
const cells = splitRow(line)
|
|
173
|
+
const obj: Record<string, string> = {}
|
|
174
|
+
headers.forEach((h, i) => { obj[h] = cells[i] ?? '' })
|
|
175
|
+
return obj
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe('parseCliTable — integration with real CLI output patterns', () => {
|
|
180
|
+
it('parses agent list output format', () => {
|
|
181
|
+
const cliOutput = [
|
|
182
|
+
'+-------------------+---------+----------+',
|
|
183
|
+
'| Name | Type | Created |',
|
|
184
|
+
'+-------------------+---------+----------+',
|
|
185
|
+
'| coder-1 | coder | 10:30:00 |',
|
|
186
|
+
'| researcher-alpha | resear | 10:31:00 |',
|
|
187
|
+
'+-------------------+---------+----------+',
|
|
188
|
+
].join('\n')
|
|
189
|
+
const result = parseCliTable(cliOutput)
|
|
190
|
+
expect(result).toHaveLength(2)
|
|
191
|
+
expect(result[0].name).toBe('coder-1')
|
|
192
|
+
expect(result[0].type).toBe('coder')
|
|
193
|
+
expect(result[1].name).toBe('researcher-alpha')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('parses performance metrics output', () => {
|
|
197
|
+
const cliOutput = [
|
|
198
|
+
'| Metric | Current | Limit | Status |',
|
|
199
|
+
'+------------+---------+-------+--------+',
|
|
200
|
+
'| Latency | 45ms | 100ms | ok |',
|
|
201
|
+
'| Throughput | 120/s | 500/s | ok |',
|
|
202
|
+
'| Memory | 256MB | 1GB | ok |',
|
|
203
|
+
].join('\n')
|
|
204
|
+
const result = parseCliTable(cliOutput)
|
|
205
|
+
expect(result).toHaveLength(3)
|
|
206
|
+
expect(result[0].metric).toBe('Latency')
|
|
207
|
+
expect(result[2].metric).toBe('Memory')
|
|
208
|
+
expect(result[2].limit).toBe('1GB')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('parses benchmark output with different columns', () => {
|
|
212
|
+
const cliOutput = [
|
|
213
|
+
'| Operation | Mean | P95 | P99 | Status |',
|
|
214
|
+
'+-----------+-------+-------+-------+--------+',
|
|
215
|
+
'| read | 12ms | 25ms | 50ms | pass |',
|
|
216
|
+
'| write | 18ms | 35ms | 70ms | pass |',
|
|
217
|
+
].join('\n')
|
|
218
|
+
const result = parseCliTable(cliOutput)
|
|
219
|
+
expect(result).toHaveLength(2)
|
|
220
|
+
expect(result[0]).toEqual({
|
|
221
|
+
operation: 'read', mean: '12ms', p95: '25ms', p99: '50ms', status: 'pass',
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// ── Health check parsing ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
describe('Health check parsing logic', () => {
|
|
229
|
+
const knownChecks = [
|
|
230
|
+
'Version Freshness', 'Node.js Version', 'npm Version', 'Claude Code CLI',
|
|
231
|
+
'Git:', 'Git Repository', 'Config File', 'Daemon Status', 'Memory Database',
|
|
232
|
+
'API Keys', 'MCP Servers', 'Disk Space', 'TypeScript', 'agentic-flow',
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
function parseHealthChecks(raw: string): Array<{ name: string; status: 'pass' | 'warn' | 'fail'; detail: string }> {
|
|
236
|
+
const checks: Array<{ name: string; status: 'pass' | 'warn' | 'fail'; detail: string }> = []
|
|
237
|
+
for (const line of raw.replace(/\r/g, '').split('\n')) {
|
|
238
|
+
for (const check of knownChecks) {
|
|
239
|
+
const checkName = check.replace(':', '')
|
|
240
|
+
if (line.includes(checkName + ':')) {
|
|
241
|
+
const colonIdx = line.indexOf(checkName + ':')
|
|
242
|
+
const name = checkName.trim()
|
|
243
|
+
const detail = line.substring(colonIdx + checkName.length + 1).trim()
|
|
244
|
+
const isWarn = detail.match(/not (a |running|installed|found)|no (config|api)/i)
|
|
245
|
+
const isFail = detail.match(/fail|error|critical/i)
|
|
246
|
+
checks.push({
|
|
247
|
+
name,
|
|
248
|
+
status: isFail ? 'fail' : isWarn ? 'warn' : 'pass',
|
|
249
|
+
detail,
|
|
250
|
+
})
|
|
251
|
+
break
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return checks
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it('parses passing checks', () => {
|
|
259
|
+
const raw = [
|
|
260
|
+
'✓ Version Freshness: v3.1.0 (latest)',
|
|
261
|
+
'✓ Node.js Version: v20.11.0',
|
|
262
|
+
'✓ npm Version: 10.2.4',
|
|
263
|
+
].join('\n')
|
|
264
|
+
const checks = parseHealthChecks(raw)
|
|
265
|
+
expect(checks).toHaveLength(3)
|
|
266
|
+
expect(checks[0]).toEqual({ name: 'Version Freshness', status: 'pass', detail: 'v3.1.0 (latest)' })
|
|
267
|
+
expect(checks[1]).toEqual({ name: 'Node.js Version', status: 'pass', detail: 'v20.11.0' })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('parses warning checks (not installed, not found)', () => {
|
|
271
|
+
const raw = [
|
|
272
|
+
'⚠ Daemon Status: not running',
|
|
273
|
+
'⚠ API Keys: no API key configured',
|
|
274
|
+
].join('\n')
|
|
275
|
+
const checks = parseHealthChecks(raw)
|
|
276
|
+
expect(checks).toHaveLength(2)
|
|
277
|
+
expect(checks[0].status).toBe('warn')
|
|
278
|
+
expect(checks[1].status).toBe('warn')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('parses fail checks', () => {
|
|
282
|
+
const raw = '✗ Memory Database: critical error in HNSW index'
|
|
283
|
+
const checks = parseHealthChecks(raw)
|
|
284
|
+
expect(checks).toHaveLength(1)
|
|
285
|
+
expect(checks[0].status).toBe('fail')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('handles Windows-mangled output (no Unicode)', () => {
|
|
289
|
+
// On Windows, UTF-8 symbols may get mangled, so we rely on check names
|
|
290
|
+
const raw = [
|
|
291
|
+
'? Version Freshness: v3.1.0',
|
|
292
|
+
'? Daemon Status: not running',
|
|
293
|
+
].join('\n')
|
|
294
|
+
const checks = parseHealthChecks(raw)
|
|
295
|
+
expect(checks).toHaveLength(2)
|
|
296
|
+
expect(checks[0].name).toBe('Version Freshness')
|
|
297
|
+
expect(checks[1].status).toBe('warn')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns empty for unrecognized output', () => {
|
|
301
|
+
const raw = 'Some random output\nwithout check names'
|
|
302
|
+
const checks = parseHealthChecks(raw)
|
|
303
|
+
expect(checks).toHaveLength(0)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// ── Time matching logic ─────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
describe('Time matching (UTC to local conversion)', () => {
|
|
310
|
+
it('converts UTC ISO string to local time string', () => {
|
|
311
|
+
const utcDate = new Date('2025-01-15T14:30:00Z')
|
|
312
|
+
// The registry stores local time for matching with `agent list` output
|
|
313
|
+
const localStr = utcDate.toLocaleTimeString('en-US', { hour12: false })
|
|
314
|
+
// localStr should be a valid time like "09:30:00" or "14:30:00" depending on timezone
|
|
315
|
+
expect(localStr).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('handles date at midnight UTC', () => {
|
|
319
|
+
const utcDate = new Date('2025-06-01T00:00:00Z')
|
|
320
|
+
const localStr = utcDate.toLocaleTimeString('en-US', { hour12: false })
|
|
321
|
+
expect(localStr).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('handles date at end of day UTC', () => {
|
|
325
|
+
const utcDate = new Date('2025-06-01T23:59:59Z')
|
|
326
|
+
const localStr = utcDate.toLocaleTimeString('en-US', { hour12: false })
|
|
327
|
+
expect(localStr).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// ── Env var cleanup ─────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
describe('Env var cleanup for child processes', () => {
|
|
334
|
+
it('identifies CLAUDE-prefixed env vars', () => {
|
|
335
|
+
const testEnv: Record<string, string> = {
|
|
336
|
+
CLAUDE_SESSION_ID: 'abc123',
|
|
337
|
+
CLAUDE_API_KEY: 'sk-test',
|
|
338
|
+
CLAUDE_CONFIG_DIR: '/home/user/.claude',
|
|
339
|
+
PATH: '/usr/bin',
|
|
340
|
+
HOME: '/home/user',
|
|
341
|
+
NODE_ENV: 'development',
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Replicate the env cleanup logic from server.ts
|
|
345
|
+
const cleanEnv = { ...testEnv }
|
|
346
|
+
for (const key of Object.keys(cleanEnv)) {
|
|
347
|
+
if (key.startsWith('CLAUDE')) delete cleanEnv[key]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
expect(cleanEnv).not.toHaveProperty('CLAUDE_SESSION_ID')
|
|
351
|
+
expect(cleanEnv).not.toHaveProperty('CLAUDE_API_KEY')
|
|
352
|
+
expect(cleanEnv).not.toHaveProperty('CLAUDE_CONFIG_DIR')
|
|
353
|
+
expect(cleanEnv).toHaveProperty('PATH')
|
|
354
|
+
expect(cleanEnv).toHaveProperty('HOME')
|
|
355
|
+
expect(cleanEnv).toHaveProperty('NODE_ENV')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('handles env with no CLAUDE vars', () => {
|
|
359
|
+
const testEnv: Record<string, string> = { PATH: '/usr/bin', HOME: '/home' }
|
|
360
|
+
const cleanEnv = { ...testEnv }
|
|
361
|
+
for (const key of Object.keys(cleanEnv)) {
|
|
362
|
+
if (key.startsWith('CLAUDE')) delete cleanEnv[key]
|
|
363
|
+
}
|
|
364
|
+
expect(Object.keys(cleanEnv)).toEqual(['PATH', 'HOME'])
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('handles env with only CLAUDE vars', () => {
|
|
368
|
+
const testEnv: Record<string, string> = { CLAUDE_A: '1', CLAUDE_B: '2' }
|
|
369
|
+
const cleanEnv = { ...testEnv }
|
|
370
|
+
for (const key of Object.keys(cleanEnv)) {
|
|
371
|
+
if (key.startsWith('CLAUDE')) delete cleanEnv[key]
|
|
372
|
+
}
|
|
373
|
+
expect(Object.keys(cleanEnv)).toHaveLength(0)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// ── WebSocket broadcast logic ───────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
describe('WebSocket broadcast logic', () => {
|
|
380
|
+
const PERSIST_EVENTS = new Set([
|
|
381
|
+
'task:added', 'task:updated', 'task:list',
|
|
382
|
+
'workflow:added', 'workflow:updated',
|
|
383
|
+
'session:added', 'session:updated', 'session:list', 'session:active',
|
|
384
|
+
'swarm:status', 'swarm-monitor:purged',
|
|
385
|
+
'agent:activity', 'agent:added', 'agent:removed', 'agents:cleared',
|
|
386
|
+
'performance:metrics',
|
|
387
|
+
])
|
|
388
|
+
|
|
389
|
+
it('identifies persist-worthy event types', () => {
|
|
390
|
+
expect(PERSIST_EVENTS.has('task:added')).toBe(true)
|
|
391
|
+
expect(PERSIST_EVENTS.has('task:updated')).toBe(true)
|
|
392
|
+
expect(PERSIST_EVENTS.has('swarm:status')).toBe(true)
|
|
393
|
+
expect(PERSIST_EVENTS.has('agent:activity')).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('does not persist transient events', () => {
|
|
397
|
+
expect(PERSIST_EVENTS.has('task:output')).toBe(false)
|
|
398
|
+
expect(PERSIST_EVENTS.has('agent:output')).toBe(false)
|
|
399
|
+
expect(PERSIST_EVENTS.has('log')).toBe(false)
|
|
400
|
+
expect(PERSIST_EVENTS.has('viz:update')).toBe(false)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('broadcast message format is valid JSON with type, payload, timestamp', () => {
|
|
404
|
+
const type = 'task:added'
|
|
405
|
+
const payload = { id: 't1', title: 'Test' }
|
|
406
|
+
const msg = JSON.stringify({ type, payload, timestamp: new Date().toISOString() })
|
|
407
|
+
const parsed = JSON.parse(msg)
|
|
408
|
+
expect(parsed.type).toBe('task:added')
|
|
409
|
+
expect(parsed.payload.id).toBe('t1')
|
|
410
|
+
expect(parsed.timestamp).toBeTruthy()
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// ── sanitizeShellArg comprehensive tests ────────────────────────────────
|
|
415
|
+
|
|
416
|
+
function sanitizeShellArg(arg: string): string {
|
|
417
|
+
return arg.replace(/[;&|`$(){}[\]!#~<>\\]/g, '')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
describe('sanitizeShellArg — injection prevention', () => {
|
|
421
|
+
it('blocks command chaining with semicolons', () => {
|
|
422
|
+
expect(sanitizeShellArg('coder; rm -rf /')).toBe('coder rm -rf /')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('blocks pipe injection', () => {
|
|
426
|
+
expect(sanitizeShellArg('coder | cat /etc/passwd')).toBe('coder cat /etc/passwd')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('blocks command substitution', () => {
|
|
430
|
+
expect(sanitizeShellArg('$(cat /etc/shadow)')).toBe('cat /etc/shadow')
|
|
431
|
+
expect(sanitizeShellArg('`id`')).toBe('id')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('preserves normal agent names', () => {
|
|
435
|
+
expect(sanitizeShellArg('my-coder-agent')).toBe('my-coder-agent')
|
|
436
|
+
expect(sanitizeShellArg('researcher_v2')).toBe('researcher_v2')
|
|
437
|
+
expect(sanitizeShellArg('agent.test')).toBe('agent.test')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('preserves spaces and dashes', () => {
|
|
441
|
+
expect(sanitizeShellArg('my agent name')).toBe('my agent name')
|
|
442
|
+
expect(sanitizeShellArg('code-review-2025')).toBe('code-review-2025')
|
|
443
|
+
})
|
|
444
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
3
|
+
|
|
4
|
+
// ── Utility functions copied from server.ts (can't import — it starts a server) ──
|
|
5
|
+
|
|
6
|
+
function parseCliTable(raw: string): Record<string, string>[] {
|
|
7
|
+
const lines = raw.replace(/\r/g, '').split('\n')
|
|
8
|
+
const dataLines = lines.filter(l => l.trim().startsWith('|') && !l.match(/^[|+\-─\s]+$/))
|
|
9
|
+
if (dataLines.length < 2) return []
|
|
10
|
+
const splitRow = (line: string) =>
|
|
11
|
+
line.split('|').slice(1, -1).map(c => c.trim().replace(/\.{3}$/, ''))
|
|
12
|
+
const headers = splitRow(dataLines[0]).map(h => h.toLowerCase().replace(/\s+/g, '_'))
|
|
13
|
+
return dataLines.slice(1).map(line => {
|
|
14
|
+
const cells = splitRow(line)
|
|
15
|
+
const obj: Record<string, string> = {}
|
|
16
|
+
headers.forEach((h, i) => { obj[h] = cells[i] ?? '' })
|
|
17
|
+
return obj
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseCliOutput(raw: string): unknown {
|
|
22
|
+
const lines = raw.split('\n').filter(l => l.trim() && !l.match(/^[+─┌┐└┘├┤┬┴┼═╔╗╚╝╠╣╦╩╬\-]+$/))
|
|
23
|
+
const data: Record<string, string> = {}
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const match = line.match(/^\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/)
|
|
26
|
+
if (match && !match[1].match(/^-+$/)) {
|
|
27
|
+
data[match[1].trim()] = match[2].trim()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Object.keys(data).length > 0 ? data : { raw }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeShellArg(arg: string): string {
|
|
34
|
+
return arg.replace(/[;&|`$(){}[\]!#~<>\\]/g, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── parseCliTable ───────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('parseCliTable', () => {
|
|
40
|
+
it('returns empty array for empty string', () => {
|
|
41
|
+
expect(parseCliTable('')).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns empty array for header-only table', () => {
|
|
45
|
+
const table = '| Name | Status |\n+------+--------+'
|
|
46
|
+
expect(parseCliTable(table)).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('parses a 2-column table', () => {
|
|
50
|
+
const table = [
|
|
51
|
+
'| Name | Status |',
|
|
52
|
+
'+--------+---------+',
|
|
53
|
+
'| agent1 | running |',
|
|
54
|
+
'| agent2 | idle |',
|
|
55
|
+
].join('\n')
|
|
56
|
+
const result = parseCliTable(table)
|
|
57
|
+
expect(result).toEqual([
|
|
58
|
+
{ name: 'agent1', status: 'running' },
|
|
59
|
+
{ name: 'agent2', status: 'idle' },
|
|
60
|
+
])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('parses a multi-column table', () => {
|
|
64
|
+
const table = [
|
|
65
|
+
'| Metric | Current | Limit | Status |',
|
|
66
|
+
'+------------+---------+-------+--------+',
|
|
67
|
+
'| Latency | 45ms | 100ms | ok |',
|
|
68
|
+
'| Throughput | 120/s | 500/s | ok |',
|
|
69
|
+
].join('\n')
|
|
70
|
+
const result = parseCliTable(table)
|
|
71
|
+
expect(result).toHaveLength(2)
|
|
72
|
+
expect(result[0]).toEqual({ metric: 'Latency', current: '45ms', limit: '100ms', status: 'ok' })
|
|
73
|
+
expect(result[1]).toEqual({ metric: 'Throughput', current: '120/s', limit: '500/s', status: 'ok' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('strips trailing ellipsis from cell values', () => {
|
|
77
|
+
const table = [
|
|
78
|
+
'| Name | Description |',
|
|
79
|
+
'+---------------+----------------+',
|
|
80
|
+
'| long-agent... | does stuff... |',
|
|
81
|
+
].join('\n')
|
|
82
|
+
const result = parseCliTable(table)
|
|
83
|
+
expect(result[0].name).toBe('long-agent')
|
|
84
|
+
expect(result[0].description).toBe('does stuff')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('filters separator lines', () => {
|
|
88
|
+
const table = [
|
|
89
|
+
'+--------+---------+',
|
|
90
|
+
'| Name | Status |',
|
|
91
|
+
'+--------+---------+',
|
|
92
|
+
'| agent1 | running |',
|
|
93
|
+
'+--------+---------+',
|
|
94
|
+
].join('\n')
|
|
95
|
+
const result = parseCliTable(table)
|
|
96
|
+
expect(result).toHaveLength(1)
|
|
97
|
+
expect(result[0].name).toBe('agent1')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('lowercases headers and replaces spaces with underscores', () => {
|
|
101
|
+
const table = [
|
|
102
|
+
'| Agent Name | Run Count | Last Activity |',
|
|
103
|
+
'| bot-1 | 5 | 2025-01-01 |',
|
|
104
|
+
].join('\n')
|
|
105
|
+
const result = parseCliTable(table)
|
|
106
|
+
expect(result[0]).toHaveProperty('agent_name', 'bot-1')
|
|
107
|
+
expect(result[0]).toHaveProperty('run_count', '5')
|
|
108
|
+
expect(result[0]).toHaveProperty('last_activity', '2025-01-01')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('handles Windows \\r\\n line endings', () => {
|
|
112
|
+
const table = '| Name | Val |\r\n| a | b |\r\n'
|
|
113
|
+
const result = parseCliTable(table)
|
|
114
|
+
expect(result).toHaveLength(1)
|
|
115
|
+
expect(result[0]).toEqual({ name: 'a', val: 'b' })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('handles missing cells gracefully', () => {
|
|
119
|
+
const table = [
|
|
120
|
+
'| A | B | C |',
|
|
121
|
+
'| 1 | 2 |',
|
|
122
|
+
].join('\n')
|
|
123
|
+
const result = parseCliTable(table)
|
|
124
|
+
expect(result[0].a).toBe('1')
|
|
125
|
+
expect(result[0].b).toBe('2')
|
|
126
|
+
expect(result[0].c).toBe('')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ── parseCliOutput ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('parseCliOutput', () => {
|
|
133
|
+
it('parses key-value table', () => {
|
|
134
|
+
const raw = [
|
|
135
|
+
'+----------+--------+',
|
|
136
|
+
'| Status | active |',
|
|
137
|
+
'| Uptime | 5m |',
|
|
138
|
+
'+----------+--------+',
|
|
139
|
+
].join('\n')
|
|
140
|
+
const result = parseCliOutput(raw) as Record<string, string>
|
|
141
|
+
expect(result.Status).toBe('active')
|
|
142
|
+
expect(result.Uptime).toBe('5m')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns { raw } for non-table input', () => {
|
|
146
|
+
const raw = 'Some plain text output'
|
|
147
|
+
expect(parseCliOutput(raw)).toEqual({ raw: 'Some plain text output' })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('filters separator and border lines', () => {
|
|
151
|
+
const raw = [
|
|
152
|
+
'╔══════════╗',
|
|
153
|
+
'| Key | Val |',
|
|
154
|
+
'╚══════════╝',
|
|
155
|
+
].join('\n')
|
|
156
|
+
const result = parseCliOutput(raw) as Record<string, string>
|
|
157
|
+
expect(result.Key).toBe('Val')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns { raw } for empty string', () => {
|
|
161
|
+
expect(parseCliOutput('')).toEqual({ raw: '' })
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ── sanitizeShellArg ────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('sanitizeShellArg', () => {
|
|
168
|
+
it('returns clean strings unchanged', () => {
|
|
169
|
+
expect(sanitizeShellArg('hello-world')).toBe('hello-world')
|
|
170
|
+
expect(sanitizeShellArg('agent_name')).toBe('agent_name')
|
|
171
|
+
expect(sanitizeShellArg('test123')).toBe('test123')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('removes semicolons and pipes', () => {
|
|
175
|
+
expect(sanitizeShellArg('cmd;rm -rf /')).toBe('cmdrm -rf /')
|
|
176
|
+
expect(sanitizeShellArg('a|b')).toBe('ab')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('removes backticks and dollar signs', () => {
|
|
180
|
+
expect(sanitizeShellArg('`whoami`')).toBe('whoami')
|
|
181
|
+
expect(sanitizeShellArg('$HOME')).toBe('HOME')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('removes parentheses and braces', () => {
|
|
185
|
+
expect(sanitizeShellArg('$(cmd)')).toBe('cmd')
|
|
186
|
+
expect(sanitizeShellArg('{a,b}')).toBe('a,b')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('removes brackets, hash, tilde, angle brackets, backslash', () => {
|
|
190
|
+
expect(sanitizeShellArg('[test]')).toBe('test')
|
|
191
|
+
expect(sanitizeShellArg('#comment')).toBe('comment')
|
|
192
|
+
expect(sanitizeShellArg('~user')).toBe('user')
|
|
193
|
+
expect(sanitizeShellArg('<in>')).toBe('in')
|
|
194
|
+
expect(sanitizeShellArg('path\\to')).toBe('pathto')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('removes mixed dangerous characters', () => {
|
|
198
|
+
expect(sanitizeShellArg('a;b|c`d$e(f)g{h}i[j]k!l#m~n<o>p\\q')).toBe('abcdefghijklmnopq')
|
|
199
|
+
})
|
|
200
|
+
})
|