@polderlabs/bizar 4.5.1 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  2. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  3. package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
  4. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-O6ANdD4W.js → mobile-CWqPoGaT.js} +18 -19
  6. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js → mobile-i4Uv9eW8.js} +1 -2
  8. package/bizar-dash/dist/assets/{mobile-DQLFCjwJ.js.map → mobile-i4Uv9eW8.js.map} +1 -1
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  12. package/bizar-dash/src/server/headroom.mjs +37 -35
  13. package/bizar-dash/src/server/logger.mjs +71 -0
  14. package/bizar-dash/src/server/memory-lightrag.mjs +84 -37
  15. package/bizar-dash/src/server/metrics.mjs +193 -0
  16. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  17. package/bizar-dash/src/server/providers-store.mjs +73 -1
  18. package/bizar-dash/src/server/routes/chat.mjs +41 -0
  19. package/bizar-dash/src/server/routes/lightrag.mjs +7 -2
  20. package/bizar-dash/src/server/routes/memory.mjs +13 -4
  21. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  22. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  23. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  24. package/bizar-dash/src/server/server.mjs +101 -37
  25. package/bizar-dash/src/server/watcher.mjs +2 -2
  26. package/bizar-dash/src/web/App.tsx +40 -6
  27. package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
  28. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  29. package/bizar-dash/src/web/components/Topbar.tsx +1 -1
  30. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  31. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  32. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  33. package/bizar-dash/src/web/lib/api.ts +12 -11
  34. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  35. package/bizar-dash/src/web/locales/en.json +52 -0
  36. package/bizar-dash/src/web/main.tsx +5 -0
  37. package/bizar-dash/src/web/styles/chat.css +2 -0
  38. package/bizar-dash/src/web/styles/main.css +52 -23
  39. package/bizar-dash/src/web/styles/tasks.css +2 -0
  40. package/bizar-dash/src/web/views/Activity.tsx +24 -17
  41. package/bizar-dash/src/web/views/History.tsx +90 -75
  42. package/bizar-dash/src/web/views/Memory.tsx +4 -3
  43. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  44. package/bizar-dash/src/web/views/Overview.tsx +15 -10
  45. package/bizar-dash/src/web/views/Settings.tsx +9 -2
  46. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  47. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  48. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +8 -6
  49. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  50. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  51. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  52. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  53. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  54. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  55. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  56. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  59. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  60. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  61. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  62. package/bizar-dash/tests/logger.test.mjs +207 -0
  63. package/bizar-dash/tests/metrics.test.mjs +183 -0
  64. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  65. package/bizar-dash/tests/setup.ts +7 -0
  66. package/bizar-dash/vitest.config.ts +13 -0
  67. package/cli/artifact-cli.mjs +605 -0
  68. package/cli/artifact-render.mjs +621 -0
  69. package/cli/artifact-server.mjs +847 -0
  70. package/cli/artifact.mjs +38 -2089
  71. package/cli/bg.mjs +5 -13
  72. package/cli/bin.mjs +170 -1348
  73. package/cli/commands/artifact.mjs +20 -0
  74. package/cli/commands/dash.mjs +160 -0
  75. package/cli/commands/headroom.mjs +204 -0
  76. package/cli/commands/install.mjs +169 -0
  77. package/cli/commands/memory.mjs +25 -0
  78. package/cli/commands/minimax.mjs +265 -0
  79. package/cli/commands/mod.mjs +185 -0
  80. package/cli/commands/service.mjs +65 -0
  81. package/cli/commands/usage.mjs +109 -0
  82. package/cli/commands/util.mjs +306 -0
  83. package/cli/doctor.mjs +6 -16
  84. package/cli/memory.mjs +65 -21
  85. package/cli/provision.mjs +10 -15
  86. package/cli/service-controller.mjs +1 -11
  87. package/cli/service.mjs +1 -11
  88. package/cli/utils.mjs +41 -1
  89. package/install.sh +1 -2
  90. package/package.json +8 -3
  91. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +0 -1
  92. package/bizar-dash/dist/assets/main-usWhlPWa.js +0 -362
  93. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +0 -1
  94. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +0 -1
