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 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.2",
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://registry.npmjs.org"
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
+ })