@polderlabs/bizar 4.5.2 → 4.7.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 (123) 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-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.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 -0
  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/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -0,0 +1,264 @@
1
+ /**
2
+ * tests/digest-store.test.mjs
3
+ *
4
+ * v4.8.0 — Tests for the digest store CRUD operations.
5
+ *
6
+ * Verifies:
7
+ * - generateWeeklyDigest returns valid markdown
8
+ * - saveDigest writes file to expected paths
9
+ * - listDigests returns sorted by date
10
+ * - getDigest reads back the saved content
11
+ * - deleteDigest removes the file
12
+ */
13
+
14
+ import { describe, it, beforeEach, afterEach } from 'node:test';
15
+ import assert from 'node:assert';
16
+ import { tmpdir, homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
19
+
20
+ // Point the store to a temp directory
21
+ const storeHome = join(tmpdir(), `bizar-digest-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
22
+ process.env.BIZAR_STORE_HOME = storeHome;
23
+
24
+ const DIGEST_STORE = await import('../src/server/digest-store.mjs');
25
+
26
+ describe('digest-store', () => {
27
+ beforeEach(() => {
28
+ mkdirSync(storeHome, { recursive: true });
29
+ });
30
+
31
+ afterEach(() => {
32
+ try { rmSync(storeHome, { recursive: true, force: true }); } catch { /* ignore */ }
33
+ });
34
+
35
+ // ── computeWeekRange ──────────────────────────────────────────────────────
36
+
37
+ describe('computeWeekRange', () => {
38
+ it('returns last 7 days when no args given', () => {
39
+ const { weekStart, weekEnd } = DIGEST_STORE.computeWeekRange();
40
+ assert.ok(typeof weekStart === 'string');
41
+ assert.ok(typeof weekEnd === 'string');
42
+ assert.match(weekStart, /^\d{4}-\d{2}-\d{2}$/);
43
+ assert.match(weekEnd, /^\d{4}-\d{2}-\d{2}$/);
44
+ // weekEnd should be >= weekStart
45
+ assert.ok(weekEnd >= weekStart);
46
+ });
47
+
48
+ it('returns the specified week range', () => {
49
+ const { weekStart, weekEnd } = DIGEST_STORE.computeWeekRange('2026-06-29', '2026-07-05');
50
+ assert.strictEqual(weekStart, '2026-06-29');
51
+ assert.strictEqual(weekEnd, '2026-07-05');
52
+ });
53
+ });
54
+
55
+ // ── generateWeeklyDigest ──────────────────────────────────────────────────
56
+
57
+ describe('generateWeeklyDigest', () => {
58
+ it('returns valid markdown with frontmatter', async () => {
59
+ const result = await DIGEST_STORE.generateWeeklyDigest({
60
+ weekStart: '2026-06-29',
61
+ weekEnd: '2026-07-05',
62
+ projectRoot: storeHome,
63
+ dryRun: true,
64
+ });
65
+
66
+ assert.ok(result.markdown, 'markdown should be present');
67
+ assert.ok(result.markdown.startsWith('---'), 'markdown should start with frontmatter');
68
+ assert.ok(result.markdown.includes('Weekly Digest'), 'markdown should contain title');
69
+ assert.ok(result.markdown.includes('## Tasks completed'), 'markdown should have tasks section');
70
+ assert.ok(result.markdown.includes('## Memory notes written'), 'markdown should have memory section');
71
+ assert.ok(result.markdown.includes('## Chat sessions'), 'markdown should have chat section');
72
+ assert.ok(result.markdown.includes('## Schedules fired'), 'markdown should have schedules section');
73
+ assert.ok(result.markdown.includes('## Background agents completed'), 'markdown should have bg section');
74
+ assert.ok(result.markdown.includes('## Token usage'), 'markdown should have usage section');
75
+ assert.deepStrictEqual(result.sections, result.sections, 'sections object should be present');
76
+ assert.strictEqual(result.weekStart, '2026-06-29');
77
+ assert.strictEqual(result.weekEnd, '2026-07-05');
78
+ assert.strictEqual(result.dryRun, true);
79
+ });
80
+
81
+ it('defaults to last 7 days when dates omitted', async () => {
82
+ const result = await DIGEST_STORE.generateWeeklyDigest({
83
+ projectRoot: storeHome,
84
+ dryRun: true,
85
+ });
86
+ assert.ok(result.markdown);
87
+ assert.ok(result.weekStart, 'weekStart should be present');
88
+ assert.ok(result.weekEnd, 'weekEnd should be present');
89
+ });
90
+
91
+ it('sections contain expected shapes', async () => {
92
+ const result = await DIGEST_STORE.generateWeeklyDigest({
93
+ weekStart: '2026-06-29',
94
+ weekEnd: '2026-07-05',
95
+ projectRoot: storeHome,
96
+ dryRun: true,
97
+ });
98
+
99
+ // All 7 section keys should be present
100
+ const expectedKeys = ['tasks', 'tasks-created', 'memory-writes', 'chat-sessions',
101
+ 'schedules-fired', 'bg-agents-completed', 'usage-stats'];
102
+ for (const key of expectedKeys) {
103
+ assert.ok(key in result.sections, `section "${key}" should be present`);
104
+ }
105
+ });
106
+ });
107
+
108
+ // ── saveDigest ────────────────────────────────────────────────────────────
109
+
110
+ describe('saveDigest', () => {
111
+ it('writes digest files to expected paths', async () => {
112
+ const markdown = '---\ntitle: "Test"\n---\n\n# Test digest body';
113
+ const result = await DIGEST_STORE.saveDigest({
114
+ markdown,
115
+ weekStart: '2026-07-05',
116
+ });
117
+
118
+ assert.ok(result.ok);
119
+ assert.ok(Array.isArray(result.paths));
120
+ assert.ok(result.paths.length >= 1);
121
+
122
+ // Primary path should exist
123
+ const primaryPath = result.paths[0];
124
+ assert.ok(existsSync(primaryPath), `primary path should exist: ${primaryPath}`);
125
+ const content = readFileSync(primaryPath, 'utf8');
126
+ assert.ok(content.includes(markdown));
127
+ });
128
+
129
+ it('also writes to project vault if .obsidian exists', async () => {
130
+ const vaultDigests = join(storeHome, '.obsidian', 'digests');
131
+ mkdirSync(vaultDigests, { recursive: true });
132
+
133
+ const markdown = '---\ntitle: "Vault Test"\n---\n\n# Vault test body';
134
+ const result = await DIGEST_STORE.saveDigest({
135
+ markdown,
136
+ weekStart: '2026-06-29',
137
+ projectRoot: storeHome,
138
+ });
139
+
140
+ assert.ok(result.ok);
141
+ const vaultPath = join(vaultDigests, 'weekly-2026-06-29.md');
142
+ assert.ok(existsSync(vaultPath), `vault path should exist: ${vaultPath}`);
143
+ });
144
+ });
145
+
146
+ // ── listDigests ───────────────────────────────────────────────────────────
147
+
148
+ describe('listDigests', () => {
149
+ it('returns an empty list when no digests exist', async () => {
150
+ const digests = await DIGEST_STORE.listDigests();
151
+ assert.ok(Array.isArray(digests));
152
+ assert.strictEqual(digests.length, 0);
153
+ });
154
+
155
+ it('returns digests sorted by date descending', async () => {
156
+ // Create two digests out of order
157
+ await DIGEST_STORE.saveDigest({
158
+ markdown: '# Alpha',
159
+ weekStart: '2026-07-05',
160
+ });
161
+ await DIGEST_STORE.saveDigest({
162
+ markdown: '# Beta',
163
+ weekStart: '2026-06-28',
164
+ });
165
+
166
+ const digests = await DIGEST_STORE.listDigests();
167
+ assert.strictEqual(digests.length, 2);
168
+ // Should be sorted: 2026-07-05 first, then 2026-06-28
169
+ assert.strictEqual(digests[0].weekStart, '2026-07-05');
170
+ assert.strictEqual(digests[1].weekStart, '2026-06-28');
171
+ });
172
+
173
+ it('respects the limit parameter', async () => {
174
+ await DIGEST_STORE.saveDigest({ markdown: '# A', weekStart: '2026-07-05' });
175
+ await DIGEST_STORE.saveDigest({ markdown: '# B', weekStart: '2026-06-28' });
176
+ await DIGEST_STORE.saveDigest({ markdown: '# C', weekStart: '2026-06-21' });
177
+
178
+ const digests = await DIGEST_STORE.listDigests({ limit: 2 });
179
+ assert.strictEqual(digests.length, 2);
180
+ });
181
+ });
182
+
183
+ // ── getDigest ─────────────────────────────────────────────────────────────
184
+
185
+ describe('getDigest', () => {
186
+ it('returns null for non-existent path', async () => {
187
+ const result = await DIGEST_STORE.getDigest('/nonexistent/path.md');
188
+ assert.strictEqual(result, null);
189
+ });
190
+
191
+ it('reads back the saved digest content', async () => {
192
+ const markdown = '---\ntitle: "Read Test"\n---\n\n# Read test body';
193
+ const saveResult = await DIGEST_STORE.saveDigest({
194
+ markdown,
195
+ weekStart: '2026-07-04',
196
+ });
197
+ const primaryPath = saveResult.paths[0];
198
+
199
+ const digest = await DIGEST_STORE.getDigest(primaryPath);
200
+ assert.ok(digest, 'digest should be found');
201
+ assert.strictEqual(digest.content, markdown);
202
+ assert.strictEqual(digest.weekStart, '2026-07-04');
203
+ assert.ok(digest.sizeBytes > 0);
204
+ assert.strictEqual(digest.path, primaryPath);
205
+ });
206
+ });
207
+
208
+ // ── deleteDigest ──────────────────────────────────────────────────────────
209
+
210
+ describe('deleteDigest', () => {
211
+ it('returns ok:false for empty path', async () => {
212
+ const result = await DIGEST_STORE.deleteDigest(null);
213
+ assert.strictEqual(result.ok, false);
214
+ });
215
+
216
+ it('removes the digest file and index entry', async () => {
217
+ const markdown = '# Delete me';
218
+ const saveResult = await DIGEST_STORE.saveDigest({
219
+ markdown,
220
+ weekStart: '2026-07-03',
221
+ });
222
+ const primaryPath = saveResult.paths[0];
223
+ assert.ok(existsSync(primaryPath), 'file should exist before deletion');
224
+
225
+ const delResult = await DIGEST_STORE.deleteDigest(primaryPath);
226
+ assert.strictEqual(delResult.ok, true);
227
+ assert.strictEqual(existsSync(primaryPath), false, 'file should be removed');
228
+
229
+ // Index entry should be gone
230
+ const digests = await DIGEST_STORE.listDigests();
231
+ assert.strictEqual(digests.length, 0);
232
+ });
233
+ });
234
+
235
+ // ── generateAndSave ───────────────────────────────────────────────────────
236
+
237
+ describe('generateAndSave', () => {
238
+ it('generates and saves when dryRun is false', async () => {
239
+ const result = await DIGEST_STORE.generateAndSave({
240
+ weekStart: '2026-06-28',
241
+ weekEnd: '2026-07-04',
242
+ projectRoot: storeHome,
243
+ dryRun: false,
244
+ });
245
+
246
+ assert.ok(result.markdown);
247
+ assert.ok(result.saveResult);
248
+ assert.ok(result.saveResult.ok);
249
+ assert.ok(result.saveResult.paths.length >= 1);
250
+ });
251
+
252
+ it('does not save when dryRun is true', async () => {
253
+ const result = await DIGEST_STORE.generateAndSave({
254
+ weekStart: '2026-06-28',
255
+ weekEnd: '2026-07-04',
256
+ projectRoot: storeHome,
257
+ dryRun: true,
258
+ });
259
+
260
+ assert.ok(result.markdown);
261
+ assert.strictEqual(result.saveResult, undefined);
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,84 @@
1
+ import { render, screen, renderHook } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { ModalProvider, useModal } from '../../src/web/components/Modal';
5
+
6
+ function TestHarness() {
7
+ const modal = useModal();
8
+ return (
9
+ <div>
10
+ <button onClick={() => modal.open({ title: 'Dialog', children: <p>Content</p> })}>
11
+ Open
12
+ </button>
13
+ <span data-testid="modal-open">{String(modal.isModalOpen)}</span>
14
+ </div>
15
+ );
16
+ }
17
+
18
+ describe('useModal', () => {
19
+ it('isModalOpen starts as false', () => {
20
+ render(
21
+ <ModalProvider>
22
+ <TestHarness />
23
+ </ModalProvider>,
24
+ );
25
+
26
+ expect(screen.getByTestId('modal-open')).toHaveTextContent('false');
27
+ });
28
+
29
+ it('open() renders a modal dialog', async () => {
30
+ const user = userEvent.setup();
31
+ render(
32
+ <ModalProvider>
33
+ <TestHarness />
34
+ </ModalProvider>,
35
+ );
36
+
37
+ await user.click(screen.getByText('Open'));
38
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
39
+ expect(screen.getByText('Dialog')).toBeInTheDocument();
40
+ expect(screen.getByText('Content')).toBeInTheDocument();
41
+ });
42
+
43
+ it('close() removes the dialog', async () => {
44
+ const user = userEvent.setup();
45
+
46
+ function HarnessWithClose() {
47
+ const modal = useModal();
48
+ return (
49
+ <div>
50
+ <button
51
+ onClick={() =>
52
+ modal.open({
53
+ title: 'Dialog',
54
+ children: <p>Content</p>,
55
+ footer: <button onClick={() => modal.close()}>Close Modal</button>,
56
+ })
57
+ }
58
+ >
59
+ Open
60
+ </button>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ render(
66
+ <ModalProvider>
67
+ <HarnessWithClose />
68
+ </ModalProvider>,
69
+ );
70
+
71
+ await user.click(screen.getByText('Open'));
72
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
73
+
74
+ await user.click(screen.getByText('Close Modal'));
75
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
76
+ });
77
+
78
+ it('returns fallback api when no provider is present', () => {
79
+ const { result } = renderHook(() => useModal());
80
+ expect(result.current.isModalOpen).toBe(false);
81
+ expect(result.current.open({ children: <p>test</p> })).toBe('');
82
+ expect(() => result.current.close()).not.toThrow();
83
+ });
84
+ });
@@ -0,0 +1,50 @@
1
+ import { render, screen, act, renderHook } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { ToastProvider, useToast } from '../../src/web/components/Toast';
5
+
6
+ function TestHarness() {
7
+ const toast = useToast();
8
+ return (
9
+ <div>
10
+ <button onClick={() => toast.success('Success toast body')}>Trigger Success</button>
11
+ <button onClick={() => toast.error('Error toast body')}>Trigger Error</button>
12
+ <button onClick={() => toast.info('Info toast body')}>Trigger Info</button>
13
+ </div>
14
+ );
15
+ }
16
+
17
+ describe('useToast', () => {
18
+ it('calling success() renders a toast', async () => {
19
+ const user = userEvent.setup();
20
+ render(
21
+ <ToastProvider>
22
+ <TestHarness />
23
+ </ToastProvider>,
24
+ );
25
+
26
+ await user.click(screen.getByText('Trigger Success'));
27
+ expect(screen.getByText('Success toast body')).toBeInTheDocument();
28
+ });
29
+
30
+ it('calling error() renders an error toast', async () => {
31
+ const user = userEvent.setup();
32
+ render(
33
+ <ToastProvider>
34
+ <TestHarness />
35
+ </ToastProvider>,
36
+ );
37
+
38
+ await user.click(screen.getByText('Trigger Error'));
39
+ expect(screen.getByText('Error toast body')).toBeInTheDocument();
40
+ });
41
+
42
+ it('returns fallback api when no provider is present', () => {
43
+ const { result } = renderHook(() => useToast());
44
+ expect(result.current.toasts).toEqual([]);
45
+ expect(result.current.isModalOpen).toBeUndefined();
46
+ // Calling methods should not throw
47
+ expect(() => result.current.show('test')).not.toThrow();
48
+ expect(() => result.current.dismiss(1)).not.toThrow();
49
+ });
50
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { t, setLocale, getLocale } from '../../src/web/lib/i18n';
3
+
4
+ describe('i18n', () => {
5
+ it('t() returns the key if no translation exists', () => {
6
+ expect(t('nonexistent.key')).toBe('nonexistent.key');
7
+ });
8
+
9
+ it('t() returns translation if registered', () => {
10
+ setLocale('en', { 'hello': 'Hello World' });
11
+ expect(t('hello')).toBe('Hello World');
12
+ });
13
+
14
+ it('setLocale() switches locale and getLocale() reflects it', () => {
15
+ setLocale('en');
16
+ expect(getLocale()).toBe('en');
17
+
18
+ setLocale('fr');
19
+ expect(getLocale()).toBe('fr');
20
+
21
+ setLocale('de', { greet: 'Hallo' });
22
+ expect(getLocale()).toBe('de');
23
+ expect(t('greet')).toBe('Hallo');
24
+ });
25
+
26
+ it('supports variable interpolation with {varName} syntax', () => {
27
+ setLocale('en', {
28
+ greeting: 'Hello, {name}!',
29
+ ageMsg: '{name} is {age} years old',
30
+ });
31
+ expect(t('greeting', { name: 'World' })).toBe('Hello, World!');
32
+ expect(t('ageMsg', { name: 'Alice', age: 30 })).toBe('Alice is 30 years old');
33
+ });
34
+
35
+ it('falls back to en translations when current locale is missing a key', () => {
36
+ setLocale('en', { shared: 'English text' });
37
+ setLocale('fr', { local: 'Texte français' });
38
+
39
+ // 'shared' is not in fr, should fall back to en
40
+ expect(t('shared')).toBe('English text');
41
+ // 'local' is in fr
42
+ expect(t('local')).toBe('Texte français');
43
+ // Neither locale has this
44
+ expect(t('missing')).toBe('missing');
45
+ });
46
+ });
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ cn,
4
+ formatRelative,
5
+ formatTime,
6
+ truncate,
7
+ debounce,
8
+ statusBadgeKind,
9
+ hashText,
10
+ autoTitleFromContent,
11
+ } from '../../src/web/lib/utils';
12
+
13
+ describe('cn', () => {
14
+ it('joins class names', () => {
15
+ expect(cn('a', 'b')).toBe('a b');
16
+ });
17
+
18
+ it('filters falsy values', () => {
19
+ expect(cn('a', false, null, undefined, 'b')).toBe('a b');
20
+ });
21
+
22
+ it('returns empty string with no args', () => {
23
+ expect(cn()).toBe('');
24
+ });
25
+ });
26
+
27
+ describe('formatRelative', () => {
28
+ it('returns empty string for null/undefined', () => {
29
+ expect(formatRelative(null)).toBe('');
30
+ expect(formatRelative(undefined)).toBe('');
31
+ });
32
+
33
+ it('returns "just now" for recent timestamps', () => {
34
+ expect(formatRelative(Date.now())).toBe('just now');
35
+ expect(formatRelative(Date.now() + 1000)).toBe('just now');
36
+ });
37
+
38
+ it('returns minutes', () => {
39
+ const fiveMinAgo = Date.now() - 5 * 60_000;
40
+ expect(formatRelative(fiveMinAgo)).toBe('5m ago');
41
+ });
42
+
43
+ it('returns hours', () => {
44
+ const twoHoursAgo = Date.now() - 2 * 3_600_000;
45
+ expect(formatRelative(twoHoursAgo)).toBe('2h ago');
46
+ });
47
+
48
+ it('returns days', () => {
49
+ const threeDaysAgo = Date.now() - 3 * 86_400_000;
50
+ expect(formatRelative(threeDaysAgo)).toBe('3d ago');
51
+ });
52
+
53
+ it('returns localized date for older timestamps', () => {
54
+ const oldDate = Date.now() - 30 * 86_400_000;
55
+ const result = formatRelative(oldDate);
56
+ expect(result).not.toMatch(/^(just now|\d+[mhd] ago)$/);
57
+ expect(result.length).toBeGreaterThan(0);
58
+ });
59
+ });
60
+
61
+ describe('formatTime', () => {
62
+ it('returns empty for null/undefined', () => {
63
+ expect(formatTime(null)).toBe('');
64
+ expect(formatTime(undefined)).toBe('');
65
+ });
66
+
67
+ it('formats a Date object', () => {
68
+ const d = new Date('2024-06-15T12:00:00');
69
+ const result = formatTime(d);
70
+ expect(result).toBe(d.toLocaleString());
71
+ });
72
+
73
+ it('formats a timestamp number', () => {
74
+ const ts = new Date('2024-06-15T12:00:00').getTime();
75
+ expect(formatTime(ts)).toBe(new Date(ts).toLocaleString());
76
+ });
77
+ });
78
+
79
+ describe('truncate', () => {
80
+ it('returns empty for null/undefined', () => {
81
+ expect(truncate(null)).toBe('');
82
+ expect(truncate(undefined)).toBe('');
83
+ });
84
+
85
+ it('returns string as-is when shorter than max', () => {
86
+ expect(truncate('hello', 10)).toBe('hello');
87
+ });
88
+
89
+ it('truncates with ellipsis when longer than max', () => {
90
+ expect(truncate('hello world this is long', 10)).toBe('hello worl…');
91
+ });
92
+
93
+ it('uses default max of 160', () => {
94
+ const short = 'a'.repeat(100);
95
+ expect(truncate(short)).toBe(short);
96
+ const long = 'a'.repeat(200);
97
+ expect(truncate(long)).toHaveLength(161);
98
+ expect(truncate(long)).toMatch(/…$/);
99
+ });
100
+ });
101
+
102
+ describe('debounce', () => {
103
+ it('delays execution', () => {
104
+ vi.useFakeTimers();
105
+ const fn = vi.fn();
106
+ const debounced = debounce(fn, 100);
107
+
108
+ debounced();
109
+ expect(fn).not.toHaveBeenCalled();
110
+
111
+ vi.advanceTimersByTime(100);
112
+ expect(fn).toHaveBeenCalledOnce();
113
+ vi.useRealTimers();
114
+ });
115
+
116
+ it('calls with the latest arguments', () => {
117
+ vi.useFakeTimers();
118
+ const fn = vi.fn();
119
+ const debounced = debounce(fn, 100);
120
+
121
+ debounced('a');
122
+ debounced('b');
123
+ vi.advanceTimersByTime(100);
124
+
125
+ expect(fn).toHaveBeenCalledOnce();
126
+ expect(fn).toHaveBeenCalledWith('b');
127
+ vi.useRealTimers();
128
+ });
129
+ });
130
+
131
+ describe('statusBadgeKind', () => {
132
+ it('returns success for approved/done', () => {
133
+ expect(statusBadgeKind('approved')).toBe('success');
134
+ expect(statusBadgeKind('done')).toBe('success');
135
+ });
136
+
137
+ it('returns empty for draft/queued', () => {
138
+ expect(statusBadgeKind('draft')).toBe('');
139
+ expect(statusBadgeKind('queued')).toBe('');
140
+ });
141
+
142
+ it('returns info for in-progress/doing', () => {
143
+ expect(statusBadgeKind('in-progress')).toBe('info');
144
+ expect(statusBadgeKind('doing')).toBe('info');
145
+ });
146
+
147
+ it('returns error for rejected', () => {
148
+ expect(statusBadgeKind('rejected')).toBe('error');
149
+ });
150
+
151
+ it('returns accent for unknown statuses', () => {
152
+ expect(statusBadgeKind('unknown')).toBe('accent');
153
+ });
154
+ });
155
+
156
+ describe('hashText', () => {
157
+ it('returns a string', () => {
158
+ expect(typeof hashText('hello')).toBe('string');
159
+ });
160
+
161
+ it('is deterministic', () => {
162
+ expect(hashText('hello')).toBe(hashText('hello'));
163
+ });
164
+
165
+ it('differs for different inputs', () => {
166
+ expect(hashText('hello')).not.toBe(hashText('world'));
167
+ });
168
+ });
169
+
170
+ describe('autoTitleFromContent', () => {
171
+ it('extracts first non-empty line', () => {
172
+ expect(autoTitleFromContent('Hello world')).toBe('Hello world');
173
+ });
174
+
175
+ it('strips markdown heading markers', () => {
176
+ expect(autoTitleFromContent('## My Feature')).toBe('My Feature');
177
+ });
178
+
179
+ it('generates fallback title when body is empty', () => {
180
+ const result = autoTitleFromContent('');
181
+ expect(result).toMatch(/^Untitled task/);
182
+ });
183
+
184
+ it('returns empty when fallback is false and body is empty', () => {
185
+ expect(autoTitleFromContent('', false)).toBe('');
186
+ });
187
+
188
+ it('truncates long first lines', () => {
189
+ const long = 'a'.repeat(100);
190
+ const result = autoTitleFromContent(long);
191
+ expect(result.length).toBeLessThan(long.length);
192
+ expect(result).toMatch(/…$/);
193
+ });
194
+ });