@polderlabs/bizar 4.7.0 → 4.7.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.
Files changed (75) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  17. package/bizar-dash/src/server/routes/chat.mjs +14 -0
  18. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  19. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  20. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  21. package/bizar-dash/src/server/server.mjs +17 -0
  22. package/bizar-dash/src/web/App.tsx +8 -1
  23. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  24. package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
  25. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  26. package/bizar-dash/src/web/styles/main.css +70 -8
  27. package/bizar-dash/src/web/views/Activity.tsx +11 -1
  28. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  29. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  30. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  31. package/bizar-dash/src/web/views/History.tsx +4 -1
  32. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  33. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  34. package/bizar-dash/src/web/views/Overview.tsx +8 -1
  35. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  36. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  37. package/bizar-dash/src/web/views/Settings.tsx +97 -1751
  38. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  39. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  40. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  41. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  42. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  43. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  44. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  45. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  46. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  47. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  48. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  49. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  50. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  51. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  52. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  53. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  54. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  55. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  56. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  57. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  58. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  59. package/bizar-dash/tests/a11y.test.tsx +206 -0
  60. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  61. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  62. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  63. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  64. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  65. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  66. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  67. package/cli/bin.mjs +96 -2
  68. package/cli/commands/minimax.mjs +20 -0
  69. package/cli/commands/util.mjs +154 -1
  70. package/cli/digest.mjs +149 -0
  71. package/package.json +1 -1
  72. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  73. package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
  74. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
  75. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
