rufloui 0.3.1 → 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/CHANGELOG.md +14 -0
- package/CLAUDE.md +6 -6
- package/README.md +13 -9
- 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 +308 -21
- 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/AgentsPanel.tsx +1 -1
- package/src/frontend/pages/TasksPanel.tsx +60 -3
- package/src/frontend/pages/WebhooksPanel.tsx +282 -116
- package/src/frontend/pages/WorkflowsPanel.tsx +1 -1
- package/src/frontend/types.ts +12 -1
- package/vite.config.ts +3 -3
- package/vitest.config.ts +1 -0
- package/frontend +0 -0
- package/{,+ +0 -0
- /package/{Webhooks) → {}),} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.2] - 2026-03-12
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Port configuration** — Backend moved from 3001 to **28580**, frontend from 5173 to **28588**, daemon from 3002 to **28581**
|
|
14
|
+
- Avoids conflicts with reserved port ranges on Windows (Hyper-V, Docker, antivirus)
|
|
15
|
+
- All documentation, CORS config, WebSocket connections, and Vite proxy updated accordingly
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Workflow cancel** — Now properly updates local store, kills running processes, and cancels linked tasks
|
|
20
|
+
- **Task cancel** — Now kills running `claude -p` processes and cancels linked workflows
|
|
21
|
+
- **Workflow delete** — Removes from local store even when CLI fails
|
|
22
|
+
|
|
9
23
|
## [0.3.1] - 2026-03-11
|
|
10
24
|
|
|
11
25
|
### Added
|
package/CLAUDE.md
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npm install
|
|
17
|
-
npm run dev # Starts both frontend (Vite :
|
|
17
|
+
npm run dev # Starts both frontend (Vite :28588) and backend (Express :28580)
|
|
18
18
|
# Or individually:
|
|
19
|
-
npm run dev:frontend # Vite dev server on port
|
|
20
|
-
npm run dev:backend # Express API on port
|
|
19
|
+
npm run dev:frontend # Vite dev server on port 28588
|
|
20
|
+
npm run dev:backend # Express API on port 28580 (tsx watch, auto-reloads)
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
The frontend proxies `/api/*` and `/ws` to `localhost:
|
|
23
|
+
The frontend proxies `/api/*` and `/ws` to `localhost:28580` via Vite config.
|
|
24
24
|
|
|
25
25
|
## Project Structure
|
|
26
26
|
|
|
@@ -141,7 +141,7 @@ The backend is a single Express file that wraps CLI commands, manages state, and
|
|
|
141
141
|
|
|
142
142
|
### WebSocket Events
|
|
143
143
|
|
|
144
|
-
- Path: `ws://localhost:
|
|
144
|
+
- Path: `ws://localhost:28580/ws`
|
|
145
145
|
- **Broadcast events**: `swarm:status`, `agent:activity`, `agent:output`, `task:added`, `task:updated`, `task:output`, `workflow:added`, `workflow:updated`, `session:list`, `performance:metrics`, `viz:update`, `swarm-monitor:update`, `log`
|
|
146
146
|
- Frontend connects in `App.tsx` and dispatches to Zustand store
|
|
147
147
|
|
|
@@ -255,7 +255,7 @@ These are critical to understand when modifying the backend:
|
|
|
255
255
|
- ALWAYS read a file before editing it
|
|
256
256
|
- NEVER commit secrets, credentials, or .env files
|
|
257
257
|
- Keep files under 500 lines where possible (server.ts is an exception at ~2200 lines)
|
|
258
|
-
- After editing `server.ts`, the backend must be restarted: `npx kill-port
|
|
258
|
+
- After editing `server.ts`, the backend must be restarted: `npx kill-port 28580; npx tsx src/backend/server.ts`
|
|
259
259
|
- Frontend hot-reloads automatically via Vite
|
|
260
260
|
- When API returns `{ items: [...] }` vs `[...]`, always normalize before setting store
|
|
261
261
|
|
package/README.md
CHANGED
|
@@ -47,14 +47,18 @@ npm run dev
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
This starts:
|
|
50
|
-
- **Frontend** (Vite) on `http://localhost:
|
|
51
|
-
- **Backend** (Express + WebSocket) on `http://localhost:
|
|
50
|
+
- **Frontend** (Vite) on `http://localhost:28588`
|
|
51
|
+
- **Backend** (Express + WebSocket) on `http://localhost:28580`
|
|
52
52
|
|
|
53
53
|
The frontend proxies `/api/*` and `/ws` to the backend automatically.
|
|
54
54
|
|
|
55
|
-
### Install via
|
|
55
|
+
### Install via npm
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
```bash
|
|
58
|
+
npm install rufloui
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Install via GitHub Packages
|
|
58
62
|
|
|
59
63
|
```bash
|
|
60
64
|
# Configure npm to use GitHub Packages for the @mario-pb scope
|
|
@@ -64,14 +68,14 @@ echo "@mario-pb:registry=https://npm.pkg.github.com" >> .npmrc
|
|
|
64
68
|
npm install @mario-pb/rufloui
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
> **Note:**
|
|
71
|
+
> **Note:** GitHub Packages requires a personal access token with `read:packages` scope.
|
|
68
72
|
> Add it to your `~/.npmrc`: `//npm.pkg.github.com/:_authToken=YOUR_TOKEN`
|
|
69
73
|
|
|
70
74
|
### Individual Services
|
|
71
75
|
|
|
72
76
|
```bash
|
|
73
|
-
npm run dev:frontend # Vite dev server on port
|
|
74
|
-
npm run dev:backend # Express API on port
|
|
77
|
+
npm run dev:frontend # Vite dev server on port 28588
|
|
78
|
+
npm run dev:backend # Express API on port 28580 (auto-reloads)
|
|
75
79
|
```
|
|
76
80
|
|
|
77
81
|
### Production Build
|
|
@@ -123,7 +127,7 @@ src/
|
|
|
123
127
|
```
|
|
124
128
|
Browser (React 19) Express Backend
|
|
125
129
|
┌────────────────────┐ ┌────────────────────────┐
|
|
126
|
-
│ Vite :
|
|
130
|
+
│ Vite :28588 │───REST /api──>│ Express :28580 │
|
|
127
131
|
│ Zustand Store │<──WebSocket──>│ WebSocket Server │
|
|
128
132
|
│ sessionStorage │ │ │
|
|
129
133
|
└────────────────────┘ │ ┌──────────────────┐ │
|
|
@@ -161,7 +165,7 @@ Once the app is running, here's how to go from zero to a working multi-agent swa
|
|
|
161
165
|
2. **Spawn agents** — Go to **Agents**, select a type (e.g. `coder`), give it a name, and click **Spawn**. Repeat for other roles you need (`researcher`, `tester`, etc.).
|
|
162
166
|
3. **Create a task** — Go to **Tasks**, click **Create Task**, fill in a title and description (e.g. "Write a fibonacci function in Python with tests").
|
|
163
167
|
4. **Assign to swarm** — On the task card, click **Assign to Swarm**. The multi-agent pipeline kicks in: a coordinator plans subtasks, specialist agents execute them in parallel waves.
|
|
164
|
-
5. **Watch it live** — Switch to **Swarm Monitor** to see agent cards light up with real-time output and the orange working glow animation.
|
|
168
|
+
5. **Watch it live** — Switch to **Swarm Monitor** to see agent cards light up with real-time output and the orange working glow animation.
|
|
165
169
|
6. **Continue if needed** — When a task completes, click **Continue Task** to send a follow-up instruction with full context from the previous run.
|
|
166
170
|
|
|
167
171
|
## Prerequisites
|
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
|
+
})
|