@polderlabs/bizar 5.3.0 → 5.4.0

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.
Files changed (69) hide show
  1. package/bizar-dash/dist/assets/EnvVarsSection-DqvB8ZcJ.js +4 -0
  2. package/bizar-dash/dist/assets/EnvVarsSection-DqvB8ZcJ.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileChat-DRXP2ERQ.js +1 -0
  4. package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-DRXP2ERQ.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js.map +1 -0
  7. package/bizar-dash/dist/assets/Toast-CcZVVN4i.js +1 -0
  8. package/bizar-dash/dist/assets/Toast-CcZVVN4i.js.map +1 -0
  9. package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-OMEGx5KA.js} +66 -61
  10. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-CMAGZouj.js +16 -0
  12. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-3G6mOhiK.css → main-O2tZVdHm.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +1 -0
  15. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js.map +1 -0
  16. package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-D6V-7WnA.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +1 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-iyoW95UB.js +2 -0
  20. package/bizar-dash/dist/assets/useSlashCommands-iyoW95UB.js.map +1 -0
  21. package/bizar-dash/dist/index.html +8 -6
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/auth.mjs +14 -1
  25. package/bizar-dash/src/server/routes/users.mjs +18 -0
  26. package/bizar-dash/src/web/MobileApp.tsx +26 -19
  27. package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
  28. package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
  29. package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
  30. package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
  31. package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
  32. package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
  33. package/bizar-dash/src/web/main.tsx +1 -0
  34. package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
  35. package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
  36. package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
  37. package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
  38. package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
  39. package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
  40. package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
  41. package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
  42. package/bizar-dash/src/web/mobile.tsx +1 -0
  43. package/bizar-dash/src/web/styles/main.css +388 -0
  44. package/bizar-dash/src/web/styles/memory.css +45 -0
  45. package/bizar-dash/src/web/styles/mobile-layout.css +231 -0
  46. package/bizar-dash/src/web/styles/mobile.css +180 -0
  47. package/bizar-dash/src/web/styles/settings.css +110 -0
  48. package/bizar-dash/src/web/styles/tasks.css +305 -0
  49. package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
  50. package/bizar-dash/tests/mobile-layout.test.tsx +296 -0
  51. package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
  52. package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
  53. package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
  54. package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
  55. package/cli/commands/dash.mjs +5 -0
  56. package/cli/commands/tailscale.mjs +8 -1
  57. package/package.json +1 -1
  58. package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
  59. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
  60. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
  61. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
  62. package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
  63. package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
  64. package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
  65. package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
  66. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
  67. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
  68. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
  69. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js.map +0 -1
