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
package/TESTS.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# RuFloUI Test Suite
|
|
2
|
+
|
|
3
|
+
## Quick Start
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm test # Run all tests once
|
|
7
|
+
npx vitest # Run in watch mode
|
|
8
|
+
npx vitest --coverage # Run with coverage report
|
|
9
|
+
npx vitest run src/backend # Run only backend tests
|
|
10
|
+
npx vitest run src/frontend # Run only frontend tests
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Test Runner
|
|
14
|
+
|
|
15
|
+
- **Vitest 2.x** with two environment modes:
|
|
16
|
+
- `jsdom` (default) — for React component and browser API tests
|
|
17
|
+
- `node` — for backend tests (opted-in per file via `// @vitest-environment node`)
|
|
18
|
+
|
|
19
|
+
Configuration: `vitest.config.ts`
|
|
20
|
+
Setup file: `src/frontend/test-setup.ts` (loads `@testing-library/jest-dom` matchers)
|
|
21
|
+
|
|
22
|
+
## Test Structure
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/
|
|
26
|
+
├── backend/__tests__/
|
|
27
|
+
│ ├── server-utils.test.ts # Unit: parseCliTable, parseCliOutput, sanitizeShellArg
|
|
28
|
+
│ ├── server-integration.test.ts # Integration: persistence layer, health check parsing,
|
|
29
|
+
│ │ # time matching, env var cleanup, WebSocket broadcast logic
|
|
30
|
+
│ ├── e2e-workflows.test.ts # E2E: task lifecycle, agent lifecycle, swarm lifecycle,
|
|
31
|
+
│ │ # session lifecycle, workflow lifecycle
|
|
32
|
+
│ ├── webhook-github.test.ts # Unit+Integration: GitHub webhook signature verification,
|
|
33
|
+
│ │ # route handlers, config, auto-assign, templates
|
|
34
|
+
│ └── webhook-gitlab.test.ts # Unit+Integration: GitLab webhook token validation,
|
|
35
|
+
│ # route handlers, config, auto-assign, edge cases
|
|
36
|
+
└── frontend/__tests__/
|
|
37
|
+
├── store.test.ts # Unit: Zustand store actions and selectors (agents, tasks,
|
|
38
|
+
│ # logs, viz sessions, swarm monitor, all setters)
|
|
39
|
+
├── api.test.ts # Unit: API client (all endpoint namespaces, error handling,
|
|
40
|
+
│ # timeout behavior)
|
|
41
|
+
└── components.test.tsx # Unit: UI components (Button, Card, StatusBadge) with
|
|
42
|
+
# variants, sizes, hover, disabled/loading states
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What Each File Covers
|
|
46
|
+
|
|
47
|
+
### Backend
|
|
48
|
+
|
|
49
|
+
| File | Level | Coverage |
|
|
50
|
+
|------|-------|----------|
|
|
51
|
+
| `server-utils.test.ts` | Unit | `parseCliTable` (8 cases: empty, headers, multi-col, ellipsis, separators, CRLF, missing cells), `parseCliOutput` (4 cases), `sanitizeShellArg` (6 cases: injection vectors) |
|
|
52
|
+
| `server-integration.test.ts` | Integration | Persistence save/load (6 cases: tasks, agents, workflows, .tmp recovery, atomic write, corruption), health check parsing (5 cases: pass/warn/fail/Windows), time matching (3 cases), env var cleanup (3 cases), WebSocket broadcast event classification (3 cases), sanitizeShellArg injection prevention (5 cases), parseCliTable with real CLI output patterns (3 cases) |
|
|
53
|
+
| `e2e-workflows.test.ts` | E2E | Task lifecycle: create-assign-complete-cancel (8 cases), Agent lifecycle: spawn-list-terminate (5 cases), Swarm lifecycle: init-status-shutdown-reinit (5 cases), Session lifecycle: save-list-restore-delete (6 cases), Workflow lifecycle: create-execute-complete (4 cases) |
|
|
54
|
+
| `webhook-github.test.ts` | Unit+Integration | Signature verification (9 cases), webhook receiver (4 cases), event storage/ordering (3 cases), invalid payloads (8 cases), config GET/PUT (10 cases), HMAC integration (2 cases), test endpoint (5 cases), task templates (5 cases), auto-assign failures (2 cases), event status updates (3 cases) |
|
|
55
|
+
| `webhook-gitlab.test.ts` | Unit+Integration | GitLab issue hooks (3 cases), disabled state (1 case), token validation (4 cases), event/repo filtering (5 cases), auto-assign (3 cases), config GET/PUT (3 cases), test endpoint (5 cases), event updates (2 cases), edge cases (4 cases) |
|
|
56
|
+
|
|
57
|
+
### Frontend
|
|
58
|
+
|
|
59
|
+
| File | Level | Coverage |
|
|
60
|
+
|------|-------|----------|
|
|
61
|
+
| `store.test.ts` | Unit | Simple setters (6 cases), agent CRUD (6 cases), task CRUD (4 cases), log management (3 cases: prepend, cap), collection setters (4 cases), viz session actions (4 cases), swarm monitor (1 case), additional setters (5 cases: memory stats, active session, hive mind, neural, performance, coordination) |
|
|
62
|
+
| `api.test.ts` | Unit | System endpoints (2 cases), agent endpoints (5 cases), task endpoints (8 cases), memory endpoints (2 cases), swarm endpoints (3 cases), session endpoints (4 cases), webhook endpoints (6 cases), workflow endpoints (5 cases), config endpoints (3 cases), performance endpoints (2 cases), error handling (3 cases: non-OK, JSON parse fail, timeout) |
|
|
63
|
+
| `components.test.tsx` | Unit | Button: render, click, disabled, loading, variants, sizes, hover behavior, custom style (12 cases). Card: children, title, actions, header presence, complex children (7 cases). StatusBadge: render, statuses, empty/null, dot element, sizes, all color mappings (8 cases) |
|
|
64
|
+
|
|
65
|
+
## Coverage Strategy
|
|
66
|
+
|
|
67
|
+
### Unit Tests
|
|
68
|
+
Test individual functions and components in isolation. Mock external dependencies (fetch, store, CLI). Fast and deterministic.
|
|
69
|
+
|
|
70
|
+
### Integration Tests
|
|
71
|
+
Test interaction between components: persistence layer (file I/O), health check parsing (regex matching on CLI output), WebSocket message format and event classification. Use real file system (temp dirs) where needed.
|
|
72
|
+
|
|
73
|
+
### E2E Tests
|
|
74
|
+
Simulate full user workflows through the in-memory store layer: creating a task and moving it through its lifecycle, spawning agents and managing them, initializing and shutting down swarms. These verify the logical flow without requiring the actual Express server or CLI.
|
|
75
|
+
|
|
76
|
+
## Test Dependencies
|
|
77
|
+
|
|
78
|
+
- `vitest` — test runner
|
|
79
|
+
- `jsdom` — browser environment for React tests
|
|
80
|
+
- `@testing-library/react` — React component rendering
|
|
81
|
+
- `@testing-library/jest-dom` — DOM assertion matchers
|
|
82
|
+
- `@testing-library/user-event` — user interaction simulation
|
|
83
|
+
|
|
84
|
+
All dependencies are in `devDependencies` in `package.json`.
|
|
85
|
+
|
|
86
|
+
## Adding New Tests
|
|
87
|
+
|
|
88
|
+
1. **Backend**: Add to `src/backend/__tests__/`. Use `// @vitest-environment node` at the top.
|
|
89
|
+
2. **Frontend**: Add to `src/frontend/__tests__/`. Default jsdom environment applies.
|
|
90
|
+
3. **Naming**: Use `*.test.ts` for pure logic, `*.test.tsx` for React components.
|
|
91
|
+
4. **Pattern**: Server utility functions are copied into test files (since importing `server.ts` starts the server). Webhook modules export testable functions directly.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rufloui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.35",
|
|
4
4
|
"description": "React 19 dashboard for claude-flow v3 multi-agent orchestration",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/Mario-PB/rufloui.git"
|
|
8
8
|
},
|
|
9
9
|
"publishConfig": {
|
|
10
|
-
"registry": "https://
|
|
10
|
+
"registry": "https://npm.pkg.github.com"
|
|
11
11
|
},
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"type": "module",
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
3
|
+
|
|
4
|
+
// ── E2E-style tests that verify full workflows ──────────────────────────
|
|
5
|
+
// These simulate the complete request/response cycles that the server handles,
|
|
6
|
+
// testing the logical flow without actually starting the Express server or CLI.
|
|
7
|
+
|
|
8
|
+
// ── In-memory stores (replicating server.ts store pattern) ──────────────
|
|
9
|
+
|
|
10
|
+
type TaskRecord = {
|
|
11
|
+
id: string; title: string; description: string; status: string
|
|
12
|
+
priority: string; createdAt: string; assignedTo?: string; completedAt?: string; result?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type WorkflowRecord = {
|
|
16
|
+
id: string; name: string; status: string
|
|
17
|
+
steps: Array<{ id: string; name: string; status: string }>; createdAt: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type SessionRecord = {
|
|
21
|
+
id: string; name: string; status: string; createdAt: string
|
|
22
|
+
agentCount: number; taskCount: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let taskStore: Map<string, TaskRecord>
|
|
26
|
+
let agentRegistry: Map<string, { id: string; name: string; type: string }>
|
|
27
|
+
let terminatedAgents: Set<string>
|
|
28
|
+
let workflowStore: Map<string, WorkflowRecord>
|
|
29
|
+
let sessionStore: Map<string, SessionRecord>
|
|
30
|
+
let broadcastEvents: Array<{ type: string; payload: unknown }>
|
|
31
|
+
|
|
32
|
+
function broadcast(type: string, payload: unknown) {
|
|
33
|
+
broadcastEvents.push({ type, payload })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
taskStore = new Map()
|
|
38
|
+
agentRegistry = new Map()
|
|
39
|
+
terminatedAgents = new Set()
|
|
40
|
+
workflowStore = new Map()
|
|
41
|
+
sessionStore = new Map()
|
|
42
|
+
broadcastEvents = []
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ── Task lifecycle: create → assign → complete ──────────────────────────
|
|
46
|
+
|
|
47
|
+
describe('E2E: Task lifecycle (create → assign → complete)', () => {
|
|
48
|
+
function createTask(title: string, description: string, priority = 'normal'): TaskRecord {
|
|
49
|
+
const id = `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
|
50
|
+
const task: TaskRecord = {
|
|
51
|
+
id, title, description, status: 'pending', priority, createdAt: new Date().toISOString(),
|
|
52
|
+
}
|
|
53
|
+
taskStore.set(id, task)
|
|
54
|
+
broadcast('task:added', task)
|
|
55
|
+
return task
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assignTask(taskId: string, agentId: string): TaskRecord {
|
|
59
|
+
const task = taskStore.get(taskId)
|
|
60
|
+
if (!task) throw new Error(`Task ${taskId} not found`)
|
|
61
|
+
task.status = 'in_progress'
|
|
62
|
+
task.assignedTo = agentId
|
|
63
|
+
broadcast('task:updated', task)
|
|
64
|
+
return task
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function completeTask(taskId: string, result: string): TaskRecord {
|
|
68
|
+
const task = taskStore.get(taskId)
|
|
69
|
+
if (!task) throw new Error(`Task ${taskId} not found`)
|
|
70
|
+
task.status = 'completed'
|
|
71
|
+
task.completedAt = new Date().toISOString()
|
|
72
|
+
task.result = result
|
|
73
|
+
broadcast('task:updated', task)
|
|
74
|
+
return task
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cancelTask(taskId: string): TaskRecord {
|
|
78
|
+
const task = taskStore.get(taskId)
|
|
79
|
+
if (!task) throw new Error(`Task ${taskId} not found`)
|
|
80
|
+
task.status = 'cancelled'
|
|
81
|
+
broadcast('task:updated', task)
|
|
82
|
+
return task
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('creates a task with pending status', () => {
|
|
86
|
+
const task = createTask('Fix bug', 'Fix the login bug')
|
|
87
|
+
expect(task.status).toBe('pending')
|
|
88
|
+
expect(task.title).toBe('Fix bug')
|
|
89
|
+
expect(taskStore.has(task.id)).toBe(true)
|
|
90
|
+
expect(broadcastEvents).toHaveLength(1)
|
|
91
|
+
expect(broadcastEvents[0].type).toBe('task:added')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('assigns a task to an agent', () => {
|
|
95
|
+
const task = createTask('Fix bug', 'Fix the login bug')
|
|
96
|
+
const assigned = assignTask(task.id, 'agent-1')
|
|
97
|
+
expect(assigned.status).toBe('in_progress')
|
|
98
|
+
expect(assigned.assignedTo).toBe('agent-1')
|
|
99
|
+
expect(broadcastEvents).toHaveLength(2)
|
|
100
|
+
expect(broadcastEvents[1].type).toBe('task:updated')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('completes a task with result', () => {
|
|
104
|
+
const task = createTask('Fix bug', 'Fix the login bug')
|
|
105
|
+
assignTask(task.id, 'agent-1')
|
|
106
|
+
const completed = completeTask(task.id, 'Bug fixed by patching auth module')
|
|
107
|
+
expect(completed.status).toBe('completed')
|
|
108
|
+
expect(completed.result).toBe('Bug fixed by patching auth module')
|
|
109
|
+
expect(completed.completedAt).toBeTruthy()
|
|
110
|
+
expect(broadcastEvents).toHaveLength(3)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('full lifecycle: create → assign → complete broadcasts 3 events', () => {
|
|
114
|
+
const task = createTask('Deploy', 'Deploy to staging')
|
|
115
|
+
assignTask(task.id, 'agent-1')
|
|
116
|
+
completeTask(task.id, 'Deployed successfully')
|
|
117
|
+
|
|
118
|
+
const types = broadcastEvents.map(e => e.type)
|
|
119
|
+
expect(types).toEqual(['task:added', 'task:updated', 'task:updated'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('cancels a pending task', () => {
|
|
123
|
+
const task = createTask('Unused task', 'Not needed')
|
|
124
|
+
const cancelled = cancelTask(task.id)
|
|
125
|
+
expect(cancelled.status).toBe('cancelled')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('cancels an in-progress task', () => {
|
|
129
|
+
const task = createTask('Long task', 'Takes too long')
|
|
130
|
+
assignTask(task.id, 'agent-1')
|
|
131
|
+
const cancelled = cancelTask(task.id)
|
|
132
|
+
expect(cancelled.status).toBe('cancelled')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('throws when assigning a non-existent task', () => {
|
|
136
|
+
expect(() => assignTask('nonexistent', 'agent-1')).toThrow('not found')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('handles multiple tasks independently', () => {
|
|
140
|
+
const t1 = createTask('Task 1', 'First')
|
|
141
|
+
const t2 = createTask('Task 2', 'Second')
|
|
142
|
+
assignTask(t1.id, 'agent-1')
|
|
143
|
+
completeTask(t1.id, 'Done 1')
|
|
144
|
+
|
|
145
|
+
expect(taskStore.get(t1.id)!.status).toBe('completed')
|
|
146
|
+
expect(taskStore.get(t2.id)!.status).toBe('pending')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ── Agent lifecycle: spawn → list → terminate ───────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('E2E: Agent lifecycle (spawn → list → terminate)', () => {
|
|
153
|
+
let agentCounter = 0
|
|
154
|
+
function spawnAgent(type: string, name: string): { id: string; name: string; type: string; createdAt: string } {
|
|
155
|
+
agentCounter++
|
|
156
|
+
const createdAt = `2025-01-01T00:00:${String(agentCounter).padStart(2, '0')}Z`
|
|
157
|
+
const id = `agent-${agentCounter}`
|
|
158
|
+
const agent = { id, name, type }
|
|
159
|
+
agentRegistry.set(createdAt, agent)
|
|
160
|
+
broadcast('agent:added', { ...agent, createdAt })
|
|
161
|
+
return { ...agent, createdAt }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function listAgents(): Array<{ id: string; name: string; type: string; terminated: boolean }> {
|
|
165
|
+
return [...agentRegistry.entries()].map(([createdAt, agent]) => ({
|
|
166
|
+
...agent,
|
|
167
|
+
terminated: terminatedAgents.has(createdAt),
|
|
168
|
+
})).filter(a => !a.terminated)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function terminateAgent(createdAt: string) {
|
|
172
|
+
if (!agentRegistry.has(createdAt)) throw new Error('Agent not found')
|
|
173
|
+
terminatedAgents.add(createdAt)
|
|
174
|
+
const agent = agentRegistry.get(createdAt)!
|
|
175
|
+
broadcast('agent:removed', { id: agent.id })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('spawns an agent and adds to registry', () => {
|
|
179
|
+
const agent = spawnAgent('coder', 'my-coder')
|
|
180
|
+
expect(agent.type).toBe('coder')
|
|
181
|
+
expect(agent.name).toBe('my-coder')
|
|
182
|
+
expect(agentRegistry.size).toBe(1)
|
|
183
|
+
expect(broadcastEvents[0].type).toBe('agent:added')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('lists active agents (excludes terminated)', () => {
|
|
187
|
+
const a1 = spawnAgent('coder', 'coder-1')
|
|
188
|
+
const a2 = spawnAgent('tester', 'tester-1')
|
|
189
|
+
|
|
190
|
+
let agents = listAgents()
|
|
191
|
+
expect(agents).toHaveLength(2)
|
|
192
|
+
|
|
193
|
+
terminateAgent(a1.createdAt)
|
|
194
|
+
agents = listAgents()
|
|
195
|
+
expect(agents).toHaveLength(1)
|
|
196
|
+
expect(agents[0].name).toBe('tester-1')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('terminate broadcasts agent:removed event', () => {
|
|
200
|
+
const a1 = spawnAgent('coder', 'coder-1')
|
|
201
|
+
terminateAgent(a1.createdAt)
|
|
202
|
+
const removeEvents = broadcastEvents.filter(e => e.type === 'agent:removed')
|
|
203
|
+
expect(removeEvents).toHaveLength(1)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('throws when terminating unknown agent', () => {
|
|
207
|
+
expect(() => terminateAgent('unknown-time')).toThrow('not found')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('supports spawning multiple agent types', () => {
|
|
211
|
+
spawnAgent('coder', 'c1')
|
|
212
|
+
spawnAgent('researcher', 'r1')
|
|
213
|
+
spawnAgent('tester', 't1')
|
|
214
|
+
spawnAgent('reviewer', 'rev1')
|
|
215
|
+
|
|
216
|
+
const agents = listAgents()
|
|
217
|
+
expect(agents).toHaveLength(4)
|
|
218
|
+
expect(agents.map(a => a.type).sort()).toEqual(['coder', 'researcher', 'reviewer', 'tester'])
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ── Swarm lifecycle: init → status → shutdown ───────────────────────────
|
|
223
|
+
|
|
224
|
+
describe('E2E: Swarm lifecycle (init → status → shutdown)', () => {
|
|
225
|
+
let swarmConfig = {
|
|
226
|
+
id: '', topology: '', strategy: '', maxAgents: 0, createdAt: '', shutdown: true,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function initSwarm(topology: string, maxAgents: number, strategy = 'round-robin') {
|
|
230
|
+
swarmConfig = {
|
|
231
|
+
id: `swarm-${Date.now()}`,
|
|
232
|
+
topology,
|
|
233
|
+
strategy,
|
|
234
|
+
maxAgents,
|
|
235
|
+
createdAt: new Date().toISOString(),
|
|
236
|
+
shutdown: false,
|
|
237
|
+
}
|
|
238
|
+
broadcast('swarm:status', {
|
|
239
|
+
status: 'active', id: swarmConfig.id, topology, maxAgents, strategy,
|
|
240
|
+
})
|
|
241
|
+
return swarmConfig
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getSwarmStatus() {
|
|
245
|
+
if (swarmConfig.shutdown) {
|
|
246
|
+
return { status: 'inactive', id: '', topology: '', maxAgents: 0 }
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
status: 'active',
|
|
250
|
+
id: swarmConfig.id,
|
|
251
|
+
topology: swarmConfig.topology,
|
|
252
|
+
maxAgents: swarmConfig.maxAgents,
|
|
253
|
+
strategy: swarmConfig.strategy,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function shutdownSwarm() {
|
|
258
|
+
swarmConfig.shutdown = true
|
|
259
|
+
broadcast('swarm:status', { status: 'inactive' })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
swarmConfig = { id: '', topology: '', strategy: '', maxAgents: 0, createdAt: '', shutdown: true }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('initializes a swarm', () => {
|
|
267
|
+
const config = initSwarm('mesh', 8, 'round-robin')
|
|
268
|
+
expect(config.topology).toBe('mesh')
|
|
269
|
+
expect(config.maxAgents).toBe(8)
|
|
270
|
+
expect(config.shutdown).toBe(false)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('reports active status after init', () => {
|
|
274
|
+
initSwarm('hierarchical', 4)
|
|
275
|
+
const status = getSwarmStatus()
|
|
276
|
+
expect(status.status).toBe('active')
|
|
277
|
+
expect(status.topology).toBe('hierarchical')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('reports inactive status after shutdown', () => {
|
|
281
|
+
initSwarm('mesh', 8)
|
|
282
|
+
shutdownSwarm()
|
|
283
|
+
const status = getSwarmStatus()
|
|
284
|
+
expect(status.status).toBe('inactive')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('full lifecycle broadcasts correct events', () => {
|
|
288
|
+
initSwarm('star', 6)
|
|
289
|
+
shutdownSwarm()
|
|
290
|
+
expect(broadcastEvents).toHaveLength(2)
|
|
291
|
+
expect(broadcastEvents[0].type).toBe('swarm:status')
|
|
292
|
+
expect((broadcastEvents[0].payload as any).status).toBe('active')
|
|
293
|
+
expect((broadcastEvents[1].payload as any).status).toBe('inactive')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('can re-initialize after shutdown', () => {
|
|
297
|
+
initSwarm('mesh', 8)
|
|
298
|
+
shutdownSwarm()
|
|
299
|
+
initSwarm('hierarchical', 12)
|
|
300
|
+
const status = getSwarmStatus()
|
|
301
|
+
expect(status.status).toBe('active')
|
|
302
|
+
expect(status.topology).toBe('hierarchical')
|
|
303
|
+
expect(status.maxAgents).toBe(12)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// ── Session lifecycle: save → list → restore → delete ───────────────────
|
|
308
|
+
|
|
309
|
+
describe('E2E: Session lifecycle (save → list → restore → delete)', () => {
|
|
310
|
+
let sessionCounter = 0
|
|
311
|
+
function saveSession(name: string): SessionRecord {
|
|
312
|
+
sessionCounter++
|
|
313
|
+
const id = `sess-${sessionCounter}`
|
|
314
|
+
const session: SessionRecord = {
|
|
315
|
+
id, name, status: 'saved', createdAt: new Date().toISOString(),
|
|
316
|
+
agentCount: agentRegistry.size, taskCount: taskStore.size,
|
|
317
|
+
}
|
|
318
|
+
sessionStore.set(id, session)
|
|
319
|
+
broadcast('session:list', [...sessionStore.values()])
|
|
320
|
+
return session
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function listSessions(): SessionRecord[] {
|
|
324
|
+
return [...sessionStore.values()]
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function restoreSession(id: string): SessionRecord {
|
|
328
|
+
const session = sessionStore.get(id)
|
|
329
|
+
if (!session) throw new Error('Session not found')
|
|
330
|
+
session.status = 'restored'
|
|
331
|
+
broadcast('session:active', session)
|
|
332
|
+
return session
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function deleteSession(id: string) {
|
|
336
|
+
if (!sessionStore.has(id)) throw new Error('Session not found')
|
|
337
|
+
sessionStore.delete(id)
|
|
338
|
+
broadcast('session:list', [...sessionStore.values()])
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
it('saves a session', () => {
|
|
342
|
+
const session = saveSession('morning-session')
|
|
343
|
+
expect(session.status).toBe('saved')
|
|
344
|
+
expect(session.name).toBe('morning-session')
|
|
345
|
+
expect(sessionStore.size).toBe(1)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('lists all sessions', () => {
|
|
349
|
+
saveSession('session-1')
|
|
350
|
+
saveSession('session-2')
|
|
351
|
+
const sessions = listSessions()
|
|
352
|
+
expect(sessions).toHaveLength(2)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('restores a session', () => {
|
|
356
|
+
const saved = saveSession('my-session')
|
|
357
|
+
const restored = restoreSession(saved.id)
|
|
358
|
+
expect(restored.status).toBe('restored')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('deletes a session', () => {
|
|
362
|
+
const s1 = saveSession('temp')
|
|
363
|
+
deleteSession(s1.id)
|
|
364
|
+
expect(sessionStore.size).toBe(0)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('throws when restoring non-existent session', () => {
|
|
368
|
+
expect(() => restoreSession('bad-id')).toThrow('not found')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('throws when deleting non-existent session', () => {
|
|
372
|
+
expect(() => deleteSession('bad-id')).toThrow('not found')
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// ── Workflow lifecycle ──────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
describe('E2E: Workflow lifecycle', () => {
|
|
379
|
+
function createWorkflow(name: string, steps: string[]): WorkflowRecord {
|
|
380
|
+
const id = `wf-${Date.now()}`
|
|
381
|
+
const workflow: WorkflowRecord = {
|
|
382
|
+
id, name, status: 'draft', createdAt: new Date().toISOString(),
|
|
383
|
+
steps: steps.map((s, i) => ({ id: `step-${i}`, name: s, status: 'pending' })),
|
|
384
|
+
}
|
|
385
|
+
workflowStore.set(id, workflow)
|
|
386
|
+
broadcast('workflow:added', workflow)
|
|
387
|
+
return workflow
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function executeWorkflow(id: string): WorkflowRecord {
|
|
391
|
+
const wf = workflowStore.get(id)
|
|
392
|
+
if (!wf) throw new Error('Workflow not found')
|
|
393
|
+
wf.status = 'running'
|
|
394
|
+
for (const step of wf.steps) step.status = 'running'
|
|
395
|
+
broadcast('workflow:updated', wf)
|
|
396
|
+
return wf
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function completeWorkflow(id: string): WorkflowRecord {
|
|
400
|
+
const wf = workflowStore.get(id)
|
|
401
|
+
if (!wf) throw new Error('Workflow not found')
|
|
402
|
+
wf.status = 'completed'
|
|
403
|
+
for (const step of wf.steps) step.status = 'completed'
|
|
404
|
+
broadcast('workflow:updated', wf)
|
|
405
|
+
return wf
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
it('creates a workflow with steps', () => {
|
|
409
|
+
const wf = createWorkflow('deploy-pipeline', ['build', 'test', 'deploy'])
|
|
410
|
+
expect(wf.steps).toHaveLength(3)
|
|
411
|
+
expect(wf.status).toBe('draft')
|
|
412
|
+
expect(wf.steps[0].name).toBe('build')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('executes a workflow', () => {
|
|
416
|
+
const wf = createWorkflow('ci', ['lint', 'test'])
|
|
417
|
+
const running = executeWorkflow(wf.id)
|
|
418
|
+
expect(running.status).toBe('running')
|
|
419
|
+
expect(running.steps.every(s => s.status === 'running')).toBe(true)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('completes a workflow', () => {
|
|
423
|
+
const wf = createWorkflow('ci', ['lint', 'test'])
|
|
424
|
+
executeWorkflow(wf.id)
|
|
425
|
+
const completed = completeWorkflow(wf.id)
|
|
426
|
+
expect(completed.status).toBe('completed')
|
|
427
|
+
expect(completed.steps.every(s => s.status === 'completed')).toBe(true)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('full lifecycle broadcasts correct events', () => {
|
|
431
|
+
const wf = createWorkflow('ci', ['test'])
|
|
432
|
+
executeWorkflow(wf.id)
|
|
433
|
+
completeWorkflow(wf.id)
|
|
434
|
+
expect(broadcastEvents.map(e => e.type)).toEqual([
|
|
435
|
+
'workflow:added', 'workflow:updated', 'workflow:updated',
|
|
436
|
+
])
|
|
437
|
+
})
|
|
438
|
+
})
|