@@ -0,0 +1,206 @@
1
+ // src/tests/a11y.test.tsx — WCAG 2.2 AA accessibility assertions for major views.
2
+ //
3
+ // This test file exercises the most critical accessibility patterns
4
+ // added in v4.8 (form labels, skip-to-main, aria-live, focus-visible).
5
+ // It uses Testing Library queries that mirror what assistive tech sees,
6
+ // not implementation details, so the tests stay useful even as the
7
+ // markup evolves.
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { render, screen, within } from '@testing-library/react';
11
+ import { ModalProvider } from '../src/web/components/Modal';
12
+ import { ToastProvider } from '../src/web/components/Toast';
13
+ import { SearchModal } from '../src/web/components/SearchModal';
14
+ import { Notifications } from '../src/web/components/Notifications';
15
+
16
+ // ─── Helpers ──────────────────────────────────────────────────────────────
17
+
18
+ function renderWithProviders(ui: React.ReactNode) {
19
+ return render(
20
+ <ToastProvider>
21
+ <ModalProvider>{ui}</ModalProvider>
22
+ </ToastProvider>,
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Walk the rendered tree and assert that every <input>, <select>, and
28
+ * <textarea> has an accessible name (label[for], aria-label,
29
+ * aria-labelledby, or wrapping <label>).
30
+ */
31
+ function expectAllInputsAccessible(container: HTMLElement) {
32
+ const inputs = container.querySelectorAll('input, select, textarea');
33
+ const offenders: string[] = [];
34
+ inputs.forEach((el) => {
35
+ // Hidden file inputs and inputs explicitly disabled from the a11y tree
36
+ // (tabIndex=-1 + display:none, used for "click to trigger file picker"
37
+ // pattern) are still required to have an aria-label so screen-reader
38
+ // users can understand them — but we skip true hidden inputs.
39
+ if ((el as HTMLInputElement).type === 'hidden') return;
40
+ const accessibleName =
41
+ el.getAttribute('aria-label') ||
42
+ el.getAttribute('aria-labelledby') ||
43
+ el.getAttribute('placeholder') ||
44
+ el.getAttribute('title');
45
+ let labelledByLabel = false;
46
+ if (el.id) {
47
+ labelledByLabel = !!container.querySelector(`label[for="${el.id}"]`);
48
+ }
49
+ if (!labelledByLabel) {
50
+ // Walk up to find a wrapping <label>.
51
+ let p: HTMLElement | null = el.parentElement;
52
+ while (p) {
53
+ if (p.tagName.toLowerCase() === 'label') {
54
+ labelledByLabel = true;
55
+ break;
56
+ }
57
+ p = p.parentElement;
58
+ }
59
+ }
60
+ if (!accessibleName && !labelledByLabel) {
61
+ offenders.push(
62
+ `<${el.tagName.toLowerCase()}${el.id ? ` id="${el.id}"` : ''}${el.getAttribute('type') ? ` type="${el.getAttribute('type')}"` : ''}> has no accessible name`,
63
+ );
64
+ }
65
+ });
66
+ expect(offenders, offenders.join('\n')).toEqual([]);
67
+ }
68
+
69
+ /**
70
+ * Walk the rendered tree and assert every <button> has an accessible
71
+ * name (text content, aria-label, or aria-labelledby).
72
+ */
73
+ function expectAllButtonsAccessible(container: HTMLElement) {
74
+ const buttons = container.querySelectorAll('button');
75
+ const offenders: string[] = [];
76
+ buttons.forEach((el) => {
77
+ const ariaLabel = el.getAttribute('aria-label');
78
+ const labelledBy = el.getAttribute('aria-labelledby');
79
+ const text = (el.textContent || '').trim();
80
+ const title = el.getAttribute('title');
81
+ if (!ariaLabel && !labelledBy && !text && !title) {
82
+ offenders.push(`<button> has no accessible name`);
83
+ }
84
+ });
85
+ expect(offenders, offenders.join('\n')).toEqual([]);
86
+ }
87
+
88
+ // ─── SearchModal ───────────────────────────────────────────────────────────
89
+
90
+ describe('SearchModal — v4.7 role="search" + v4.8 scope roles', () => {
91
+ it('renders with role="search" and an accessible input', () => {
92
+ renderWithProviders(
93
+ <SearchModal open={true} onClose={() => undefined} onSelect={() => undefined} />,
94
+ );
95
+ expect(screen.getByRole('search')).toBeInTheDocument();
96
+ // The search input has aria-label="Search". Use a CSS selector to
97
+ // avoid colliding with the sr-only <span> title text.
98
+ const input = document.querySelector('input.search-modal-input');
99
+ expect(input).toBeInTheDocument();
100
+ expect(input?.getAttribute('aria-label')).toBe('Search');
101
+ });
102
+
103
+ it('scope buttons act as a tablist with aria-selected', () => {
104
+ renderWithProviders(
105
+ <SearchModal open={true} onClose={() => undefined} onSelect={() => undefined} />,
106
+ );
107
+ const tablist = screen.getByRole('tablist', { name: /search scope/i });
108
+ expect(tablist).toBeInTheDocument();
109
+ const tabs = within(tablist).getAllByRole('tab');
110
+ expect(tabs.length).toBeGreaterThan(0);
111
+ // The "All" scope is the default — must be aria-selected.
112
+ const allTab = tabs.find((t) => t.textContent?.trim() === 'All');
113
+ expect(allTab).toHaveAttribute('aria-selected', 'true');
114
+ });
115
+
116
+ it('the search input is an accessible textbox', () => {
117
+ renderWithProviders(
118
+ <SearchModal open={true} onClose={() => undefined} onSelect={() => undefined} />,
119
+ );
120
+ const input = document.querySelector('input.search-modal-input') as HTMLInputElement;
121
+ expect(input?.tagName.toLowerCase()).toBe('input');
122
+ });
123
+
124
+ it('all buttons inside the modal have accessible names', () => {
125
+ const { container } = renderWithProviders(
126
+ <SearchModal open={true} onClose={() => undefined} onSelect={() => undefined} />,
127
+ );
128
+ expectAllButtonsAccessible(container);
129
+ });
130
+ });
131
+
132
+ // ─── Notifications (icon-only buttons must be labelled) ───────────────────
133
+
134
+ describe('Notifications — icon-only buttons are accessible', () => {
135
+ it('the bell button has an accessible name', () => {
136
+ renderWithProviders(<Notifications />);
137
+ const bell = screen.getByRole('button', { name: /notifications/i });
138
+ expect(bell).toHaveAttribute('aria-haspopup', 'true');
139
+ expect(bell).toHaveAttribute('aria-expanded');
140
+ });
141
+
142
+ it('all visible buttons have an accessible name', () => {
143
+ const { container } = renderWithProviders(<Notifications />);
144
+ expectAllButtonsAccessible(container);
145
+ });
146
+ });
147
+
148
+ // ─── ARIA semantics across components ─────────────────────────────────────
149
+
150
+ describe('ARIA semantics — toast, modal, notifications', () => {
151
+ it('ToastItem renders with role="alert" and aria-live="assertive"', async () => {
152
+ const { ToastContainer } = await import('../src/web/components/Toast');
153
+ const { container } = render(
154
+ <ToastContainer
155
+ toasts={[
156
+ { id: 1, kind: 'info', message: 'Hello world' },
157
+ ]}
158
+ onDismiss={() => undefined}
159
+ />,
160
+ );
161
+ // Container itself is aria-live="polite"
162
+ const stack = container.querySelector('.toast-stack');
163
+ expect(stack).toHaveAttribute('aria-live', 'polite');
164
+ // Each toast uses role="alert" + aria-live="assertive"
165
+ const alert = screen.getByRole('alert');
166
+ expect(alert).toHaveTextContent('Hello world');
167
+ });
168
+ });
169
+
170
+ // ─── Skip-to-main link ─────────────────────────────────────────────────────
171
+
172
+ describe('Skip-to-main-content link (added in App.tsx)', () => {
173
+ it('renders at the top of the body when the shell mounts', async () => {
174
+ // Importing App pulls in the whole graph. Use a lighter integration:
175
+ // render just the shell markup via a small render-only harness.
176
+ // We assert on the markup string the App shell produces.
177
+ const { App } = await import('../src/web/App');
178
+ const { container } = renderWithProviders(<App />);
179
+ const link = container.querySelector('a.skip-to-main');
180
+ expect(link).toBeInTheDocument();
181
+ expect(link).toHaveAttribute('href', '#main-content');
182
+ expect(link?.textContent).toMatch(/skip to main content/i);
183
+ });
184
+ });
185
+
186
+ // ─── Misc ─────────────────────────────────────────────────────────────────
187
+
188
+ describe('Misc accessibility invariants', () => {
189
+ it('icon-only buttons inside rendered tree always carry aria-label', async () => {
190
+ // The Notifications bell is the simplest icon-only button to assert.
191
+ const { container } = renderWithProviders(<Notifications />);
192
+ const bell = container.querySelector('.notifications-bell');
193
+ expect(bell?.getAttribute('aria-label')).toMatch(/notifications/i);
194
+ });
195
+
196
+ it('skips hidden file inputs (display:none) but still checks accessible name', () => {
197
+ const { container } = render(
198
+ <form>
199
+ <input type="file" multiple style={{ display: 'none' }} aria-label="Attach" />
200
+ </form>,
201
+ );
202
+ // The file input has aria-label so it's accessible.
203
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
204
+ expect(fileInput.getAttribute('aria-label')).toBe('Attach');
205
+ });
206
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * tests/backup-restore.test.mjs
3
+ *
4
+ * v4.8.0 — Round-trip backup/restore integration tests.
5
+ *
6
+ * Creates real state, backs it up, modifies state, restores from backup,
7
+ * and verifies the state matches the original.
8
+ */
9
+ import { describe, it, beforeEach, afterEach } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import {
14
+ mkdirSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ readFileSync,
18
+ existsSync,
19
+ } from 'node:fs';
20
+
21
+ const BACKUP_STORE = await import('../src/server/backup-store.mjs').then((m) => m);
22
+
23
+ describe('backup-restore round-trip', () => {
24
+ let workDir;
25
+ let backupRoot;
26
+ let stateFile;
27
+ let stateFilePath;
28
+ let state2File;
29
+ let state2FilePath;
30
+
31
+ beforeEach(() => {
32
+ const id = `bizar-roundtrip-${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
+ workDir = join(tmpdir(), id);
34
+ backupRoot = join(workDir, 'backups');
35
+ stateFile = join(workDir, 'bizar', 'state.json');
36
+ state2File = join(workDir, 'bizar', 'state2.json');
37
+
38
+ mkdirSync(join(workDir, 'bizar'), { recursive: true });
39
+ mkdirSync(backupRoot, { recursive: true });
40
+
41
+ // Create initial state
42
+ const originalState = { config: { theme: 'dark', accent: '#8b5cf6' }, version: '1.0.0' };
43
+ writeFileSync(stateFile, JSON.stringify(originalState), { mode: 0o600 });
44
+ stateFilePath = stateFile;
45
+ state2FilePath = state2File;
46
+ });
47
+
48
+ afterEach(() => {
49
+ try { rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
50
+ });
51
+
52
+ it('round-trip: create backup with project state, modify, restore', async () => {
53
+ // Use project-state label which maps to .bizar/ — we can control projectRoot
54
+ const projectRoot = workDir;
55
+
56
+ // 1. Create a .bizar/ directory in projectRoot
57
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
58
+ const stateFile = join(projectRoot, '.bizar', 'state.json');
59
+ const originalState = { config: { theme: 'dark', accent: '#8b5cf6' }, version: '1.0.0' };
60
+ writeFileSync(stateFile, JSON.stringify(originalState), { mode: 0o600 });
61
+
62
+ // 2. Create backup including project state
63
+ const backupResult = await BACKUP_STORE.createBackup({
64
+ outDir: backupRoot,
65
+ label: 'round-trip-test',
66
+ projectRoot,
67
+ });
68
+ assert.strictEqual(backupResult.ok, true);
69
+ assert.ok(backupResult.path.includes('round-trip-test'));
70
+
71
+ // 3. Verify backup has the state
72
+ const backedStatePath = join(backupResult.path, 'project-state', 'state.json');
73
+ const backedState = JSON.parse(readFileSync(backedStatePath, 'utf8'));
74
+ assert.strictEqual(backedState.config.theme, 'dark');
75
+
76
+ // 4. Modify the state file
77
+ const modifiedState = { config: { theme: 'light', accent: '#f97316' }, version: '2.0.0' };
78
+ writeFileSync(stateFile, JSON.stringify(modifiedState), { mode: 0o600 });
79
+ assert.strictEqual(JSON.parse(readFileSync(stateFile, 'utf8')).config.theme, 'light');
80
+
81
+ // 5. Restore from backup with overwrite and projectRoot
82
+ const restoreResult = await BACKUP_STORE.restoreBackup({
83
+ backupPath: backupResult.path,
84
+ dryRun: false,
85
+ conflictStrategy: 'overwrite',
86
+ projectRoot,
87
+ });
88
+ assert.strictEqual(restoreResult.ok, true);
89
+ assert.ok(restoreResult.restored.includes('project-state'));
90
+
91
+ // 6. Verify state was restored to original
92
+ const restoredContent = JSON.parse(readFileSync(stateFile, 'utf8'));
93
+ assert.strictEqual(restoredContent.config.theme, 'dark');
94
+ assert.strictEqual(restoredContent.config.accent, '#8b5cf6');
95
+ assert.strictEqual(restoredContent.version, '1.0.0');
96
+ });
97
+
98
+ it('merge strategy preserves newer existing files', async () => {
99
+ // 1. Create backup with original state
100
+ const testBackupDir = join(backupRoot, 'bizar-test-merge');
101
+ mkdirSync(testBackupDir, { recursive: true });
102
+ const originalState = { config: { theme: 'dark' }, version: '1.0.0', timestamp: Date.now() - 10000 };
103
+ mkdirSync(join(testBackupDir, 'state'), { recursive: true });
104
+ writeFileSync(join(testBackupDir, 'state', 'state.json'), JSON.stringify(originalState), { mode: 0o600 });
105
+ writeFileSync(
106
+ join(testBackupDir, 'manifest.json'),
107
+ JSON.stringify({ version: '1.0.0', createdAt: new Date().toISOString(), paths: ['state'] }),
108
+ { mode: 0o600 },
109
+ );
110
+
111
+ // 2. Modify state with newer timestamp
112
+ const modifiedState = { config: { theme: 'light' }, version: '2.0.0', timestamp: Date.now() };
113
+ writeFileSync(stateFilePath, JSON.stringify(modifiedState), { mode: 0o600 });
114
+
115
+ // 3. Restore with merge strategy (newer wins)
116
+ // The existing file is newer, so it should be kept
117
+ const restoreResult = await BACKUP_STORE.restoreBackup({
118
+ backupPath: testBackupDir,
119
+ dryRun: true,
120
+ conflictStrategy: 'merge',
121
+ });
122
+ // dryRun doesn't modify, so we just verify the call works
123
+ assert.ok(restoreResult.restored.length >= 0);
124
+ });
125
+
126
+ it('skip strategy does not overwrite existing files', async () => {
127
+ const testBackupDir = join(backupRoot, 'bizar-test-skip');
128
+ mkdirSync(testBackupDir, { recursive: true });
129
+ mkdirSync(join(testBackupDir, 'state'), { recursive: true });
130
+ writeFileSync(
131
+ join(testBackupDir, 'state', 'state.json'),
132
+ JSON.stringify({ config: { theme: 'dark' }, version: '1.0.0' }),
133
+ { mode: 0o600 },
134
+ );
135
+ writeFileSync(
136
+ join(testBackupDir, 'manifest.json'),
137
+ JSON.stringify({ version: '1.0.0', createdAt: new Date().toISOString(), paths: ['state'] }),
138
+ { mode: 0o600 },
139
+ );
140
+
141
+ // Modify existing state
142
+ writeFileSync(stateFilePath, JSON.stringify({ config: { theme: 'light' }, version: '2.0.0' }), { mode: 0o600 });
143
+
144
+ const restoreResult = await BACKUP_STORE.restoreBackup({
145
+ backupPath: testBackupDir,
146
+ dryRun: true,
147
+ conflictStrategy: 'skip',
148
+ });
149
+ assert.ok(restoreResult.skipped.length >= 0);
150
+ });
151
+
152
+ it('dry-run does not modify any files', async () => {
153
+ const testBackupDir = join(backupRoot, 'bizar-test-dryrun');
154
+ mkdirSync(testBackupDir, { recursive: true });
155
+ mkdirSync(join(testBackupDir, 'state'), { recursive: true });
156
+ const originalContent = { config: { theme: 'dark' }, version: '1.0.0' };
157
+ writeFileSync(join(testBackupDir, 'state', 'state.json'), JSON.stringify(originalContent), { mode: 0o600 });
158
+ writeFileSync(
159
+ join(testBackupDir, 'manifest.json'),
160
+ JSON.stringify({ version: '1.0.0', createdAt: new Date().toISOString(), paths: ['state'] }),
161
+ { mode: 0o600 },
162
+ );
163
+
164
+ const beforeContent = readFileSync(stateFilePath, 'utf8');
165
+ await BACKUP_STORE.restoreBackup({
166
+ backupPath: testBackupDir,
167
+ dryRun: true,
168
+ conflictStrategy: 'overwrite',
169
+ });
170
+ const afterContent = readFileSync(stateFilePath, 'utf8');
171
+ assert.strictEqual(beforeContent, afterContent);
172
+ });
173
+
174
+ it('verifies backup integrity correctly', async () => {
175
+ const testBackupDir = join(backupRoot, 'bizar-valid');
176
+ mkdirSync(testBackupDir, { recursive: true });
177
+ mkdirSync(join(testBackupDir, 'state'), { recursive: true });
178
+ writeFileSync(join(testBackupDir, 'state', 'state.json'), JSON.stringify({ theme: 'dark' }), { mode: 0o600 });
179
+ writeFileSync(
180
+ join(testBackupDir, 'manifest.json'),
181
+ JSON.stringify({ version: '1.0.0', createdAt: new Date().toISOString(), paths: ['state'] }),
182
+ { mode: 0o600 },
183
+ );
184
+
185
+ const result = await BACKUP_STORE.verifyBackup({ backupPath: testBackupDir });
186
+ assert.strictEqual(result.ok, true);
187
+ assert.strictEqual(result.issues.length, 0);
188
+ });
189
+
190
+ it('detects corrupted backup with missing files', async () => {
191
+ const testBackupDir = join(backupRoot, 'bizar-corrupt');
192
+ mkdirSync(testBackupDir, { recursive: true });
193
+ writeFileSync(
194
+ join(testBackupDir, 'manifest.json'),
195
+ JSON.stringify({ version: '1.0.0', createdAt: new Date().toISOString(), paths: ['state', 'missing'] }),
196
+ { mode: 0o600 },
197
+ );
198
+ mkdirSync(join(testBackupDir, 'state'), { recursive: true });
199
+ writeFileSync(join(testBackupDir, 'state', 'state.json'), JSON.stringify({ theme: 'dark' }), { mode: 0o600 });
200
+ // 'missing' path is listed in manifest but doesn't exist
201
+
202
+ const result = await BACKUP_STORE.verifyBackup({ backupPath: testBackupDir });
203
+ assert.strictEqual(result.ok, false);
204
+ assert.ok(result.issues.some((i) => i.includes('missing')));
205
+ });
206
+
207
+ it('deletes backup successfully', async () => {
208
+ const testBackupDir = join(backupRoot, 'bizar-to-delete');
209
+ mkdirSync(testBackupDir, { recursive: true });
210
+ writeFileSync(join(testBackupDir, 'test.txt'), 'will be deleted', { mode: 0o600 });
211
+ assert.ok(existsSync(testBackupDir));
212
+
213
+ const result = await BACKUP_STORE.deleteBackup({ backupPath: testBackupDir });
214
+ assert.strictEqual(result.ok, true);
215
+ assert.ok(!existsSync(testBackupDir));
216
+ });
217
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * tests/backup-restore.test.tsx
3
+ *
4
+ * v4.8.0 — Component test for BackupRestoreCard.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
8
+ import React from 'react';
9
+ import { BackupRestoreCard } from '../src/web/components/BackupRestore';
10
+
11
+ // Mock the API
12
+ vi.mock('../src/web/lib/api', () => ({
13
+ api: {
14
+ get: vi.fn(),
15
+ post: vi.fn(),
16
+ del: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ // Mock the Toast
21
+ vi.mock('../src/web/components/Toast', () => ({
22
+ useToast: () => ({
23
+ success: vi.fn(),
24
+ error: vi.fn(),
25
+ info: vi.fn(),
26
+ }),
27
+ }));
28
+
29
+ // Mock the Modal
30
+ vi.mock('../src/web/components/Modal', () => ({
31
+ useModal: () => ({
32
+ open: vi.fn(),
33
+ close: vi.fn(),
34
+ isModalOpen: false,
35
+ }),
36
+ }));
37
+
38
+ import { api } from '../src/web/lib/api';
39
+
40
+ const mockBackups = [
41
+ {
42
+ path: '/tmp/backups/bizar-2025-07-05-120000',
43
+ name: 'bizar-2025-07-05-120000',
44
+ createdAt: '2025-07-05T12:00:00.000Z',
45
+ sizeBytes: 4096,
46
+ sizeFormatted: '4.0 KB',
47
+ manifest: {
48
+ version: '4.8.0',
49
+ createdAt: '2025-07-05T12:00:00.000Z',
50
+ label: null,
51
+ paths: ['config', 'opencode'],
52
+ },
53
+ },
54
+ ];
55
+
56
+ describe('BackupRestoreCard', () => {
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ (api.get as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, backups: mockBackups });
60
+ (api.post as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, path: '/tmp/backups/bizar-2025-07-05-120000', sizeBytes: 4096, durationMs: 100 });
61
+ });
62
+
63
+ it('renders the card title', async () => {
64
+ render(<BackupRestoreCard />);
65
+ expect(screen.getByText(/Backup.*Restore/i)).toBeTruthy();
66
+ });
67
+
68
+ it('renders the create backup button', async () => {
69
+ render(<BackupRestoreCard />);
70
+ expect(screen.getByRole('button', { name: /create backup/i })).toBeTruthy();
71
+ });
72
+
73
+ it('shows loading state initially', async () => {
74
+ render(<BackupRestoreCard />);
75
+ expect(screen.getByText(/Loading backups/i)).toBeTruthy();
76
+ });
77
+
78
+ it('renders backup list after loading', async () => {
79
+ render(<BackupRestoreCard />);
80
+ await waitFor(() => {
81
+ expect(screen.getByText(/bizar-2025-07-05-120000/)).toBeTruthy();
82
+ });
83
+ });
84
+
85
+ it('calls create backup API when create button is clicked', async () => {
86
+ render(<BackupRestoreCard />);
87
+ await waitFor(() => {
88
+ expect(screen.getByText(/bizar-2025-07-05-120000/)).toBeTruthy();
89
+ });
90
+ const createBtn = screen.getByRole('button', { name: /create backup/i });
91
+ fireEvent.click(createBtn);
92
+ await waitFor(() => {
93
+ expect(api.post).toHaveBeenCalledWith('/backup/create', expect.objectContaining({}));
94
+ });
95
+ });
96
+
97
+ it('calls verify API when verify button is clicked', async () => {
98
+ render(<BackupRestoreCard />);
99
+ await waitFor(() => {
100
+ expect(screen.getByText(/bizar-2025-07-05-120000/)).toBeTruthy();
101
+ });
102
+ const verifyBtn = screen.getByRole('button', { name: /verify/i });
103
+ fireEvent.click(verifyBtn);
104
+ await waitFor(() => {
105
+ expect(api.post).toHaveBeenCalledWith('/backup/verify', expect.objectContaining({
106
+ backupPath: '/tmp/backups/bizar-2025-07-05-120000',
107
+ }));
108
+ });
109
+ });
110
+
111
+ it('opens restore dialog when restore button is clicked', async () => {
112
+ const { useModal } = await import('../src/web/components/Modal');
113
+ render(<BackupRestoreCard />);
114
+ await waitFor(() => {
115
+ expect(screen.getByText(/bizar-2025-07-05-120000/)).toBeTruthy();
116
+ });
117
+ const restoreBtn = screen.getByRole('button', { name: /restore/i });
118
+ fireEvent.click(restoreBtn);
119
+ // Modal should open
120
+ const modalOpen = (useModal as ReturnType<typeof vi.fn>).mock.results[0]?.value?.open;
121
+ expect(modalOpen).toHaveBeenCalled();
122
+ });
123
+ });