@polderlabs/bizar 5.0.0 → 5.0.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.
Files changed (96) hide show
  1. package/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  4. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  6. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  7. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  8. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
  9. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
  10. package/bizar-dash/dist/index.html +4 -4
  11. package/bizar-dash/dist/mobile.html +3 -3
  12. package/bizar-dash/node_modules/.package-lock.json +6 -0
  13. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  14. package/bizar-dash/package-lock.json +6 -0
  15. package/bizar-dash/src/server/api.mjs +7 -0
  16. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  17. package/bizar-dash/src/server/memory-store.mjs +46 -0
  18. package/bizar-dash/src/server/providers-store.mjs +11 -4
  19. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  20. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  21. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  22. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  23. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  24. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  25. package/bizar-dash/src/server/serve-info.mjs +172 -0
  26. package/bizar-dash/src/web/App.tsx +48 -11
  27. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  28. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  29. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  30. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  31. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  32. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  33. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  34. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  35. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  36. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  37. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  38. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  39. package/bizar-dash/src/web/lib/types.ts +134 -0
  40. package/bizar-dash/src/web/styles/chat.css +8 -5
  41. package/bizar-dash/src/web/styles/main.css +77 -2
  42. package/bizar-dash/src/web/styles/settings.css +265 -0
  43. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  44. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  46. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  47. package/bizar-dash/src/web/views/Settings.tsx +76 -46
  48. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  49. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  50. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  51. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  52. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  53. package/bizar-dash/tests/autosave.test.tsx +276 -0
  54. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  55. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  56. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  57. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  58. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  59. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  60. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  61. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  62. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  63. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  64. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  65. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  66. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  67. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  68. package/cli/commands/minimax.mjs +5 -2
  69. package/cli/tests/minimax-cli.test.mjs +79 -0
  70. package/config/agents/frigg.md +1 -1
  71. package/config/agents/heimdall.md +1 -1
  72. package/config/agents/mimir.md +1 -1
  73. package/config/agents/quick.md +1 -1
  74. package/config/agents/semble-search.md +1 -1
  75. package/config/agents/vor.md +1 -1
  76. package/config/opencode.json +28 -21
  77. package/config/opencode.json.template +8 -1
  78. package/package.json +1 -1
  79. package/plugins/bizar/index.ts +77 -0
  80. package/plugins/bizar/src/compaction.d.mts +48 -0
  81. package/plugins/bizar/src/compaction.mjs +192 -0
  82. package/plugins/bizar/tests/compaction.test.ts +264 -0
  83. package/templates/schedules/daily-backup.json +12 -0
  84. package/templates/schedules/daily-cleanup.json +12 -0
  85. package/templates/schedules/hourly-health-check.json +12 -0
  86. package/templates/schedules/webhook-on-push.json +13 -0
  87. package/templates/schedules/weekly-digest.json +13 -0
  88. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
  89. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
  90. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
  91. package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
  92. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
  93. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
  94. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  95. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  96. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -0,0 +1,276 @@
