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.
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../store', () => ({
4
+ useStore: { getState: () => ({ addLog: vi.fn() }) },
5
+ }))
6
+
7
+ const mockFetch = vi.fn()
8
+ vi.stubGlobal('fetch', mockFetch)
9
+
10
+ // Import after mocks are set up
11
+ const { api } = await import('../api')
12
+
13
+ beforeEach(() => {
14
+ mockFetch.mockReset()
15
+ mockFetch.mockResolvedValue({
16
+ ok: true,
17
+ json: () => Promise.resolve({ status: 'ok' }),
18
+ })
19
+ })
20
+
21
+ describe('api.system', () => {
22
+ it('health() calls GET /api/system/health', async () => {
23
+ await api.system.health()
24
+ expect(mockFetch).toHaveBeenCalledWith(
25
+ '/api/system/health',
26
+ expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
27
+ )
28
+ })
29
+
30
+ it('info() calls GET /api/system/info', async () => {
31
+ await api.system.info()
32
+ expect(mockFetch).toHaveBeenCalledWith('/api/system/info', expect.any(Object))
33
+ })
34
+ })
35
+
36
+ describe('api.agents', () => {
37
+ it('spawn() sends POST with type and name', async () => {
38
+ await api.agents.spawn({ type: 'coder', name: 'my-coder' })
39
+ expect(mockFetch).toHaveBeenCalledWith(
40
+ '/api/agents/spawn',
41
+ expect.objectContaining({
42
+ method: 'POST',
43
+ body: JSON.stringify({ type: 'coder', name: 'my-coder' }),
44
+ }),
45
+ )
46
+ })
47
+
48
+ it('list() calls GET /api/agents', async () => {
49
+ await api.agents.list()
50
+ expect(mockFetch).toHaveBeenCalledWith('/api/agents', expect.any(Object))
51
+ })
52
+
53
+ it('terminate() sends POST to agent endpoint', async () => {
54
+ await api.agents.terminate('agent-123')
55
+ expect(mockFetch).toHaveBeenCalledWith('/api/agents/agent-123/terminate', expect.objectContaining({ method: 'POST' }))
56
+ })
57
+ })
58
+
59
+ describe('api.tasks', () => {
60
+ it('create() sends POST with task data', async () => {
61
+ await api.tasks.create({ title: 'My task', description: 'Do stuff', priority: 'high' })
62
+ expect(mockFetch).toHaveBeenCalledWith(
63
+ '/api/tasks',
64
+ expect.objectContaining({
65
+ method: 'POST',
66
+ body: JSON.stringify({ title: 'My task', description: 'Do stuff', priority: 'high' }),
67
+ }),
68
+ )
69
+ })
70
+
71
+ it('list() calls GET /api/tasks', async () => {
72
+ await api.tasks.list()
73
+ expect(mockFetch).toHaveBeenCalledWith('/api/tasks', expect.any(Object))
74
+ })
75
+ })
76
+
77
+ describe('api.memory', () => {
78
+ it('store() sends POST with key-value data', async () => {
79
+ await api.memory.store({ key: 'k', value: 'v', namespace: 'ns', tags: ['t1'] })
80
+ expect(mockFetch).toHaveBeenCalledWith(
81
+ '/api/memory',
82
+ expect.objectContaining({
83
+ method: 'POST',
84
+ body: JSON.stringify({ key: 'k', value: 'v', namespace: 'ns', tags: ['t1'] }),
85
+ }),
86
+ )
87
+ })
88
+
89
+ it('search() sends POST with query', async () => {
90
+ await api.memory.search('hello', 'ns', 10)
91
+ expect(mockFetch).toHaveBeenCalledWith(
92
+ '/api/memory/search',
93
+ expect.objectContaining({
94
+ method: 'POST',
95
+ body: JSON.stringify({ query: 'hello', namespace: 'ns', limit: 10 }),
96
+ }),
97
+ )
98
+ })
99
+ })
100
+
101
+ describe('api.swarm', () => {
102
+ it('init() sends POST with topology options', async () => {
103
+ await api.swarm.init({ topology: 'mesh', maxAgents: 8 })
104
+ expect(mockFetch).toHaveBeenCalledWith(
105
+ '/api/swarm/init',
106
+ expect.objectContaining({
107
+ method: 'POST',
108
+ body: JSON.stringify({ topology: 'mesh', maxAgents: 8 }),
109
+ }),
110
+ )
111
+ })
112
+
113
+ it('status() calls GET /api/swarm/status', async () => {
114
+ await api.swarm.status()
115
+ expect(mockFetch).toHaveBeenCalledWith('/api/swarm/status', expect.any(Object))
116
+ })
117
+
118
+ it('shutdown() sends POST /api/swarm/shutdown', async () => {
119
+ await api.swarm.shutdown()
120
+ expect(mockFetch).toHaveBeenCalledWith('/api/swarm/shutdown', expect.objectContaining({ method: 'POST' }))
121
+ })
122
+ })
123
+
124
+ describe('api.sessions', () => {
125
+ it('list() calls GET /api/sessions', async () => {
126
+ await api.sessions.list()
127
+ expect(mockFetch).toHaveBeenCalledWith('/api/sessions', expect.any(Object))
128
+ })
129
+
130
+ it('save() sends POST with name', async () => {
131
+ await api.sessions.save('my-session')
132
+ expect(mockFetch).toHaveBeenCalledWith(
133
+ '/api/sessions/save',
134
+ expect.objectContaining({
135
+ method: 'POST',
136
+ body: JSON.stringify({ name: 'my-session' }),
137
+ }),
138
+ )
139
+ })
140
+
141
+ it('restore() sends POST to session endpoint', async () => {
142
+ await api.sessions.restore('sess-1')
143
+ expect(mockFetch).toHaveBeenCalledWith(
144
+ '/api/sessions/sess-1/restore',
145
+ expect.objectContaining({ method: 'POST' }),
146
+ )
147
+ })
148
+
149
+ it('delete() sends DELETE to session endpoint', async () => {
150
+ await api.sessions.delete('sess-1')
151
+ expect(mockFetch).toHaveBeenCalledWith(
152
+ '/api/sessions/sess-1',
153
+ expect.objectContaining({ method: 'DELETE' }),
154
+ )
155
+ })
156
+ })
157
+
158
+ describe('api.webhooks', () => {
159
+ it('getGitHubConfig() calls GET', async () => {
160
+ await api.webhooks.getGitHubConfig()
161
+ expect(mockFetch).toHaveBeenCalledWith('/api/webhooks/github/config', expect.any(Object))
162
+ })
163
+
164
+ it('setGitHubConfig() sends PUT with config', async () => {
165
+ await api.webhooks.setGitHubConfig({ enabled: true, repos: ['a/b'] })
166
+ expect(mockFetch).toHaveBeenCalledWith(
167
+ '/api/webhooks/github/config',
168
+ expect.objectContaining({
169
+ method: 'PUT',
170
+ body: JSON.stringify({ enabled: true, repos: ['a/b'] }),
171
+ }),
172
+ )
173
+ })
174
+
175
+ it('getGitHubEvents() calls GET', async () => {
176
+ await api.webhooks.getGitHubEvents()
177
+ expect(mockFetch).toHaveBeenCalledWith('/api/webhooks/github/events', expect.any(Object))
178
+ })
179
+
180
+ it('testGitHub() sends POST', async () => {
181
+ await api.webhooks.testGitHub()
182
+ expect(mockFetch).toHaveBeenCalledWith(
183
+ '/api/webhooks/github/test',
184
+ expect.objectContaining({ method: 'POST' }),
185
+ )
186
+ })
187
+
188
+ it('getGitLabConfig() calls GET', async () => {
189
+ await api.webhooks.getGitLabConfig()
190
+ expect(mockFetch).toHaveBeenCalledWith('/api/webhooks/gitlab/config', expect.any(Object))
191
+ })
192
+
193
+ it('testGitLab() sends POST', async () => {
194
+ await api.webhooks.testGitLab()
195
+ expect(mockFetch).toHaveBeenCalledWith(
196
+ '/api/webhooks/gitlab/test',
197
+ expect.objectContaining({ method: 'POST' }),
198
+ )
199
+ })
200
+ })
201
+
202
+ describe('api.workflows', () => {
203
+ it('list() calls GET /api/workflows', async () => {
204
+ await api.workflows.list()
205
+ expect(mockFetch).toHaveBeenCalledWith('/api/workflows', expect.any(Object))
206
+ })
207
+
208
+ it('create() sends POST with name and steps', async () => {
209
+ await api.workflows.create({ name: 'ci', steps: [{ action: 'test' }] })
210
+ expect(mockFetch).toHaveBeenCalledWith(
211
+ '/api/workflows',
212
+ expect.objectContaining({
213
+ method: 'POST',
214
+ body: JSON.stringify({ name: 'ci', steps: [{ action: 'test' }] }),
215
+ }),
216
+ )
217
+ })
218
+
219
+ it('execute() sends POST to workflow execute', async () => {
220
+ await api.workflows.execute('wf-1')
221
+ expect(mockFetch).toHaveBeenCalledWith(
222
+ '/api/workflows/wf-1/execute',
223
+ expect.objectContaining({ method: 'POST' }),
224
+ )
225
+ })
226
+
227
+ it('cancel() sends POST to workflow cancel', async () => {
228
+ await api.workflows.cancel('wf-1')
229
+ expect(mockFetch).toHaveBeenCalledWith(
230
+ '/api/workflows/wf-1/cancel',
231
+ expect.objectContaining({ method: 'POST' }),
232
+ )
233
+ })
234
+
235
+ it('delete() sends DELETE', async () => {
236
+ await api.workflows.delete('wf-1')
237
+ expect(mockFetch).toHaveBeenCalledWith(
238
+ '/api/workflows/wf-1',
239
+ expect.objectContaining({ method: 'DELETE' }),
240
+ )
241
+ })
242
+ })
243
+
244
+ describe('api.config', () => {
245
+ it('list() calls GET /api/config', async () => {
246
+ await api.config.list()
247
+ expect(mockFetch).toHaveBeenCalledWith('/api/config', expect.any(Object))
248
+ })
249
+
250
+ it('set() sends PUT with value', async () => {
251
+ await api.config.set('theme', 'dark')
252
+ expect(mockFetch).toHaveBeenCalledWith(
253
+ '/api/config/theme',
254
+ expect.objectContaining({
255
+ method: 'PUT',
256
+ body: JSON.stringify({ value: 'dark' }),
257
+ }),
258
+ )
259
+ })
260
+
261
+ it('reset() sends POST', async () => {
262
+ await api.config.reset()
263
+ expect(mockFetch).toHaveBeenCalledWith(
264
+ '/api/config/reset',
265
+ expect.objectContaining({ method: 'POST' }),
266
+ )
267
+ })
268
+ })
269
+
270
+ describe('api.performance', () => {
271
+ it('metrics() calls GET', async () => {
272
+ await api.performance.metrics()
273
+ expect(mockFetch).toHaveBeenCalledWith('/api/performance/metrics', expect.any(Object))
274
+ })
275
+
276
+ it('benchmark() sends POST', async () => {
277
+ await api.performance.benchmark({ type: 'full' })
278
+ expect(mockFetch).toHaveBeenCalledWith(
279
+ '/api/performance/benchmark',
280
+ expect.objectContaining({ method: 'POST' }),
281
+ )
282
+ })
283
+ })
284
+
285
+ describe('api.agents — extended', () => {
286
+ it('terminateAll() sends POST /api/agents/terminate-all', async () => {
287
+ await api.agents.terminateAll()
288
+ expect(mockFetch).toHaveBeenCalledWith(
289
+ '/api/agents/terminate-all',
290
+ expect.objectContaining({ method: 'POST' }),
291
+ )
292
+ })
293
+
294
+ it('status() calls GET /api/agents/:id/status', async () => {
295
+ await api.agents.status('a1')
296
+ expect(mockFetch).toHaveBeenCalledWith('/api/agents/a1/status', expect.any(Object))
297
+ })
298
+ })
299
+
300
+ describe('api.tasks — extended', () => {
301
+ it('assign() sends POST with agentId', async () => {
302
+ await api.tasks.assign('t1', 'a1')
303
+ expect(mockFetch).toHaveBeenCalledWith(
304
+ '/api/tasks/t1/assign',
305
+ expect.objectContaining({
306
+ method: 'POST',
307
+ body: JSON.stringify({ agentId: 'a1' }),
308
+ }),
309
+ )
310
+ })
311
+
312
+ it('complete() sends POST with result', async () => {
313
+ await api.tasks.complete('t1', 'done')
314
+ expect(mockFetch).toHaveBeenCalledWith(
315
+ '/api/tasks/t1/complete',
316
+ expect.objectContaining({
317
+ method: 'POST',
318
+ body: JSON.stringify({ result: 'done' }),
319
+ }),
320
+ )
321
+ })
322
+
323
+ it('cancel() sends POST', async () => {
324
+ await api.tasks.cancel('t1')
325
+ expect(mockFetch).toHaveBeenCalledWith(
326
+ '/api/tasks/t1/cancel',
327
+ expect.objectContaining({ method: 'POST' }),
328
+ )
329
+ })
330
+
331
+ it('output() calls GET with tail param', async () => {
332
+ await api.tasks.output('t1', 100)
333
+ expect(mockFetch).toHaveBeenCalledWith(
334
+ '/api/tasks/t1/output?tail=100',
335
+ expect.any(Object),
336
+ )
337
+ })
338
+
339
+ it('cleanCompleted() sends POST', async () => {
340
+ await api.tasks.cleanCompleted()
341
+ expect(mockFetch).toHaveBeenCalledWith(
342
+ '/api/tasks/clean-completed',
343
+ expect.objectContaining({ method: 'POST' }),
344
+ )
345
+ })
346
+ })
347
+
348
+ describe('error handling', () => {
349
+ it('throws on non-OK response', async () => {
350
+ mockFetch.mockResolvedValueOnce({
351
+ ok: false,
352
+ statusText: 'Internal Server Error',
353
+ json: () => Promise.resolve({ error: 'something broke' }),
354
+ })
355
+ await expect(api.system.health()).rejects.toThrow('something broke')
356
+ })
357
+
358
+ it('throws with statusText when json parse fails', async () => {
359
+ mockFetch.mockResolvedValueOnce({
360
+ ok: false,
361
+ statusText: 'Bad Gateway',
362
+ json: () => Promise.reject(new Error('parse error')),
363
+ })
364
+ await expect(api.system.info()).rejects.toThrow('Bad Gateway')
365
+ })
366
+
367
+ it('throws timeout error when request takes too long', async () => {
368
+ // Simulate an AbortError
369
+ const abortError = new DOMException('The operation was aborted', 'AbortError')
370
+ mockFetch.mockRejectedValueOnce(abortError)
371
+ // The request function catches AbortError and rethrows with a message
372
+ // But since our mock bypasses the controller, we test the DOMException directly
373
+ await expect(api.system.info()).rejects.toThrow()
374
+ })
375
+ })
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Button } from '../components/ui/Button'
4
+ import { Card } from '../components/ui/Card'
5
+ import { StatusBadge } from '../components/ui/StatusBadge'
6
+
7
+ // ── Button ──────────────────────────────────────────────────────────────
8
+
9
+ describe('Button', () => {
10
+ it('renders children text', () => {
11
+ render(<Button>Click me</Button>)
12
+ expect(screen.getByText('Click me')).toBeInTheDocument()
13
+ })
14
+
15
+ it('calls onClick when clicked', () => {
16
+ const onClick = vi.fn()
17
+ render(<Button onClick={onClick}>Go</Button>)
18
+ fireEvent.click(screen.getByText('Go'))
19
+ expect(onClick).toHaveBeenCalledTimes(1)
20
+ })
21
+
22
+ it('does not call onClick when disabled', () => {
23
+ const onClick = vi.fn()
24
+ render(<Button onClick={onClick} disabled>Go</Button>)
25
+ fireEvent.click(screen.getByText('Go'))
26
+ expect(onClick).not.toHaveBeenCalled()
27
+ })
28
+
29
+ it('does not call onClick when loading', () => {
30
+ const onClick = vi.fn()
31
+ render(<Button onClick={onClick} loading>Go</Button>)
32
+ fireEvent.click(screen.getByText('Go'))
33
+ expect(onClick).not.toHaveBeenCalled()
34
+ })
35
+
36
+ it('has disabled attribute when disabled', () => {
37
+ render(<Button disabled>Go</Button>)
38
+ expect(screen.getByRole('button')).toBeDisabled()
39
+ })
40
+
41
+ it('has disabled attribute when loading', () => {
42
+ render(<Button loading>Go</Button>)
43
+ expect(screen.getByRole('button')).toBeDisabled()
44
+ })
45
+
46
+ it('renders with different variants', () => {
47
+ const { rerender } = render(<Button variant="primary">P</Button>)
48
+ expect(screen.getByText('P')).toBeInTheDocument()
49
+ rerender(<Button variant="danger">D</Button>)
50
+ expect(screen.getByText('D')).toBeInTheDocument()
51
+ rerender(<Button variant="ghost">G</Button>)
52
+ expect(screen.getByText('G')).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders with different sizes', () => {
56
+ const { rerender } = render(<Button size="sm">S</Button>)
57
+ expect(screen.getByText('S')).toBeInTheDocument()
58
+ rerender(<Button size="md">M</Button>)
59
+ expect(screen.getByText('M')).toBeInTheDocument()
60
+ })
61
+ })
62
+
63
+ // ── Card ────────────────────────────────────────────────────────────────
64
+
65
+ describe('Card', () => {
66
+ it('renders children', () => {
67
+ render(<Card>Card content</Card>)
68
+ expect(screen.getByText('Card content')).toBeInTheDocument()
69
+ })
70
+
71
+ it('renders title when provided', () => {
72
+ render(<Card title="My Card">Body</Card>)
73
+ expect(screen.getByText('My Card')).toBeInTheDocument()
74
+ })
75
+
76
+ it('renders actions when provided', () => {
77
+ render(<Card actions={<button>Action</button>}>Body</Card>)
78
+ expect(screen.getByText('Action')).toBeInTheDocument()
79
+ })
80
+
81
+ it('renders both title and actions', () => {
82
+ render(<Card title="Title" actions={<span>Act</span>}>Body</Card>)
83
+ expect(screen.getByText('Title')).toBeInTheDocument()
84
+ expect(screen.getByText('Act')).toBeInTheDocument()
85
+ })
86
+
87
+ it('does not render header when no title or actions', () => {
88
+ const { container } = render(<Card>Just body</Card>)
89
+ // Header has the borderBottom style, body doesn't — check there's only one child div
90
+ const cardDiv = container.firstChild as HTMLElement
91
+ expect(cardDiv.children).toHaveLength(1) // only the body div
92
+ })
93
+ })
94
+
95
+ // ── StatusBadge ─────────────────────────────────────────────────────────
96
+
97
+ describe('StatusBadge', () => {
98
+ it('renders status text', () => {
99
+ render(<StatusBadge status="active" />)
100
+ expect(screen.getByText('active')).toBeInTheDocument()
101
+ })
102
+
103
+ it('renders different statuses', () => {
104
+ const { rerender } = render(<StatusBadge status="running" />)
105
+ expect(screen.getByText('running')).toBeInTheDocument()
106
+ rerender(<StatusBadge status="error" />)
107
+ expect(screen.getByText('error')).toBeInTheDocument()
108
+ rerender(<StatusBadge status="completed" />)
109
+ expect(screen.getByText('completed')).toBeInTheDocument()
110
+ })
111
+
112
+ it('shows unknown when status is empty', () => {
113
+ render(<StatusBadge status="" />)
114
+ expect(screen.getByText('unknown')).toBeInTheDocument()
115
+ })
116
+
117
+ it('renders a dot element', () => {
118
+ const { container } = render(<StatusBadge status="idle" />)
119
+ const spans = container.querySelectorAll('span')
120
+ // Outer container span > dot span + label span
121
+ expect(spans.length).toBeGreaterThanOrEqual(3)
122
+ })
123
+
124
+ it('renders with sm size', () => {
125
+ render(<StatusBadge status="active" size="sm" />)
126
+ expect(screen.getByText('active')).toBeInTheDocument()
127
+ })
128
+
129
+ it('applies correct color for each known status', () => {
130
+ const statuses = ['active', 'running', 'healthy', 'idle', 'pending', 'saved', 'error', 'failed', 'unhealthy', 'completed', 'paused', 'draft']
131
+ for (const status of statuses) {
132
+ const { unmount } = render(<StatusBadge status={status} />)
133
+ expect(screen.getByText(status)).toBeInTheDocument()
134
+ unmount()
135
+ }
136
+ })
137
+
138
+ it('handles null-ish status gracefully', () => {
139
+ render(<StatusBadge status={undefined as unknown as string} />)
140
+ expect(screen.getByText('unknown')).toBeInTheDocument()
141
+ })
142
+ })
143
+
144
+ // ── Button — hover behavior ────────────────────────────────────────────
145
+
146
+ describe('Button — hover behavior', () => {
147
+ it('changes opacity on mouseEnter and mouseLeave', () => {
148
+ render(<Button>Hover me</Button>)
149
+ const btn = screen.getByRole('button')
150
+ fireEvent.mouseEnter(btn)
151
+ expect(btn.style.opacity).toBe('0.85')
152
+ fireEvent.mouseLeave(btn)
153
+ expect(btn.style.opacity).toBe('1')
154
+ })
155
+
156
+ it('does not change opacity on hover when disabled', () => {
157
+ render(<Button disabled>No hover</Button>)
158
+ const btn = screen.getByRole('button')
159
+ fireEvent.mouseEnter(btn)
160
+ // opacity should not change (stays at disabled state)
161
+ expect(btn.style.opacity).not.toBe('0.85')
162
+ })
163
+ })
164
+
165
+ // ── Button — custom style ──────────────────────────────────────────────
166
+
167
+ describe('Button — custom style', () => {
168
+ it('merges custom style prop', () => {
169
+ render(<Button style={{ marginTop: 10 }}>Styled</Button>)
170
+ const btn = screen.getByRole('button')
171
+ expect(btn.style.marginTop).toBe('10px')
172
+ })
173
+ })
174
+
175
+ // ── Card — edge cases ──────────────────────────────────────────────────
176
+
177
+ describe('Card — edge cases', () => {
178
+ it('renders with only actions (no title)', () => {
179
+ render(<Card actions={<button>Act</button>}>Body</Card>)
180
+ expect(screen.getByText('Act')).toBeInTheDocument()
181
+ expect(screen.getByText('Body')).toBeInTheDocument()
182
+ })
183
+
184
+ it('renders with complex children', () => {
185
+ render(
186
+ <Card title="Complex">
187
+ <div data-testid="inner">
188
+ <span>Nested content</span>
189
+ </div>
190
+ </Card>
191
+ )
192
+ expect(screen.getByTestId('inner')).toBeInTheDocument()
193
+ expect(screen.getByText('Nested content')).toBeInTheDocument()
194
+ })
195
+ })