@polderlabs/bizar 5.0.0 → 5.0.2
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/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
- package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
- package/bizar-dash/dist/index.html +4 -4
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.package-lock.json +6 -0
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/package-lock.json +6 -0
- package/bizar-dash/src/server/api.mjs +7 -0
- package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
- package/bizar-dash/src/server/memory-store.mjs +46 -0
- package/bizar-dash/src/server/providers-store.mjs +11 -4
- package/bizar-dash/src/server/routes/_shared.mjs +2 -2
- package/bizar-dash/src/server/routes/doctor.mjs +71 -0
- package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
- package/bizar-dash/src/server/routes/memory.mjs +3 -0
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
- package/bizar-dash/src/server/routes/schedules.mjs +55 -0
- package/bizar-dash/src/server/serve-info.mjs +172 -0
- package/bizar-dash/src/web/App.tsx +48 -11
- package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
- package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
- package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
- package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
- package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
- package/bizar-dash/src/web/components/Topbar.tsx +11 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
- package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
- package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
- package/bizar-dash/src/web/lib/types.ts +134 -0
- package/bizar-dash/src/web/styles/chat.css +8 -5
- package/bizar-dash/src/web/styles/main.css +77 -2
- package/bizar-dash/src/web/styles/settings.css +265 -0
- package/bizar-dash/src/web/views/Chat.tsx +15 -1
- package/bizar-dash/src/web/views/Doctor.tsx +317 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
- package/bizar-dash/src/web/views/Schedules.tsx +94 -12
- package/bizar-dash/src/web/views/Settings.tsx +76 -46
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
- package/bizar-dash/tests/autosave.test.tsx +276 -0
- package/bizar-dash/tests/chat-composer.test.tsx +140 -0
- package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
- package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
- package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
- package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
- package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
- package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
- package/bizar-dash/tests/minimax-models.test.mjs +123 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
- package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
- package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
- package/bizar-dash/tests/settings-layout.test.tsx +129 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
- package/bizar-dash/tests/settings-nav.test.tsx +126 -0
- package/cli/commands/dash.mjs +5 -1
- package/cli/commands/minimax.mjs +5 -2
- package/cli/tests/minimax-cli.test.mjs +79 -0
- package/config/agents/frigg.md +1 -1
- package/config/agents/heimdall.md +1 -1
- package/config/agents/mimir.md +1 -1
- package/config/agents/quick.md +1 -1
- package/config/agents/semble-search.md +1 -1
- package/config/agents/vor.md +1 -1
- package/config/opencode.json +28 -21
- package/config/opencode.json.template +8 -1
- package/package.json +1 -1
- package/plugins/bizar/index.ts +77 -0
- package/plugins/bizar/src/compaction.d.mts +48 -0
- package/plugins/bizar/src/compaction.mjs +192 -0
- package/plugins/bizar/tests/compaction.test.ts +264 -0
- package/templates/schedules/daily-backup.json +12 -0
- package/templates/schedules/daily-cleanup.json +12 -0
- package/templates/schedules/hourly-health-check.json +12 -0
- package/templates/schedules/webhook-on-push.json +13 -0
- package/templates/schedules/weekly-digest.json +13 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
- package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
- 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
|
+
});
|