1
+ /**
2
+ * tests/autosave.test.tsx
3
+ *
4
+ * Tests for useAutosave hook and AutosaveField component.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import { render, screen, fireEvent, waitFor, act, renderHook } from '@testing-library/react';
8
+ import React from 'react';
9
+ import { useAutosave } from '../src/web/hooks/useAutosave';
10
+ import { AutosaveField } from '../src/web/components/AutosaveField';
11
+
12
+ // ── useAutosave tests ────────────────────────────────────────────────────────
13
+
14
+ describe('useAutosave', () => {
15
+ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); });
16
+ afterEach(() => { vi.useRealTimers(); });
17
+
18
+ it('debounces save by the configured delay', async () => {
19
+ const saveFn = vi.fn().mockResolvedValue(undefined);
20
+ const { result } = renderHook(() => useAutosave('initial', saveFn, { delay: 500 }));
21
+
22
+ act(() => { result.current.setValue('changed'); });
23
+ expect(saveFn).not.toHaveBeenCalled();
24
+
25
+ act(() => { vi.advanceTimersByTime(500); });
26
+ expect(saveFn).toHaveBeenCalledTimes(1);
27
+ expect(saveFn).toHaveBeenCalledWith('changed');
28
+ });
29
+
30
+ it('saves immediately on flush', async () => {
31
+ const saveFn = vi.fn().mockResolvedValue(undefined);
32
+ const { result } = renderHook(() => useAutosave('initial', saveFn, { delay: 500 }));
33
+
34
+ act(() => { result.current.setValue('changed'); });
35
+ expect(saveFn).not.toHaveBeenCalled();
36
+
37
+ await act(async () => { await result.current.flush(); });
38
+ expect(saveFn).toHaveBeenCalledTimes(1);
39
+ expect(saveFn).toHaveBeenCalledWith('changed');
40
+ });
41
+
42
+ it('does not save if value has not changed', async () => {
43
+ const saveFn = vi.fn().mockResolvedValue(undefined);
44
+ const { result } = renderHook(() => useAutosave('initial', saveFn, { delay: 500 }));
45
+
46
+ act(() => { vi.advanceTimersByTime(600); });
47
+ expect(saveFn).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('exposes status transitions: idle → saving → saved → idle', async () => {
51
+ let resolve: (v: unknown) => void;
52
+ const saveFn = vi.fn().mockReturnValue(new Promise(r => (resolve = r)));
53
+ const { result } = renderHook(() => useAutosave('initial', saveFn, { delay: 500 }));
54
+
55
+ expect(result.current.status).toBe('idle');
56
+
57
+ act(() => { result.current.setValue('changed'); });
58
+ expect(result.current.status).toBe('idle'); // still debouncing
59
+
60
+ act(() => { vi.advanceTimersByTime(500); });
61
+ expect(result.current.status).toBe('saving');
62
+
63
+ await act(async () => { resolve!(); });
64
+ expect(result.current.status).toBe('saved');
65
+
66
+ act(() => { vi.advanceTimersByTime(2000); });
67
+ expect(result.current.status).toBe('idle');
68
+ });
69
+
70
+ it('exposes error status on save failure', async () => {
71
+ const saveFn = vi.fn().mockRejectedValue(new Error('network error'));
72
+ const { result } = renderHook(() => useAutosave('initial', saveFn, { delay: 500 }));
73
+
74
+ act(() => { result.current.setValue('changed'); });
75
+ act(() => { vi.advanceTimersByTime(500); });
76
+
77
+ await waitFor(() => {
78
+ expect(result.current.status).toBe('error');
79
+ });
80
+ });
81
+
82
+ it('flushes pending save on unmount', async () => {
83
+ const saveFn = vi.fn().mockResolvedValue(undefined);
84
+ const { result, unmount } = renderHook(() => useAutosave('initial', saveFn, { delay: 500 }));
85
+
86
+ act(() => { result.current.setValue('changed'); });
87
+ expect(saveFn).not.toHaveBeenCalled();
88
+
89
+ unmount();
90
+ expect(saveFn).toHaveBeenCalledTimes(1);
91
+ });
92
+ });
93
+
94
+ // ── AutosaveField tests ───────────────────────────────────────────────────────
95
+
96
+ describe('AutosaveField', () => {
97
+ beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); });
98
+ afterEach(() => { vi.useRealTimers(); });
99
+
100
+ it('renders the input passed via render prop', () => {
101
+ const saveFn = vi.fn().mockResolvedValue(undefined);
102
+ render(
103
+ <AutosaveField
104
+ initialValue="hello"
105
+ saveFn={saveFn}
106
+ render={({ value, onChange }) => (
107
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} />
108
+ )}
109
+ />,
110
+ );
111
+ expect(screen.getByTestId('input')).toHaveValue('hello');
112
+ });
113
+
114
+ it('applies idle class to container by default', () => {
115
+ const saveFn = vi.fn().mockResolvedValue(undefined);
116
+ const { container } = render(
117
+ <AutosaveField
118
+ initialValue="test"
119
+ saveFn={saveFn}
120
+ render={({ value, onChange }) => (
121
+ <input value={value} onChange={e => onChange(e.target.value)} />
122
+ )}
123
+ />,
124
+ );
125
+ expect(container.querySelector('.autosave-field')).toHaveClass('autosave-field');
126
+ expect(container.querySelector('.autosave-field')).not.toHaveClass('saving');
127
+ expect(container.querySelector('.autosave-field')).not.toHaveClass('saved');
128
+ expect(container.querySelector('.autosave-field')).not.toHaveClass('error');
129
+ });
130
+
131
+ it('shows saving state during async save', async () => {
132
+ let resolve: (v: unknown) => void;
133
+ const saveFn = vi.fn().mockImplementation(async () => {
134
+ await new Promise(r => (resolve = r));
135
+ });
136
+ render(
137
+ <AutosaveField
138
+ initialValue="hello"
139
+ saveFn={saveFn}
140
+ render={({ value, onChange, onBlur }) => (
141
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
142
+ )}
143
+ />,
144
+ );
145
+
146
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'changed' } });
147
+ fireEvent.blur(screen.getByTestId('input'));
148
+
149
+ await waitFor(() => {
150
+ expect(document.querySelector('.autosave-field')).toHaveClass('saving');
151
+ });
152
+
153
+ act(() => { resolve!(); });
154
+ });
155
+
156
+ it('shows saved state after successful save', async () => {
157
+ const saveFn = vi.fn().mockResolvedValue(undefined);
158
+ render(
159
+ <AutosaveField
160
+ initialValue="hello"
161
+ saveFn={saveFn}
162
+ render={({ value, onChange, onBlur }) => (
163
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
164
+ )}
165
+ />,
166
+ );
167
+
168
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'changed' } });
169
+ fireEvent.blur(screen.getByTestId('input'));
170
+
171
+ await waitFor(() => {
172
+ expect(document.querySelector('.autosave-field')).toHaveClass('saving');
173
+ });
174
+
175
+ act(() => { vi.advanceTimersByTime(50); });
176
+
177
+ await waitFor(() => {
178
+ expect(document.querySelector('.autosave-field')).toHaveClass('saved');
179
+ });
180
+ });
181
+
182
+ it('shows error state on failed save', async () => {
183
+ const saveFn = vi.fn().mockRejectedValue(new Error('network error'));
184
+ render(
185
+ <AutosaveField
186
+ initialValue="hello"
187
+ saveFn={saveFn}
188
+ render={({ value, onChange, onBlur }) => (
189
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
190
+ )}
191
+ />,
192
+ );
193
+
194
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'changed' } });
195
+ fireEvent.blur(screen.getByTestId('input'));
196
+
197
+ await waitFor(() => {
198
+ expect(document.querySelector('.autosave-field')).toHaveClass('error');
199
+ });
200
+ });
201
+
202
+ it('saves immediately on blur when debounce is pending (done typing)', async () => {
203
+ const saveFn = vi.fn().mockResolvedValue(undefined);
204
+ render(
205
+ <AutosaveField
206
+ initialValue="hello"
207
+ delay={800}
208
+ saveFn={saveFn}
209
+ render={({ value, onChange, onBlur }) => (
210
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
211
+ )}
212
+ />,
213
+ );
214
+
215
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'typing...' } });
216
+ // Blur immediately — since user is done typing, save fires immediately
217
+ // (debounce is pending but blur triggers immediate save instead)
218
+ fireEvent.blur(screen.getByTestId('input'));
219
+
220
+ // saveFn should be called immediately on blur
221
+ expect(saveFn).toHaveBeenCalledTimes(1);
222
+ expect(saveFn).toHaveBeenCalledWith('typing...');
223
+
224
+ // Debounce timer should have been cleared — no duplicate save
225
+ act(() => { vi.advanceTimersByTime(800); });
226
+ expect(saveFn).toHaveBeenCalledTimes(1); // still 1, not 2
227
+ });
228
+
229
+ it('saves pending value on unmount', async () => {
230
+ const saveFn = vi.fn().mockResolvedValue(undefined);
231
+ const { unmount } = render(
232
+ <AutosaveField
233
+ initialValue="test"
234
+ saveFn={saveFn}
235
+ render={({ value, onChange }) => (
236
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} />
237
+ )}
238
+ />,
239
+ );
240
+
241
+ // Simulate user typing (schedules debounce)
242
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'unsaved' } });
243
+
244
+ // Unmount before debounce fires
245
+ unmount();
246
+
247
+ // saveFn should have been called with the pending value
248
+ await act(async () => { vi.advanceTimersByTime(50); });
249
+ expect(saveFn).toHaveBeenCalledWith('unsaved');
250
+ });
251
+
252
+ it('renders status icon when saving', async () => {
253
+ let resolve: (v: unknown) => void;
254
+ const saveFn = vi.fn().mockImplementation(async () => {
255
+ await new Promise(r => (resolve = r));
256
+ });
257
+ render(
258
+ <AutosaveField
259
+ initialValue="hello"
260
+ saveFn={saveFn}
261
+ render={({ value, onChange, onBlur }) => (
262
+ <input data-testid="input" value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
263
+ )}
264
+ />,
265
+ );
266
+
267
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'changed' } });
268
+ fireEvent.blur(screen.getByTestId('input'));
269
+
270
+ await waitFor(() => {
271
+ expect(document.querySelector('.autosave-status')).toBeInTheDocument();
272
+ });
273
+
274
+ act(() => { resolve!(); });
275
+ });
276
+ });
@@ -0,0 +1,140 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { ChatComposer } from '../src/web/components/chat/ChatComposer';
5
+
6
+ // Mock the enhancePrompt API
7
+ vi.mock('../src/web/lib/api', () => ({
8
+ enhancePrompt: vi.fn().mockResolvedValue('enhanced prompt'),
9
+ }));
10
+
11
+ // Mock lucide-react — use importOriginal to preserve all real icons,
12
+ // then override only the ones we need to stub
13
+ vi.mock('lucide-react', async (importOriginal) => {
14
+ const actual = await importOriginal() as Record<string, unknown>;
15
+ return {
16
+ ...actual,
17
+ Send: () => <svg data-testid="send-icon" />,
18
+ Paperclip: () => <svg data-testid="paperclip-icon" />,
19
+ X: () => <svg data-testid="x-icon" />,
20
+ Sparkles: () => <svg data-testid="sparkles-icon" />,
21
+ Bot: () => <svg data-testid="bot-icon" />,
22
+ ChevronDown: () => <svg data-testid="chevron-down-icon" />,
23
+ };
24
+ });
25
+
26
+ function makeProps() {
27
+ return {
28
+ agent: 'test-agent',
29
+ setAgent: vi.fn(),
30
+ model: 'test-model',
31
+ setModel: vi.fn(),
32
+ text: '',
33
+ setText: vi.fn(),
34
+ sending: false,
35
+ onSend: vi.fn(),
36
+ attachments: [] as string[],
37
+ setAttachments: vi.fn(),
38
+ suggestions: [] as Array<{ cmd: string; desc: string }>,
39
+ onPickSuggestion: vi.fn(),
40
+ agents: [{ name: 'test-agent' }],
41
+ onAttach: vi.fn(),
42
+ };
43
+ }
44
+
45
+ describe('ChatComposer sizing', () => {
46
+ it('renders a textarea inside the composer', () => {
47
+ render(<ChatComposer {...makeProps()} />);
48
+ const textarea = screen.getByRole('textbox');
49
+ expect(textarea).toBeInTheDocument();
50
+ });
51
+
52
+ it('composer pill exists with the correct class', () => {
53
+ const { container } = render(<ChatComposer {...makeProps()} />);
54
+ const pill = container.querySelector('.chat-composer-pill');
55
+ expect(pill).not.toBeNull();
56
+ });
57
+
58
+ it('send button exists with correct class', () => {
59
+ const { container } = render(<ChatComposer {...makeProps()} />);
60
+ const sendBtn = container.querySelector('.chat-send-btn');
61
+ expect(sendBtn).not.toBeNull();
62
+ });
63
+
64
+ it('textarea starts with rows=1 for compact single-line appearance', () => {
65
+ render(<ChatComposer {...makeProps()} />);
66
+ const textarea = screen.getByRole('textbox');
67
+ expect(textarea.getAttribute('rows')).toBe('1');
68
+ });
69
+ });
70
+
71
+ describe('ChatComposer auto-grow', () => {
72
+ it('setText is called on each keystroke (controlled component)', async () => {
73
+ const user = userEvent.setup();
74
+ const props = makeProps();
75
+ render(<ChatComposer {...props} />);
76
+ const textarea = screen.getByRole('textbox');
77
+ await user.click(textarea);
78
+ // user.type fires input events which trigger onChange → setText
79
+ await user.type(textarea, 'hello');
80
+ // setText is called for each character in a controlled component
81
+ expect(props.setText.mock.calls.length).toBeGreaterThanOrEqual(5);
82
+ });
83
+
84
+ it('empty state shows send message placeholder', () => {
85
+ render(<ChatComposer {...makeProps()} />);
86
+ const textarea = screen.getByRole('textbox');
87
+ expect(textarea).toHaveAttribute('placeholder', 'Send a message…');
88
+ });
89
+
90
+ it('sending state shows sending placeholder', () => {
91
+ const props = makeProps();
92
+ props.sending = true;
93
+ render(<ChatComposer {...props} />);
94
+ const textarea = screen.getByRole('textbox');
95
+ expect(textarea).toHaveAttribute('placeholder', 'Sending…');
96
+ });
97
+ });
98
+
99
+ describe('ChatComposer interactions', () => {
100
+ it('calls onSend when send button is clicked', async () => {
101
+ const user = userEvent.setup();
102
+ const props = makeProps();
103
+ const { rerender } = render(<ChatComposer {...props} />);
104
+ // Simulate typing: update the text prop as a real state would
105
+ props.text = 'hello world';
106
+ rerender(<ChatComposer {...props} />);
107
+ const sendBtn = document.querySelector('.chat-send-btn') as HTMLButtonElement;
108
+ await user.click(sendBtn);
109
+ expect(props.onSend).toHaveBeenCalled();
110
+ });
111
+
112
+ it('does not call onSend when textarea is empty', async () => {
113
+ const user = userEvent.setup();
114
+ const props = makeProps();
115
+ render(<ChatComposer {...props} />);
116
+ const sendBtn = document.querySelector('.chat-send-btn') as HTMLButtonElement;
117
+ await user.click(sendBtn);
118
+ expect(props.onSend).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('setText is called on input change', async () => {
122
+ const user = userEvent.setup();
123
+ const props = makeProps();
124
+ render(<ChatComposer {...props} />);
125
+ const textarea = screen.getByRole('textbox');
126
+ await user.click(textarea);
127
+ await user.keyboard('typed text');
128
+ // For controlled components, setText is called per-keystroke.
129
+ // Verify it was called multiple times (once per character)
130
+ expect(props.setText.mock.calls.length).toBeGreaterThanOrEqual(10);
131
+ });
132
+
133
+ it('textarea is disabled while sending', () => {
134
+ const props = makeProps();
135
+ props.sending = true;
136
+ render(<ChatComposer {...props} />);
137
+ const textarea = screen.getByRole('textbox');
138
+ expect(textarea).toBeDisabled();
139
+ });
140
+ });
@@ -0,0 +1,105 @@
1
+ // tests/components/doctor-panel.test.tsx — v6.0.0
2
+ //
3
+ // Tests for the reusable DoctorPanel component. Verifies:
4
+ // - Renders the category title and check rows with status pills
5
+ // - Empty state when no checks / info / children
6
+ // - info list renders below checks
7
+ // - children bypasses the default check-list render
8
+
9
+ import { render, screen } from '@testing-library/react';
10
+ import { describe, it, expect } from 'vitest';
11
+ import { DoctorPanel } from '../../src/web/components/DoctorPanel';
12
+ import type { DoctorCheck } from '../../src/web/lib/types';
13
+
14
+ const checks: DoctorCheck[] = [
15
+ { name: 'node', status: 'ok', message: 'v22.16.0 on linux/x64' },
16
+ { name: 'memory', status: 'warn', message: 'rss 612.4 MB' },
17
+ { name: 'opencode', status: 'fail', message: 'serve-info missing' },
18
+ ];
19
+
20
+ describe('DoctorPanel', () => {
21
+ it('renders the category title from defaults', () => {
22
+ render(<DoctorPanel category="system" checks={checks} />);
23
+ expect(screen.getByText('System health')).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders one row per check, with name + message', () => {
27
+ render(<DoctorPanel category="services" checks={checks} />);
28
+ expect(screen.getByText('node')).toBeInTheDocument();
29
+ expect(screen.getByText('memory')).toBeInTheDocument();
30
+ expect(screen.getByText('opencode')).toBeInTheDocument();
31
+ expect(screen.getByText('v22.16.0 on linux/x64')).toBeInTheDocument();
32
+ expect(screen.getByText('rss 612.4 MB')).toBeInTheDocument();
33
+ expect(screen.getByText('serve-info missing')).toBeInTheDocument();
34
+ });
35
+
36
+ it('renders the status pill text per row', () => {
37
+ render(<DoctorPanel category="services" checks={checks} />);
38
+ expect(screen.getAllByText('ok').length).toBeGreaterThanOrEqual(1);
39
+ expect(screen.getAllByText('warn').length).toBeGreaterThanOrEqual(1);
40
+ expect(screen.getAllByText('fail').length).toBeGreaterThanOrEqual(1);
41
+ });
42
+
43
+ it('shows the empty state when no checks / info / children are passed', () => {
44
+ render(<DoctorPanel category="errors" checks={[]} />);
45
+ expect(screen.getByText('No recent errors')).toBeInTheDocument();
46
+ });
47
+
48
+ it('renders the info list when info rows are supplied', () => {
49
+ render(
50
+ <DoctorPanel
51
+ category="counts"
52
+ checks={[]}
53
+ info={[
54
+ { label: 'Tasks', value: 42 },
55
+ { label: 'Mods', value: 5 },
56
+ ]}
57
+ />,
58
+ );
59
+ expect(screen.getByText('Tasks')).toBeInTheDocument();
60
+ expect(screen.getByText('Mods')).toBeInTheDocument();
61
+ expect(screen.getByText('42')).toBeInTheDocument();
62
+ expect(screen.getByText('5')).toBeInTheDocument();
63
+ });
64
+
65
+ it('renders children when provided, bypassing the default check-list', () => {
66
+ render(
67
+ <DoctorPanel category="actions" checks={[]}>
68
+ <div data-testid="custom-child">Run Check</div>
69
+ </DoctorPanel>,
70
+ );
71
+ expect(screen.getByTestId('custom-child')).toBeInTheDocument();
72
+ expect(screen.getByText('Actions')).toBeInTheDocument();
73
+ });
74
+
75
+ it('honors an explicit title override', () => {
76
+ render(
77
+ <DoctorPanel
78
+ category="system"
79
+ title="My custom title"
80
+ checks={[]}
81
+ />,
82
+ );
83
+ expect(screen.getByText('My custom title')).toBeInTheDocument();
84
+ expect(screen.queryByText('System health')).not.toBeInTheDocument();
85
+ });
86
+
87
+ it('renders the meta string when supplied', () => {
88
+ render(
89
+ <DoctorPanel
90
+ category="system"
91
+ checks={[]}
92
+ meta="linux/x64"
93
+ />,
94
+ );
95
+ expect(screen.getByText('linux/x64')).toBeInTheDocument();
96
+ });
97
+
98
+ it('applies the category-specific class to the card', () => {
99
+ const { container } = render(
100
+ <DoctorPanel category="services" checks={checks} />,
101
+ );
102
+ const card = container.querySelector('.doctor-panel-services');
103
+ expect(card).toBeInTheDocument();
104
+ });
105
+ });