@@ -0,0 +1,151 @@
1
+ // bizar-dash/tests/frontend-bugfixes.test.mjs
2
+ // Lightweight verification of dashboard web + build bug fixes.
3
+ // Run with: node --test bizar-dash/tests/frontend-bugfixes.test.mjs
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { resolve } from 'node:path';
7
+ import { describe, it } from 'node:test';
8
+
9
+ // import.meta.dirname = /.../BizarHarness/bizar-dash/tests
10
+ // So project root is up 2 levels
11
+ const ROOT = import.meta.dirname; // .../BizarHarness/bizar-dash/tests
12
+ const PROJECT = resolve(ROOT, '..', '..'); // .../BizarHarness/
13
+ const DASH = resolve(ROOT, '..'); // .../BizarHarness/bizar-dash/
14
+
15
+ function read(file) {
16
+ return readFileSync(resolve(PROJECT, file), 'utf-8');
17
+ }
18
+ function dash(file) {
19
+ return readFileSync(resolve(DASH, file), 'utf-8');
20
+ }
21
+
22
+ describe('W1: vite.config.ts sourcemap: hidden', () => {
23
+ it("sourcemap is 'hidden'", () => {
24
+ const content = read('vite.config.ts');
25
+ if (!content.includes("sourcemap: 'hidden'")) {
26
+ throw new Error("vite.config.ts missing sourcemap: 'hidden'");
27
+ }
28
+ if (content.includes('sourcemap: true')) {
29
+ throw new Error('vite.config.ts still contains sourcemap: true');
30
+ }
31
+ });
32
+ });
33
+
34
+ describe('W2: .npmignore excludes source maps and tests', () => {
35
+ it('excludes .map files from dist', () => {
36
+ const c = read('.npmignore');
37
+ if (!c.includes('bizar-dash/dist/**/*.map')) throw new Error('.npmignore missing bizar-dash/dist/**/*.map');
38
+ });
39
+ it('excludes **/__tests__/', () => {
40
+ const c = read('.npmignore');
41
+ if (!c.includes('**/__tests__/')) throw new Error('.npmignore missing **/__tests__/');
42
+ });
43
+ it('excludes **/*.test.mjs', () => {
44
+ const c = read('.npmignore');
45
+ if (!c.includes('**/*.test.mjs')) throw new Error('.npmignore missing **/*.test.mjs');
46
+ });
47
+ it('excludes **/*.test.ts', () => {
48
+ const c = read('.npmignore');
49
+ if (!c.includes('**/*.test.ts')) throw new Error('.npmignore missing **/*.test.ts');
50
+ });
51
+ it('excludes **/*.test.tsx', () => {
52
+ const c = read('.npmignore');
53
+ if (!c.includes('**/*.test.tsx')) throw new Error('.npmignore missing **/*.test.tsx');
54
+ });
55
+ });
56
+
57
+ describe('W3: Toast.tsx accessibility', () => {
58
+ it('ToastItem has role="alert"', () => {
59
+ const c = dash('src/web/components/Toast.tsx');
60
+ if (!c.includes('role="alert"')) throw new Error('Toast.tsx missing role="alert"');
61
+ });
62
+ it('ToastItem has aria-live="assertive"', () => {
63
+ const c = dash('src/web/components/Toast.tsx');
64
+ if (!c.includes('aria-live="assertive"')) throw new Error('Toast.tsx missing aria-live="assertive"');
65
+ });
66
+ it('ToastItem has aria-atomic="true"', () => {
67
+ const c = dash('src/web/components/Toast.tsx');
68
+ if (!c.includes('aria-atomic="true"')) throw new Error('Toast.tsx missing aria-atomic="true"');
69
+ });
70
+ });
71
+
72
+ describe('W4: App.tsx Suspense wrapper', () => {
73
+ it('Suspense imported from react', () => {
74
+ const c = dash('src/web/App.tsx');
75
+ if (!c.includes('Suspense')) throw new Error('App.tsx missing Suspense import');
76
+ });
77
+ it('renderedView wrapped in Suspense', () => {
78
+ const c = dash('src/web/App.tsx');
79
+ if (!c.includes('<Suspense')) throw new Error('App.tsx missing <Suspense> wrapper');
80
+ });
81
+ });
82
+
83
+ describe('W5: React.memo on view components', () => {
84
+ const views = [
85
+ ['Tasks', dash('src/web/views/Tasks.tsx')],
86
+ ['Settings', dash('src/web/views/Settings.tsx')],
87
+ ['Memory', dash('src/web/views/Memory.tsx')],
88
+ ['Overview', dash('src/web/views/Overview.tsx')],
89
+ ['Skills', dash('src/web/views/Skills.tsx')],
90
+ ['MiniMaxUsage',dash('src/web/views/MiniMaxUsage.tsx')],
91
+ ];
92
+ for (const [name, content] of views) {
93
+ it(`${name} is wrapped in React.memo`, () => {
94
+ // Pattern: const Name = React.memo(...) or export const Name = React.memo(...)
95
+ if (!content.includes('React.memo(')) {
96
+ throw new Error(`${name} is not wrapped in React.memo`);
97
+ }
98
+ });
99
+ }
100
+ });
101
+
102
+ describe('W6: Topbar ARIA roles', () => {
103
+ const c = dash('src/web/components/Topbar.tsx');
104
+ it('tabs-row has role="tablist"', () => { if (!c.includes('role="tablist"')) throw new Error('Topbar missing role="tablist"'); });
105
+ it('tabs-row has aria-label="Primary tabs"', () => { if (!c.includes('aria-label="Primary tabs"')) throw new Error('Topbar missing aria-label="Primary tabs"'); });
106
+ it('tab buttons have role="tab"', () => { if (!c.includes('role="tab"')) throw new Error('Topbar missing role="tab"'); });
107
+ it('tab buttons have aria-selected', () => { if (!c.includes('aria-selected=')) throw new Error('Topbar missing aria-selected on tabs'); });
108
+ });
109
+
110
+ describe('F1: Manual snapshot refetch removed from file change WS event', () => {
111
+ it("'change' WS handler no longer calls api.get('/snapshot')", () => {
112
+ const c = dash('src/web/App.tsx');
113
+ const idx = c.indexOf("msg.type === 'change'");
114
+ if (idx === -1) throw new Error("msg.type === 'change' not found");
115
+ const block = c.slice(idx, idx + 600);
116
+ if (block.includes("api.get<Snapshot>('/snapshot')") || block.includes('api.get("/snapshot")')) {
117
+ throw new Error("Manual api.get('/snapshot') still present in 'change' handler");
118
+ }
119
+ });
120
+ });
121
+
122
+ describe('F2: content-visibility: auto on list items', () => {
123
+ it('.activity-item has content-visibility: auto (main.css)', () => {
124
+ const c = dash('src/web/styles/main.css');
125
+ const i = c.indexOf('.activity-item');
126
+ if (!c.slice(i, i + 500).includes('content-visibility: auto')) {
127
+ throw new Error('.activity-item missing content-visibility: auto in main.css');
128
+ }
129
+ });
130
+ it('.task-card has content-visibility: auto (main.css)', () => {
131
+ const c = dash('src/web/styles/main.css');
132
+ const i = c.indexOf('.task-card');
133
+ if (!c.slice(i, i + 500).includes('content-visibility: auto')) {
134
+ throw new Error('.task-card missing content-visibility: auto in main.css');
135
+ }
136
+ });
137
+ it('.chat-message has content-visibility: auto (chat.css)', () => {
138
+ const c = dash('src/web/styles/chat.css');
139
+ const i = c.indexOf('.chat-message');
140
+ if (!c.slice(i, i + 300).includes('content-visibility: auto')) {
141
+ throw new Error('.chat-message missing content-visibility: auto in chat.css');
142
+ }
143
+ });
144
+ it('.task-card has content-visibility: auto (tasks.css)', () => {
145
+ const c = dash('src/web/styles/tasks.css');
146
+ const i = c.indexOf('.task-card');
147
+ if (!c.slice(i, i + 500).includes('content-visibility: auto')) {
148
+ throw new Error('.task-card missing content-visibility: auto in tasks.css');
149
+ }
150
+ });
151
+ });
@@ -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
+ });