@@ -0,0 +1,296 @@
1
+ // tests/mobile-layout.test.tsx — v5.4 mobile navigation shell tests.
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { Activity, MessageSquare, CheckSquare, Grid } from 'lucide-react';
5
+ import { MobileHeader } from '../src/web/components/MobileHeader';
6
+ import { MobileBottomNav } from '../src/web/components/MobileBottomNav';
7
+ import { MobileDrawer } from '../src/web/components/MobileDrawer';
8
+ import { MobileLayout } from '../src/web/components/MobileLayout';
9
+ import type { MobileTab } from '../src/web/components/MobileBottomNav';
10
+
11
+ const TABS: MobileTab[] = [
12
+ { id: 'activity', label: 'Activity', icon: Activity },
13
+ { id: 'chat', label: 'Chat', icon: MessageSquare },
14
+ { id: 'tasks', label: 'Tasks', icon: CheckSquare },
15
+ { id: 'settings', label: 'Settings', icon: Grid },
16
+ { id: 'more', label: 'More', icon: Grid },
17
+ ];
18
+
19
+ describe('MobileHeader', () => {
20
+ it('renders with title', () => {
21
+ render(
22
+ <MobileHeader
23
+ onMenuClick={vi.fn()}
24
+ scrolled={false}
25
+ activeTab="activity"
26
+ />,
27
+ );
28
+ expect(screen.getByText('Bizar')).toBeInTheDocument();
29
+ });
30
+
31
+ it('renders menu button', () => {
32
+ render(
33
+ <MobileHeader
34
+ onMenuClick={vi.fn()}
35
+ scrolled={false}
36
+ activeTab="activity"
37
+ />,
38
+ );
39
+ expect(screen.getByRole('button', { name: /open menu/i })).toBeInTheDocument();
40
+ });
41
+
42
+ it('renders search button by default', () => {
43
+ render(
44
+ <MobileHeader
45
+ onMenuClick={vi.fn()}
46
+ scrolled={false}
47
+ activeTab="activity"
48
+ onSearch={vi.fn()}
49
+ />,
50
+ );
51
+ expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument();
52
+ });
53
+
54
+ it('renders settings exit button when onSettings is provided', () => {
55
+ render(
56
+ <MobileHeader
57
+ onMenuClick={vi.fn()}
58
+ scrolled={false}
59
+ activeTab="settings"
60
+ onSettings={vi.fn()}
61
+ />,
62
+ );
63
+ expect(screen.getByRole('button', { name: /exit settings/i })).toBeInTheDocument();
64
+ });
65
+
66
+ it('shows Settings title when activeTab is settings', () => {
67
+ render(
68
+ <MobileHeader
69
+ onMenuClick={vi.fn()}
70
+ scrolled={false}
71
+ activeTab="settings"
72
+ />,
73
+ );
74
+ expect(screen.getByText('Settings')).toBeInTheDocument();
75
+ });
76
+
77
+ it('calls onMenuClick when menu button is clicked', () => {
78
+ const onMenuClick = vi.fn();
79
+ render(
80
+ <MobileHeader
81
+ onMenuClick={onMenuClick}
82
+ scrolled={false}
83
+ activeTab="activity"
84
+ />,
85
+ );
86
+ fireEvent.click(screen.getByRole('button', { name: /open menu/i }));
87
+ expect(onMenuClick).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it('applies scrolled class when scrolled prop is true', () => {
91
+ const { container } = render(
92
+ <MobileHeader
93
+ onMenuClick={vi.fn()}
94
+ scrolled={true}
95
+ activeTab="activity"
96
+ />,
97
+ );
98
+ expect(container.querySelector('.mobile-header')).toHaveClass('is-scrolled');
99
+ });
100
+ });
101
+
102
+ describe('MobileBottomNav', () => {
103
+ it('renders all primary tabs', () => {
104
+ render(
105
+ <MobileBottomNav
106
+ tabs={TABS}
107
+ activeTab="activity"
108
+ onTabChange={vi.fn()}
109
+ />,
110
+ );
111
+ expect(screen.getByText('Activity')).toBeInTheDocument();
112
+ expect(screen.getByText('Chat')).toBeInTheDocument();
113
+ expect(screen.getByText('Tasks')).toBeInTheDocument();
114
+ expect(screen.getByText('Settings')).toBeInTheDocument();
115
+ expect(screen.getByText('More')).toBeInTheDocument();
116
+ });
117
+
118
+ it('marks the active tab', () => {
119
+ render(
120
+ <MobileBottomNav
121
+ tabs={TABS}
122
+ activeTab="chat"
123
+ onTabChange={vi.fn()}
124
+ />,
125
+ );
126
+ const chatButton = screen.getByRole('tab', { name: /chat/i });
127
+ expect(chatButton).toHaveAttribute('aria-selected', 'true');
128
+ });
129
+
130
+ it('calls onTabChange when a tab is clicked', () => {
131
+ const onTabChange = vi.fn();
132
+ render(
133
+ <MobileBottomNav
134
+ tabs={TABS}
135
+ activeTab="activity"
136
+ onTabChange={onTabChange}
137
+ />,
138
+ );
139
+ fireEvent.click(screen.getByRole('tab', { name: /tasks/i }));
140
+ expect(onTabChange).toHaveBeenCalledWith('tasks');
141
+ });
142
+ });
143
+
144
+ describe('MobileDrawer', () => {
145
+ it('does not render when open is false', () => {
146
+ const { container } = render(
147
+ <MobileDrawer
148
+ open={false}
149
+ onClose={vi.fn()}
150
+ tabs={TABS}
151
+ activeTab="activity"
152
+ onTabChange={vi.fn()}
153
+ />,
154
+ );
155
+ expect(container.querySelector('.mobile-drawer')).not.toHaveClass('is-open');
156
+ });
157
+
158
+ it('renders overlay when open', () => {
159
+ render(
160
+ <MobileDrawer
161
+ open={true}
162
+ onClose={vi.fn()}
163
+ tabs={TABS}
164
+ activeTab="activity"
165
+ onTabChange={vi.fn()}
166
+ />,
167
+ );
168
+ expect(screen.getByLabelText(/all views/i)).toBeInTheDocument();
169
+ });
170
+
171
+ it('calls onClose when overlay is clicked', () => {
172
+ const onClose = vi.fn();
173
+ render(
174
+ <MobileDrawer
175
+ open={true}
176
+ onClose={onClose}
177
+ tabs={TABS}
178
+ activeTab="activity"
179
+ onTabChange={vi.fn()}
180
+ />,
181
+ );
182
+ fireEvent.click(screen.getByLabelText(/all views/i).previousSibling as Element);
183
+ // The overlay is the div before the aside
184
+ });
185
+
186
+ it('renders all tabs in the drawer', () => {
187
+ render(
188
+ <MobileDrawer
189
+ open={true}
190
+ onClose={vi.fn()}
191
+ tabs={TABS}
192
+ activeTab="activity"
193
+ onTabChange={vi.fn()}
194
+ />,
195
+ );
196
+ expect(screen.getByText('Activity')).toBeInTheDocument();
197
+ expect(screen.getByText('Chat')).toBeInTheDocument();
198
+ expect(screen.getByText('Tasks')).toBeInTheDocument();
199
+ expect(screen.getByText('Settings')).toBeInTheDocument();
200
+ expect(screen.getByText('More')).toBeInTheDocument();
201
+ });
202
+
203
+ it('calls onTabChange when a drawer item is clicked', () => {
204
+ const onTabChange = vi.fn();
205
+ render(
206
+ <MobileDrawer
207
+ open={true}
208
+ onClose={vi.fn()}
209
+ tabs={TABS}
210
+ activeTab="activity"
211
+ onTabChange={onTabChange}
212
+ />,
213
+ );
214
+ fireEvent.click(screen.getByRole('tab', { name: /tasks/i }));
215
+ expect(onTabChange).toHaveBeenCalledWith('tasks');
216
+ });
217
+ });
218
+
219
+ describe('MobileLayout', () => {
220
+ it('renders mobile header', () => {
221
+ render(
222
+ <MobileLayout
223
+ activeTab="activity"
224
+ onTabChange={vi.fn()}
225
+ tabs={TABS}
226
+ fullTabs={TABS}
227
+ >
228
+ <div>Child content</div>
229
+ </MobileLayout>,
230
+ );
231
+ expect(screen.getByText('Bizar')).toBeInTheDocument();
232
+ });
233
+
234
+ it('renders bottom nav with primary tabs', () => {
235
+ render(
236
+ <MobileLayout
237
+ activeTab="activity"
238
+ onTabChange={vi.fn()}
239
+ tabs={TABS}
240
+ fullTabs={TABS}
241
+ >
242
+ <div>Child content</div>
243
+ </MobileLayout>,
244
+ );
245
+ const nav = screen.getByRole('tablist', { name: /primary/i });
246
+ expect(nav).toBeInTheDocument();
247
+ expect(nav).toHaveTextContent('Activity');
248
+ expect(nav).toHaveTextContent('Chat');
249
+ });
250
+
251
+ it('opens drawer on menu click', () => {
252
+ render(
253
+ <MobileLayout
254
+ activeTab="activity"
255
+ onTabChange={vi.fn()}
256
+ tabs={TABS}
257
+ fullTabs={TABS}
258
+ >
259
+ <div>Child content</div>
260
+ </MobileLayout>,
261
+ );
262
+ fireEvent.click(screen.getByRole('button', { name: /open menu/i }));
263
+ expect(screen.getByLabelText(/all views/i)).toBeInTheDocument();
264
+ });
265
+
266
+ it('closes drawer on overlay click', () => {
267
+ render(
268
+ <MobileLayout
269
+ activeTab="activity"
270
+ onTabChange={vi.fn()}
271
+ tabs={TABS}
272
+ fullTabs={TABS}
273
+ >
274
+ <div>Child content</div>
275
+ </MobileLayout>,
276
+ );
277
+ // Open drawer first
278
+ fireEvent.click(screen.getByRole('button', { name: /open menu/i }));
279
+ const drawer = screen.getByLabelText(/all views/i);
280
+ expect(drawer).toHaveClass('is-open');
281
+ });
282
+
283
+ it('renders children content', () => {
284
+ render(
285
+ <MobileLayout
286
+ activeTab="activity"
287
+ onTabChange={vi.fn()}
288
+ tabs={TABS}
289
+ fullTabs={TABS}
290
+ >
291
+ <div data-testid="child">Child content</div>
292
+ </MobileLayout>,
293
+ );
294
+ expect(screen.getByTestId('child')).toBeInTheDocument();
295
+ });
296
+ });
@@ -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
+ });