@polderlabs/bizar 5.3.1 → 5.4.1
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/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js +4 -0
- package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js.map +1 -0
- package/bizar-dash/dist/assets/MobileChat-9UpvDP6-.js +1 -0
- package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-9UpvDP6-.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js.map +1 -0
- package/bizar-dash/dist/assets/Toast-BlcKl_dN.js +1 -0
- package/bizar-dash/dist/assets/Toast-BlcKl_dN.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-CMPxNV8t.js} +73 -73
- package/bizar-dash/dist/assets/icons-CMPxNV8t.js.map +1 -0
- package/bizar-dash/dist/assets/main-Dhrvp9Gt.js +16 -0
- package/bizar-dash/dist/assets/main-Dhrvp9Gt.js.map +1 -0
- package/bizar-dash/dist/assets/{main-3G6mOhiK.css → main-O2tZVdHm.css} +1 -1
- package/bizar-dash/dist/assets/mobile-DDY1lquG.js +1 -0
- package/bizar-dash/dist/assets/mobile-DDY1lquG.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js +2 -0
- package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-CJnZNLy3.css} +1 -1
- package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js.map +1 -0
- package/bizar-dash/dist/index.html +8 -6
- package/bizar-dash/dist/mobile.html +4 -4
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/web/MobileApp.tsx +18 -20
- package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
- package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
- package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
- package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
- package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
- package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
- package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
- package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
- package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
- package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
- package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
- package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
- package/bizar-dash/src/web/mobile.tsx +1 -0
- package/bizar-dash/src/web/styles/main.css +388 -0
- package/bizar-dash/src/web/styles/memory.css +45 -0
- package/bizar-dash/src/web/styles/mobile-layout.css +233 -0
- package/bizar-dash/src/web/styles/mobile.css +180 -0
- package/bizar-dash/src/web/styles/settings.css +156 -0
- package/bizar-dash/src/web/styles/tasks.css +305 -0
- package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
- package/bizar-dash/tests/mobile-layout.test.tsx +332 -0
- package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
- package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
- package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
- package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
- package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
- package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
- package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
- package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
- package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
- package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js.map +0 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/mobile-misc.test.tsx
|
|
3
|
+
*
|
|
4
|
+
* v5.4 — Mobile misc views tests: Memory, Marketplace, Plugins, Eval, Doctor.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { render, screen, act, waitFor } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import React from 'react';
|
|
10
|
+
|
|
11
|
+
// ─── Mocks ─────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
vi.mock('../src/web/lib/api', () => ({
|
|
14
|
+
api: {
|
|
15
|
+
get: vi.fn().mockResolvedValue({}),
|
|
16
|
+
post: vi.fn().mockResolvedValue({ ok: true }),
|
|
17
|
+
del: vi.fn().mockResolvedValue({ ok: true }),
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Stub lucide-react icons.
|
|
22
|
+
vi.mock('lucide-react', async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal() as Record<string, unknown>;
|
|
24
|
+
const make = (name: string) => {
|
|
25
|
+
const Cmp = (props: { size?: number; 'aria-hidden'?: boolean }) =>
|
|
26
|
+
<svg data-testid={`icon-${name}`} data-size={props.size} />;
|
|
27
|
+
Cmp.displayName = name;
|
|
28
|
+
return Cmp;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
BarChart: make('BarChart'),
|
|
33
|
+
Brain: make('Brain'),
|
|
34
|
+
FileText: make('FileText'),
|
|
35
|
+
GitBranch: make('GitBranch'),
|
|
36
|
+
Search: make('Search'),
|
|
37
|
+
Mic: make('Mic'),
|
|
38
|
+
Settings: make('Settings'),
|
|
39
|
+
Network: make('Network'),
|
|
40
|
+
Clipboard: make('Clipboard'),
|
|
41
|
+
Scan: make('Scan'),
|
|
42
|
+
Store: make('Store'),
|
|
43
|
+
Puzzle: make('Puzzle'),
|
|
44
|
+
ClipboardCheck: make('ClipboardCheck'),
|
|
45
|
+
Clock: make('Clock'),
|
|
46
|
+
CheckCircle2: make('CheckCircle2'),
|
|
47
|
+
AlertTriangle: make('AlertTriangle'),
|
|
48
|
+
XCircle: make('XCircle'),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Stub heavy memory sub-panels.
|
|
53
|
+
vi.mock('../src/web/views/memory/MemoryOverview', () => ({
|
|
54
|
+
MemoryOverview: () => <div data-testid="memory-overview">MemoryOverview</div>,
|
|
55
|
+
}));
|
|
56
|
+
vi.mock('../src/web/views/memory/LightragPanel', () => ({
|
|
57
|
+
LightragPanel: () => <div data-testid="lightrag-panel">LightragPanel</div>,
|
|
58
|
+
}));
|
|
59
|
+
vi.mock('../src/web/views/memory/ObsidianPanel', () => ({
|
|
60
|
+
ObsidianPanel: () => <div data-testid="obsidian-panel">ObsidianPanel</div>,
|
|
61
|
+
}));
|
|
62
|
+
vi.mock('../src/web/views/memory/GitSyncPanel', () => ({
|
|
63
|
+
GitSyncPanel: () => <div data-testid="git-panel">GitSyncPanel</div>,
|
|
64
|
+
}));
|
|
65
|
+
vi.mock('../src/web/views/memory/SemanticSearchPanel', () => ({
|
|
66
|
+
SemanticSearchPanel: () => <div data-testid="semantic-panel">SemanticSearchPanel</div>,
|
|
67
|
+
}));
|
|
68
|
+
vi.mock('../src/web/views/memory/ConfigPanel', () => ({
|
|
69
|
+
ConfigPanel: () => <div data-testid="config-panel">ConfigPanel</div>,
|
|
70
|
+
}));
|
|
71
|
+
vi.mock('../src/web/views/memory/MemoryGraphPanel', () => ({
|
|
72
|
+
MemoryGraphPanel: () => <div data-testid="graph-panel">MemoryGraphPanel</div>,
|
|
73
|
+
}));
|
|
74
|
+
vi.mock('../src/web/views/memory/VaultFromClipboardPanel', () => ({
|
|
75
|
+
VaultFromClipboardPanel: () => <div data-testid="webclip-panel">VaultFromClipboardPanel</div>,
|
|
76
|
+
}));
|
|
77
|
+
vi.mock('../src/web/views/memory/FromScreenshotPanel', () => ({
|
|
78
|
+
FromScreenshotPanel: () => <div data-testid="screenshot-panel">FromScreenshotPanel</div>,
|
|
79
|
+
}));
|
|
80
|
+
vi.mock('../src/web/components/VoiceNotesPanel', () => ({
|
|
81
|
+
VoiceNotesPanel: () => <div data-testid="voice-panel">VoiceNotesPanel</div>,
|
|
82
|
+
}));
|
|
83
|
+
vi.mock('../src/web/components/MarketplacePluginCard', () => ({
|
|
84
|
+
MarketplacePluginCard: ({ plugin }: { plugin: { name: string } }) =>
|
|
85
|
+
<div data-testid="marketplace-card">{plugin.name}</div>,
|
|
86
|
+
}));
|
|
87
|
+
vi.mock('../src/web/components/PluginPermissions', () => ({
|
|
88
|
+
PluginPermissions: () => <div data-testid="plugin-permissions">PluginPermissions</div>,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// ─── Imports ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
import { MobileMemory } from '../src/web/mobile/MobileMemory';
|
|
94
|
+
import { MobileMarketplace } from '../src/web/mobile/MobileMarketplace';
|
|
95
|
+
import { MobilePlugins } from '../src/web/mobile/MobilePlugins';
|
|
96
|
+
import { MobileEval } from '../src/web/mobile/MobileEval';
|
|
97
|
+
import { MobileDoctor } from '../src/web/mobile/MobileDoctor';
|
|
98
|
+
import { api } from '../src/web/lib/api';
|
|
99
|
+
|
|
100
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('MobileMemory', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.clearAllMocks();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('renders all source tabs', () => {
|
|
108
|
+
render(<MobileMemory />);
|
|
109
|
+
expect(screen.getByText('Overview')).toBeInTheDocument();
|
|
110
|
+
expect(screen.getByText('LightRAG')).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByText('Obsidian')).toBeInTheDocument();
|
|
112
|
+
expect(screen.getByText('Git Sync')).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText('Search')).toBeInTheDocument();
|
|
114
|
+
expect(screen.getByText('Voice')).toBeInTheDocument();
|
|
115
|
+
expect(screen.getByText('Config')).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders Overview panel by default', () => {
|
|
119
|
+
render(<MobileMemory />);
|
|
120
|
+
expect(screen.getByTestId('memory-overview')).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('switches to LightRAG panel when tab is clicked', async () => {
|
|
124
|
+
const user = userEvent.setup();
|
|
125
|
+
render(<MobileMemory />);
|
|
126
|
+
await user.click(screen.getByText('LightRAG'));
|
|
127
|
+
expect(screen.getByTestId('lightrag-panel')).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('switches to Obsidian panel when tab is clicked', async () => {
|
|
131
|
+
const user = userEvent.setup();
|
|
132
|
+
render(<MobileMemory />);
|
|
133
|
+
await user.click(screen.getByText('Obsidian'));
|
|
134
|
+
expect(screen.getByTestId('obsidian-panel')).toBeInTheDocument();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('MobileMarketplace', () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
vi.clearAllMocks();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders plugins when API returns data', async () => {
|
|
144
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
145
|
+
plugins: [
|
|
146
|
+
{ id: 'p1', name: 'Test Plugin', version: '1.0', author: 'Test', category: 'util' },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
render(<MobileMarketplace />);
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(screen.getByText('Test Plugin')).toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('filters plugins by search term', async () => {
|
|
156
|
+
const user = userEvent.setup();
|
|
157
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
158
|
+
plugins: [
|
|
159
|
+
{ id: 'p1', name: 'Alpha Plugin', version: '1.0', author: 'A', category: 'util' },
|
|
160
|
+
{ id: 'p2', name: 'Beta Plugin', version: '1.0', author: 'B', category: 'util' },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
render(<MobileMarketplace />);
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(screen.getByText('Alpha Plugin')).toBeInTheDocument();
|
|
166
|
+
expect(screen.getByText('Beta Plugin')).toBeInTheDocument();
|
|
167
|
+
});
|
|
168
|
+
const searchInput = screen.getByPlaceholderText(/search plugins/i);
|
|
169
|
+
await act(async () => {
|
|
170
|
+
await user.type(searchInput, 'alpha');
|
|
171
|
+
});
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
expect(screen.getByText('Alpha Plugin')).toBeInTheDocument();
|
|
174
|
+
});
|
|
175
|
+
expect(screen.queryByText('Beta Plugin')).not.toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('MobilePlugins', () => {
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
vi.clearAllMocks();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('renders plugin list when API returns data', async () => {
|
|
185
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
186
|
+
plugins: [
|
|
187
|
+
{ id: 'p1', name: 'My Plugin', version: '2.0', invocations: 42 },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
render(<MobilePlugins />);
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
expect(screen.getByText('My Plugin')).toBeInTheDocument();
|
|
193
|
+
expect(screen.getByText('v2.0')).toBeInTheDocument();
|
|
194
|
+
expect(screen.getByText('42 invocations')).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('filters plugins by search term', async () => {
|
|
199
|
+
const user = userEvent.setup();
|
|
200
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
201
|
+
plugins: [
|
|
202
|
+
{ id: 'p1', name: 'Alpha Plugin', version: '1.0', invocations: 0 },
|
|
203
|
+
{ id: 'p2', name: 'Beta Plugin', version: '1.0', invocations: 0 },
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
render(<MobilePlugins />);
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(screen.getByText('Alpha Plugin')).toBeInTheDocument();
|
|
209
|
+
expect(screen.getByText('Beta Plugin')).toBeInTheDocument();
|
|
210
|
+
});
|
|
211
|
+
const searchInput = screen.getByPlaceholderText(/search installed plugins/i);
|
|
212
|
+
await act(async () => {
|
|
213
|
+
await user.type(searchInput, 'alpha');
|
|
214
|
+
});
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(screen.getByText('Alpha Plugin')).toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
expect(screen.queryByText('Beta Plugin')).not.toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('MobileEval', () => {
|
|
223
|
+
beforeEach(() => {
|
|
224
|
+
vi.clearAllMocks();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('renders Runs and Schedules tabs', () => {
|
|
228
|
+
render(<MobileEval />);
|
|
229
|
+
expect(screen.getByText('Runs')).toBeInTheDocument();
|
|
230
|
+
expect(screen.getByText('Schedules')).toBeInTheDocument();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('renders runs when API returns data', async () => {
|
|
234
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
235
|
+
runs: [
|
|
236
|
+
{ id: 'run_001', startedAt: '2026-07-05T10:00:00Z', total: 10, passed: 9, failed: 1, suitePath: '/tmp' },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
render(<MobileEval />);
|
|
240
|
+
await waitFor(() => {
|
|
241
|
+
expect(screen.getByText('run_001')).toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('switches to Schedules tab when clicked', async () => {
|
|
246
|
+
const user = userEvent.setup();
|
|
247
|
+
vi.mocked(api.get).mockResolvedValue({ runs: [] });
|
|
248
|
+
render(<MobileEval />);
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
// Runs loaded (empty)
|
|
251
|
+
expect(screen.getByText('No eval runs yet.')).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
await act(async () => {
|
|
254
|
+
await user.click(screen.getByText('Schedules'));
|
|
255
|
+
});
|
|
256
|
+
// Schedules tab is now active
|
|
257
|
+
expect(screen.getByText('Schedules')).toBeInTheDocument();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('MobileDoctor', () => {
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
vi.clearAllMocks();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('renders OK status when health is ok', async () => {
|
|
267
|
+
vi.mocked(api.get).mockResolvedValue({ status: 'ok', issues: [] });
|
|
268
|
+
render(<MobileDoctor />);
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(screen.getByText('OK')).toBeInTheDocument();
|
|
271
|
+
expect(screen.getByText(/all systems healthy/i)).toBeInTheDocument();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('renders warn status with issue count', async () => {
|
|
276
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
277
|
+
status: 'warn',
|
|
278
|
+
issues: [{ name: 'DiskSpace', status: 'warn', message: 'Low disk space' }],
|
|
279
|
+
});
|
|
280
|
+
render(<MobileDoctor />);
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(screen.getByText('WARN')).toBeInTheDocument();
|
|
283
|
+
expect(screen.getByText('Issues (1)')).toBeInTheDocument();
|
|
284
|
+
expect(screen.getByText('DiskSpace')).toBeInTheDocument();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('renders fail status with issues', async () => {
|
|
289
|
+
vi.mocked(api.get).mockResolvedValue({
|
|
290
|
+
status: 'fail',
|
|
291
|
+
issues: [{ name: 'OpenCode', status: 'fail', message: 'Not reachable' }],
|
|
292
|
+
});
|
|
293
|
+
render(<MobileDoctor />);
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(screen.getByText('FAIL')).toBeInTheDocument();
|
|
296
|
+
expect(screen.getByText('Issues (1)')).toBeInTheDocument();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('auto-refreshes via setInterval', () => {
|
|
301
|
+
// Verify the component sets up a 30-second interval by checking setInterval was called
|
|
302
|
+
// with the correct delay. We use fake timers to control the interval.
|
|
303
|
+
vi.useFakeTimers();
|
|
304
|
+
vi.mocked(api.get).mockResolvedValue({ status: 'ok', issues: [] });
|
|
305
|
+
|
|
306
|
+
// Spy on window.setInterval to verify the delay
|
|
307
|
+
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
|
|
308
|
+
|
|
309
|
+
render(<MobileDoctor />);
|
|
310
|
+
|
|
311
|
+
// The component should call setInterval with ~30 seconds
|
|
312
|
+
expect(setIntervalSpy).toHaveBeenCalled();
|
|
313
|
+
const intervalCalls = setIntervalSpy.mock.calls;
|
|
314
|
+
// Find the interval delay (30 seconds = 30000ms)
|
|
315
|
+
const hasCorrectInterval = intervalCalls.some(call => call[1] === 30_000);
|
|
316
|
+
expect(hasCorrectInterval).toBe(true);
|
|
317
|
+
|
|
318
|
+
setIntervalSpy.mockRestore();
|
|
319
|
+
vi.useRealTimers();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/mobile-overview.test.tsx
|
|
3
|
+
*
|
|
4
|
+
* v5.4 — Component tests for the mobile-optimized overview (hero + stats +
|
|
5
|
+
* recent activity + quick actions).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import { render, screen } from '@testing-library/react';
|
|
9
|
+
import userEvent from '@testing-library/user-event';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { MobileOverview } from '../src/web/mobile/MobileOverview';
|
|
12
|
+
import type { Snapshot } from '../src/web/lib/types';
|
|
13
|
+
|
|
14
|
+
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/web/lib/api', () => ({
|
|
17
|
+
api: {
|
|
18
|
+
post: vi.fn().mockResolvedValue({ ok: true }),
|
|
19
|
+
get: vi.fn().mockResolvedValue({}),
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Stub lucide icons — keep them tiny testids so we can assert presence cheaply.
|
|
24
|
+
vi.mock('lucide-react', async (importOriginal) => {
|
|
25
|
+
const actual = await importOriginal() as Record<string, unknown>;
|
|
26
|
+
const make = (name: string) => {
|
|
27
|
+
const Cmp = (props: { size?: number }) => <svg data-testid={`icon-${name}`} data-size={props.size} />;
|
|
28
|
+
Cmp.displayName = name;
|
|
29
|
+
return Cmp;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
...actual,
|
|
33
|
+
CheckSquare: make('CheckSquare'),
|
|
34
|
+
Clock: make('Clock'),
|
|
35
|
+
Bot: make('Bot'),
|
|
36
|
+
Coins: make('Coins'),
|
|
37
|
+
Plus: make('Plus'),
|
|
38
|
+
MessageSquare: make('MessageSquare'),
|
|
39
|
+
Stethoscope: make('Stethoscope'),
|
|
40
|
+
Send: make('Send'),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─── Test data ──────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const BASE_SNAPSHOT: Snapshot = {
|
|
47
|
+
overview: {
|
|
48
|
+
counts: { agents: 0, plans: 0, projects: 0, sessions: 0 },
|
|
49
|
+
recentActivity: [],
|
|
50
|
+
versions: { node: '', platform: '', projectRoot: '', bizarRoot: '' },
|
|
51
|
+
generatedAt: '',
|
|
52
|
+
},
|
|
53
|
+
agents: [],
|
|
54
|
+
artifacts: [],
|
|
55
|
+
projects: [],
|
|
56
|
+
activeProject: null,
|
|
57
|
+
config: { path: '', data: null, raw: '', exists: false },
|
|
58
|
+
settings: {
|
|
59
|
+
path: '',
|
|
60
|
+
data: {
|
|
61
|
+
theme: { mode: 'dark', accent: '#8b5cf6', success: '#3fb950', warning: '#f0883e', error: '#f85149', info: '#58a6ff', fontFamily: 'sans-serif', fontSize: 14, compactMode: false, animations: true },
|
|
62
|
+
ui: { layout: 'topnav', showHeader: true, showStatusBar: true, defaultTab: 'overview' },
|
|
63
|
+
defaultAgent: 'odin',
|
|
64
|
+
defaultModel: '',
|
|
65
|
+
notifications: { onAgentComplete: false, onPlanApproval: false },
|
|
66
|
+
dashboard: { autoLaunchWeb: false },
|
|
67
|
+
service: { enabled: false, autostart: false },
|
|
68
|
+
about: { version: '5.4.0', homepage: '', license: 'MIT' },
|
|
69
|
+
agents: { maxParallel: 6, stuckThresholdMs: 600000, autoRestart: false },
|
|
70
|
+
personalization: { displayName: '', role: '', team: '', aboutMe: '', preferences: '' },
|
|
71
|
+
workflow: { artifactsEnabled: true, agentsDecideAutonomously: false, chatAutonomous: false },
|
|
72
|
+
headroom: { enabled: false, autoInstall: true, port: 8787, host: '127.0.0.1', outputShaper: false, telemetry: false, budget: 0, backend: 'anthropic', autoStart: true, autoWrap: true, routeAllProviders: true },
|
|
73
|
+
},
|
|
74
|
+
exists: true,
|
|
75
|
+
},
|
|
76
|
+
tasks: [],
|
|
77
|
+
mods: [],
|
|
78
|
+
schedules: [],
|
|
79
|
+
providers: [],
|
|
80
|
+
mcps: [],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe('MobileOverview', () => {
|
|
86
|
+
const refreshSnapshot = vi.fn().mockResolvedValue(undefined);
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('renders hero card with input', () => {
|
|
93
|
+
render(<MobileOverview snapshot={BASE_SNAPSHOT} refreshSnapshot={refreshSnapshot} />);
|
|
94
|
+
|
|
95
|
+
expect(screen.getByRole('heading', { name: /what do you want to do/i })).toBeInTheDocument();
|
|
96
|
+
const textarea = screen.getByPlaceholderText(/implement user authentication/i);
|
|
97
|
+
expect(textarea).toBeInTheDocument();
|
|
98
|
+
expect(textarea.tagName).toBe('TEXTAREA');
|
|
99
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('disables submit button when textarea is empty', () => {
|
|
103
|
+
render(<MobileOverview snapshot={BASE_SNAPSHOT} refreshSnapshot={refreshSnapshot} />);
|
|
104
|
+
|
|
105
|
+
const submit = screen.getByRole('button', { name: /submit/i });
|
|
106
|
+
expect(submit).toBeDisabled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('enables submit button when textarea has text', async () => {
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
render(<MobileOverview snapshot={BASE_SNAPSHOT} refreshSnapshot={refreshSnapshot} />);
|
|
112
|
+
|
|
113
|
+
await user.type(screen.getByPlaceholderText(/implement user authentication/i), 'fix the bug');
|
|
114
|
+
expect(screen.getByRole('button', { name: /submit/i })).not.toBeDisabled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('renders 4 stat cards', () => {
|
|
118
|
+
render(<MobileOverview snapshot={BASE_SNAPSHOT} refreshSnapshot={refreshSnapshot} />);
|
|
119
|
+
|
|
120
|
+
expect(screen.getByText('Tasks')).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText('Schedules')).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByText('Active Agents')).toBeInTheDocument();
|
|
123
|
+
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
|
124
|
+
|
|
125
|
+
// Each stat label sits on a card with the matching modifier class.
|
|
126
|
+
expect(document.querySelectorAll('.mobile-stat-card.is-blue')).toHaveLength(1);
|
|
127
|
+
expect(document.querySelectorAll('.mobile-stat-card.is-purple')).toHaveLength(1);
|
|
128
|
+
expect(document.querySelectorAll('.mobile-stat-card.is-green')).toHaveLength(1);
|
|
129
|
+
expect(document.querySelectorAll('.mobile-stat-card.is-yellow')).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('reflects task count and active task subtext from snapshot', () => {
|
|
133
|
+
const snap: Snapshot = {
|
|
134
|
+
...BASE_SNAPSHOT,
|
|
135
|
+
tasks: [
|
|
136
|
+
{ ...BASE_SNAPSHOT.tasks[0], id: 't1', title: 'a', description: '', status: 'doing', tags: [], priority: 'normal', createdAt: '', updatedAt: '' },
|
|
137
|
+
{ ...BASE_SNAPSHOT.tasks[0], id: 't2', title: 'b', description: '', status: 'done', tags: [], priority: 'normal', createdAt: '', updatedAt: '' },
|
|
138
|
+
{ ...BASE_SNAPSHOT.tasks[0], id: 't3', title: 'c', description: '', status: 'queued', tags: [], priority: 'normal', createdAt: '', updatedAt: '' },
|
|
139
|
+
] as Snapshot['tasks'],
|
|
140
|
+
schedules: [
|
|
141
|
+
{ id: 's1', name: 'nightly', cron: '0 0 * * *', enabled: true, lastRun: null, nextRun: null, target: 'agent', agentName: 'odin', createdAt: '', updatedAt: '' },
|
|
142
|
+
] as Snapshot['schedules'],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
render(<MobileOverview snapshot={snap} refreshSnapshot={refreshSnapshot} />);
|
|
146
|
+
|
|
147
|
+
// Tasks stat — first mobile-stat-value inside the blue card.
|
|
148
|
+
const blueCard = document.querySelector('.mobile-stat-card.is-blue')!;
|
|
149
|
+
expect(blueCard.querySelector('.mobile-stat-value')?.textContent).toBe('3');
|
|
150
|
+
expect(blueCard.querySelector('.mobile-stat-subtext')?.textContent).toBe('1 active');
|
|
151
|
+
|
|
152
|
+
// Schedules stat.
|
|
153
|
+
const purpleCard = document.querySelector('.mobile-stat-card.is-purple')!;
|
|
154
|
+
expect(purpleCard.querySelector('.mobile-stat-value')?.textContent).toBe('1');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('shows recent activity when snapshot has items', () => {
|
|
158
|
+
const snap: Snapshot = {
|
|
159
|
+
...BASE_SNAPSHOT,
|
|
160
|
+
overview: {
|
|
161
|
+
...BASE_SNAPSHOT.overview,
|
|
162
|
+
recentActivity: [
|
|
163
|
+
{ ts: '2026-07-05T12:34:00.000Z', kind: 'task', title: 'hello' },
|
|
164
|
+
{ ts: '2026-07-05T12:35:00.000Z', kind: 'agent', name: 'odin' },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
render(<MobileOverview snapshot={snap} refreshSnapshot={refreshSnapshot} />);
|
|
170
|
+
|
|
171
|
+
expect(screen.getByRole('heading', { name: /recent activity/i })).toBeInTheDocument();
|
|
172
|
+
const items = document.querySelectorAll('.mobile-activity-item');
|
|
173
|
+
expect(items).toHaveLength(2);
|
|
174
|
+
expect(screen.queryByText(/no recent activity/i)).not.toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('shows empty state when there is no recent activity', () => {
|
|
178
|
+
render(<MobileOverview snapshot={BASE_SNAPSHOT} refreshSnapshot={refreshSnapshot} />);
|
|
179
|
+
|
|
180
|
+
expect(screen.getByText(/no recent activity/i)).toBeInTheDocument();
|
|
181
|
+
expect(document.querySelector('.mobile-activity-list')).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('renders quick action buttons', () => {
|
|
185
|
+
render(<MobileOverview snapshot={BASE_SNAPSHOT} refreshSnapshot={refreshSnapshot} />);
|
|
186
|
+
|
|
187
|
+
expect(screen.getByRole('button', { name: /new task/i })).toBeInTheDocument();
|
|
188
|
+
expect(screen.getByRole('button', { name: /new chat/i })).toBeInTheDocument();
|
|
189
|
+
expect(screen.getByRole('button', { name: /run doctor/i })).toBeInTheDocument();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('clamps recent activity to 8 items', () => {
|
|
193
|
+
const many: { ts: string; kind: string; title: string }[] = [];
|
|
194
|
+
for (let i = 0; i < 12; i++) {
|
|
195
|
+
many.push({ ts: `2026-07-05T12:${String(i).padStart(2, '0')}:00.000Z`, kind: 'task', title: `t${i}` });
|
|
196
|
+
}
|
|
197
|
+
const snap: Snapshot = {
|
|
198
|
+
...BASE_SNAPSHOT,
|
|
199
|
+
overview: { ...BASE_SNAPSHOT.overview, recentActivity: many },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
render(<MobileOverview snapshot={snap} refreshSnapshot={refreshSnapshot} />);
|
|
203
|
+
|
|
204
|
+
expect(document.querySelectorAll('.mobile-activity-item')).toHaveLength(8);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('handles null snapshot without crashing', () => {
|
|
208
|
+
render(<MobileOverview snapshot={null} refreshSnapshot={refreshSnapshot} />);
|
|
209
|
+
|
|
210
|
+
expect(screen.getByRole('heading', { name: /what do you want to do/i })).toBeInTheDocument();
|
|
211
|
+
expect(screen.getByText(/no recent activity/i)).toBeInTheDocument();
|
|
212
|
+
expect(screen.getByText('Tasks')).toBeInTheDocument();
|
|
213
|
+
});
|
|
214
|
+
});
|