@polderlabs/bizar 4.5.2 → 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 (71) hide show
  1. package/bizar-dash/dist/assets/main-DGGq-iZI.js +361 -0
  2. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +1 -0
  3. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-CWqPoGaT.js} +18 -18
  4. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-i4Uv9eW8.js} +1 -1
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-i4Uv9eW8.js.map} +1 -1
  7. package/bizar-dash/dist/index.html +2 -2
  8. package/bizar-dash/dist/mobile.html +2 -2
  9. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  10. package/bizar-dash/src/server/logger.mjs +71 -0
  11. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  12. package/bizar-dash/src/server/metrics.mjs +193 -0
  13. package/bizar-dash/src/server/routes/chat.mjs +6 -3
  14. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  15. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  16. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  17. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  18. package/bizar-dash/src/server/server.mjs +62 -0
  19. package/bizar-dash/src/web/components/SearchModal.tsx +3 -2
  20. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  21. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  22. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  23. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  24. package/bizar-dash/src/web/locales/en.json +52 -0
  25. package/bizar-dash/src/web/main.tsx +5 -0
  26. package/bizar-dash/src/web/views/Activity.tsx +24 -17
  27. package/bizar-dash/src/web/views/History.tsx +90 -75
  28. package/bizar-dash/src/web/views/Overview.tsx +11 -8
  29. package/bizar-dash/src/web/views/Settings.tsx +7 -1
  30. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  31. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  32. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  33. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  34. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  35. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  36. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  37. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  38. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  39. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  40. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  41. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  42. package/bizar-dash/tests/logger.test.mjs +207 -0
  43. package/bizar-dash/tests/metrics.test.mjs +183 -0
  44. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  45. package/bizar-dash/tests/setup.ts +7 -0
  46. package/bizar-dash/vitest.config.ts +13 -0
  47. package/cli/artifact-cli.mjs +605 -0
  48. package/cli/artifact-render.mjs +621 -0
  49. package/cli/artifact-server.mjs +847 -0
  50. package/cli/artifact.mjs +38 -2096
  51. package/cli/bg.mjs +5 -13
  52. package/cli/bin.mjs +149 -1361
  53. package/cli/commands/artifact.mjs +20 -0
  54. package/cli/commands/dash.mjs +160 -0
  55. package/cli/commands/headroom.mjs +204 -0
  56. package/cli/commands/install.mjs +169 -0
  57. package/cli/commands/memory.mjs +25 -0
  58. package/cli/commands/minimax.mjs +265 -0
  59. package/cli/commands/mod.mjs +185 -0
  60. package/cli/commands/service.mjs +65 -0
  61. package/cli/commands/usage.mjs +109 -0
  62. package/cli/commands/util.mjs +306 -0
  63. package/cli/doctor.mjs +1 -13
  64. package/cli/provision.mjs +2 -13
  65. package/cli/service-controller.mjs +1 -11
  66. package/cli/service.mjs +1 -11
  67. package/cli/utils.mjs +41 -1
  68. package/package.json +6 -1
  69. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  70. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  71. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -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
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * tests/logger.test.mjs
3
+ *
4
+ * Tests for src/server/logger.mjs — verifies level filtering, JSON
5
+ * output shape, context merge behaviour, and the child() factory.
6
+ *
7
+ * The module reads BIZAR_LOG_LEVEL at import time, so each test that
8
+ * needs a different level uses Node's --import flag… except that's
9
+ * hard to do mid-test. Instead we set the env BEFORE the first
10
+ * dynamic import by using a top-level await and reset modules via
11
+ * `node:test`'s `mock`.
12
+ *
13
+ * Simpler: we always force level='debug' at the top of each test
14
+ * with a child module that re-exports the logger functions but
15
+ * respects a per-test override. That mirrors what real callers do
16
+ * anyway — they call the functions, not the level constant.
17
+ *
18
+ * Strategy: capture stdout/stderr by replacing console.{log,warn,error}
19
+ * in-place for the duration of the test, then assert on the captured
20
+ * lines. Restore the originals in finally.
21
+ */
22
+ import test from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { Writable } from 'node:stream';
25
+
26
+ function captureConsole() {
27
+ const original = {
28
+ log: console.log,
29
+ warn: console.warn,
30
+ error: console.error,
31
+ };
32
+ const lines = [];
33
+ const sink = new Writable({
34
+ write(chunk, _enc, cb) {
35
+ lines.push(String(chunk).replace(/\n$/, ''));
36
+ cb();
37
+ },
38
+ });
39
+ // Replace console.* with writes to our sink. The logger calls
40
+ // console.log/warn/error, so we route each stream independently to
41
+ // its own bucket so we can verify the level → stream mapping.
42
+ const buckets = { log: [], warn: [], error: [] };
43
+ console.log = (...args) => {
44
+ buckets.log.push(args.map(String).join(' '));
45
+ sink.write(args.map(String).join(' ') + '\n');
46
+ };
47
+ console.warn = (...args) => {
48
+ buckets.warn.push(args.map(String).join(' '));
49
+ sink.write(args.map(String).join(' ') + '\n');
50
+ };
51
+ console.error = (...args) => {
52
+ buckets.error.push(args.map(String).join(' '));
53
+ sink.write(args.map(String).join(' ') + '\n');
54
+ };
55
+ return {
56
+ buckets,
57
+ lines,
58
+ restore() {
59
+ console.log = original.log;
60
+ console.warn = original.warn;
61
+ console.error = original.error;
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Force BIZAR_LOG_LEVEL to the given value BEFORE importing the
68
+ * logger module. We delete the cached module from require.cache so
69
+ * the level constant re-evaluates, then dynamic-import it fresh.
70
+ */
71
+ async function loadLogger(level) {
72
+ // For ESM the import cache lives on the loader, not require.cache.
73
+ // Use a unique query string per level to force a fresh module
74
+ // evaluation (Node treats different specifiers as different modules).
75
+ const url = `../src/server/logger.mjs?level=${level}`;
76
+ process.env.BIZAR_LOG_LEVEL = level;
77
+ return import(url);
78
+ }
79
+
80
+ test('logger emits JSON with ts/level/msg shape', async () => {
81
+ process.env.BIZAR_LOG_LEVEL = 'debug';
82
+ const cap = captureConsole();
83
+ try {
84
+ const { info } = await loadLogger('debug');
85
+ info('server started', { port: 4317 });
86
+ assert.equal(cap.buckets.log.length, 1);
87
+ const parsed = JSON.parse(cap.buckets.log[0]);
88
+ assert.equal(parsed.msg, 'server started');
89
+ assert.equal(parsed.level, 'info');
90
+ assert.equal(parsed.port, 4317);
91
+ assert.ok(typeof parsed.ts === 'string');
92
+ // ts must look like an ISO timestamp.
93
+ assert.match(parsed.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
94
+ } finally {
95
+ cap.restore();
96
+ }
97
+ });
98
+
99
+ test('level filtering — debug suppressed at default level', async () => {
100
+ process.env.BIZAR_LOG_LEVEL = 'info';
101
+ const cap = captureConsole();
102
+ try {
103
+ const { debug, info } = await loadLogger('info');
104
+ debug('should not appear');
105
+ info('should appear');
106
+ assert.equal(cap.buckets.log.length, 1);
107
+ const parsed = JSON.parse(cap.buckets.log[0]);
108
+ assert.equal(parsed.msg, 'should appear');
109
+ assert.equal(parsed.level, 'info');
110
+ } finally {
111
+ cap.restore();
112
+ }
113
+ });
114
+
115
+ test('level filtering — warn goes to console.warn, error to console.error', async () => {
116
+ process.env.BIZAR_LOG_LEVEL = 'debug';
117
+ const cap = captureConsole();
118
+ try {
119
+ const { warn, error } = await loadLogger('debug');
120
+ warn('a warning', { module: 'test' });
121
+ error('a failure', { module: 'test', code: 'E_BANG' });
122
+ assert.equal(cap.buckets.warn.length, 1);
123
+ assert.equal(cap.buckets.error.length, 1);
124
+ assert.equal(cap.buckets.log.length, 0);
125
+ const w = JSON.parse(cap.buckets.warn[0]);
126
+ assert.equal(w.level, 'warn');
127
+ assert.equal(w.module, 'test');
128
+ const e = JSON.parse(cap.buckets.error[0]);
129
+ assert.equal(e.level, 'error');
130
+ assert.equal(e.code, 'E_BANG');
131
+ } finally {
132
+ cap.restore();
133
+ }
134
+ });
135
+
136
+ test('level filtering — error suppressed above error level (none higher exists)', async () => {
137
+ // There's no level above error, so this is a sanity test: errors
138
+ // always print. Make sure we didn't accidentally silence them.
139
+ process.env.BIZAR_LOG_LEVEL = 'error';
140
+ const cap = captureConsole();
141
+ try {
142
+ const { info, warn, error } = await loadLogger('error');
143
+ info('hidden');
144
+ warn('hidden');
145
+ error('visible');
146
+ assert.equal(cap.buckets.error.length, 1);
147
+ assert.equal(cap.buckets.log.length, 0);
148
+ assert.equal(cap.buckets.warn.length, 0);
149
+ assert.equal(JSON.parse(cap.buckets.error[0]).msg, 'visible');
150
+ } finally {
151
+ cap.restore();
152
+ }
153
+ });
154
+
155
+ test('child logger binds context to every call', async () => {
156
+ process.env.BIZAR_LOG_LEVEL = 'debug';
157
+ const cap = captureConsole();
158
+ try {
159
+ const { child } = await loadLogger('debug');
160
+ const log = child({ module: 'overview', session: 'abc' });
161
+ log.info('snapshot built', { size: 42 });
162
+ log.warn('cache miss');
163
+ // Both calls should carry the bound module + session.
164
+ const i = JSON.parse(cap.buckets.log[0]);
165
+ assert.equal(i.msg, 'snapshot built');
166
+ assert.equal(i.module, 'overview');
167
+ assert.equal(i.session, 'abc');
168
+ assert.equal(i.size, 42);
169
+ const w = JSON.parse(cap.buckets.warn[0]);
170
+ assert.equal(w.msg, 'cache miss');
171
+ assert.equal(w.module, 'overview');
172
+ assert.equal(w.session, 'abc');
173
+ } finally {
174
+ cap.restore();
175
+ }
176
+ });
177
+
178
+ test('child per-call context overrides bound context', async () => {
179
+ process.env.BIZAR_LOG_LEVEL = 'debug';
180
+ const cap = captureConsole();
181
+ try {
182
+ const { child } = await loadLogger('debug');
183
+ const log = child({ module: 'overview', session: 'abc' });
184
+ log.info('snapshot', { session: 'override' });
185
+ const parsed = JSON.parse(cap.buckets.log[0]);
186
+ // Per-call context wins over the bound context for shared keys.
187
+ assert.equal(parsed.session, 'override');
188
+ assert.equal(parsed.module, 'overview');
189
+ } finally {
190
+ cap.restore();
191
+ }
192
+ });
193
+
194
+ test('undefined ctx is ignored without throwing', async () => {
195
+ process.env.BIZAR_LOG_LEVEL = 'debug';
196
+ const cap = captureConsole();
197
+ try {
198
+ const { info } = await loadLogger('debug');
199
+ assert.doesNotThrow(() => info('hello'));
200
+ assert.doesNotThrow(() => info('hello', null));
201
+ assert.doesNotThrow(() => info('hello', undefined));
202
+ const parsed = JSON.parse(cap.buckets.log[0]);
203
+ assert.equal(parsed.msg, 'hello');
204
+ } finally {
205
+ cap.restore();
206
+ }
207
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * tests/metrics.test.mjs
3
+ *
4
+ * Tests for src/server/metrics.mjs — verifies counter/gauge/histogram
5
+ * accumulators, label handling, and Prometheus text exposition output.
6
+ *
7
+ * Each test resets the registry in finally so order doesn't matter
8
+ * and tests are independent.
9
+ */
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+
13
+ const METRICS_URL = '../src/server/metrics.mjs';
14
+
15
+ async function loadMetrics() {
16
+ // Bump the URL per-test-call to force fresh module load (the
17
+ // registry state lives at module scope, so a fresh import = a fresh
18
+ // empty registry). Tests reset() in finally too as belt-and-braces.
19
+ const url = `${METRICS_URL}?v=${Date.now()}-${Math.random()}`;
20
+ return import(url);
21
+ }
22
+
23
+ test('counter increments without labels', async () => {
24
+ const m = await loadMetrics();
25
+ try {
26
+ const c = m.counter('hits_total', 'Total hits');
27
+ c.inc();
28
+ c.inc();
29
+ c.inc();
30
+ const out = m.render();
31
+ assert.match(out, /^# HELP hits_total Total hits/m);
32
+ assert.match(out, /^# TYPE hits_total counter/m);
33
+ assert.match(out, /^hits_total 3$/m);
34
+ } finally {
35
+ m.reset();
36
+ }
37
+ });
38
+
39
+ test('counter increments are partitioned by labels', async () => {
40
+ const m = await loadMetrics();
41
+ try {
42
+ const c = m.counter('http_requests_total', 'HTTP requests');
43
+ c.inc({ method: 'GET', status: '200' });
44
+ c.inc({ method: 'GET', status: '200' });
45
+ c.inc({ method: 'POST', status: '200' });
46
+ c.inc({ method: 'GET', status: '500' });
47
+ const out = m.render();
48
+ // Two GET 200 → value 2.
49
+ assert.match(out, /^http_requests_total\{method="GET",status="200"\} 2$/m);
50
+ // One of each for the other combinations.
51
+ assert.match(out, /^http_requests_total\{method="POST",status="200"\} 1$/m);
52
+ assert.match(out, /^http_requests_total\{method="GET",status="500"\} 1$/m);
53
+ } finally {
54
+ m.reset();
55
+ }
56
+ });
57
+
58
+ test('gauge stores last value per label set', async () => {
59
+ const m = await loadMetrics();
60
+ try {
61
+ const g = m.gauge('ws_clients', 'Active WS clients');
62
+ g.set(3);
63
+ g.set(7); // overwrites the 3
64
+ g.set(12, { shard: 'a' });
65
+ g.set(5, { shard: 'b' });
66
+ const out = m.render();
67
+ assert.match(out, /^# TYPE ws_clients gauge/m);
68
+ assert.match(out, /^ws_clients 7$/m);
69
+ assert.match(out, /^ws_clients\{shard="a"\} 12$/m);
70
+ assert.match(out, /^ws_clients\{shard="b"\} 5$/m);
71
+ } finally {
72
+ m.reset();
73
+ }
74
+ });
75
+
76
+ test('histogram tracks count/sum + bucket counts', async () => {
77
+ const m = await loadMetrics();
78
+ try {
79
+ const h = m.histogram('latency_seconds', 'Request latency', {
80
+ buckets: [0.1, 0.5, 1, 5],
81
+ });
82
+ h.observe(0.05); // bucket le=0.1
83
+ h.observe(0.2); // bucket le=0.5
84
+ h.observe(0.2); // bucket le=0.5
85
+ h.observe(2); // bucket le=5
86
+ h.observe(10); // +Inf
87
+ const out = m.render();
88
+ assert.match(out, /^# TYPE latency_seconds histogram/m);
89
+ // Bucket counts: cumulative per Prometheus convention.
90
+ assert.match(out, /^latency_seconds_bucket\{le="0\.1"\} 1$/m);
91
+ assert.match(out, /^latency_seconds_bucket\{le="0\.5"\} 3$/m);
92
+ assert.match(out, /^latency_seconds_bucket\{le="1"\} 3$/m);
93
+ assert.match(out, /^latency_seconds_bucket\{le="5"\} 4$/m);
94
+ assert.match(out, /^latency_seconds_bucket\{le="\+Inf"\} 5$/m);
95
+ // Count + sum.
96
+ assert.match(out, /^latency_seconds_count 5$/m);
97
+ assert.match(out, /^latency_seconds_sum 12\.45$/m);
98
+ } finally {
99
+ m.reset();
100
+ }
101
+ });
102
+
103
+ test('histogram uses default buckets when none provided', async () => {
104
+ const m = await loadMetrics();
105
+ try {
106
+ const h = m.histogram('req_dur', 'Request duration');
107
+ h.observe(0.001); // fits in default 0.005 bucket
108
+ const out = m.render();
109
+ // We don't assert specific default values — just that the
110
+ // standard bucket boundaries appear in the output.
111
+ assert.match(out, /le="0\.005"/);
112
+ assert.match(out, /le="\+Inf"/);
113
+ assert.match(out, /^req_dur_count 1$/m);
114
+ } finally {
115
+ m.reset();
116
+ }
117
+ });
118
+
119
+ test('render returns empty string when no metrics registered', async () => {
120
+ const m = await loadMetrics();
121
+ try {
122
+ assert.equal(m.render(), '');
123
+ } finally {
124
+ m.reset();
125
+ }
126
+ });
127
+
128
+ test('render combines counters, gauges, histograms in order', async () => {
129
+ const m = await loadMetrics();
130
+ try {
131
+ m.counter('first_total', 'first').inc();
132
+ m.gauge('second', 'second').set(42);
133
+ m.histogram('third', 'third').observe(1);
134
+ const out = m.render();
135
+ const i1 = out.indexOf('# TYPE first_total');
136
+ const i2 = out.indexOf('# TYPE second');
137
+ const i3 = out.indexOf('# TYPE third');
138
+ assert.ok(i1 >= 0 && i2 > i1 && i3 > i2, 'metrics must render in registration order');
139
+ } finally {
140
+ m.reset();
141
+ }
142
+ });
143
+
144
+ test('label values are escaped', async () => {
145
+ const m = await loadMetrics();
146
+ try {
147
+ const c = m.counter('events_total', 'Events');
148
+ c.inc({ name: 'has "quote"' });
149
+ const out = m.render();
150
+ assert.match(out, /events_total\{name="has \\"quote\\""\} 1/);
151
+ } finally {
152
+ m.reset();
153
+ }
154
+ });
155
+
156
+ test('reset clears all metric types', async () => {
157
+ const m = await loadMetrics();
158
+ m.counter('c', 'c').inc();
159
+ m.gauge('g', 'g').set(1);
160
+ m.histogram('h', 'h').observe(1);
161
+ assert.notEqual(m.render(), '');
162
+ m.reset();
163
+ assert.equal(m.render(), '');
164
+ });
165
+
166
+ test('labels with same content but different key order collapse to one bucket', async () => {
167
+ // Prometheus convention is that label sets are unordered, so
168
+ // {a:'1',b:'2'} and {b:'2',a:'1'} must be the same series. Our
169
+ // implementation sorts keys before stringifying so this holds.
170
+ const m = await loadMetrics();
171
+ try {
172
+ const c = m.counter('mixed_total', 'mixed');
173
+ c.inc({ a: '1', b: '2' });
174
+ c.inc({ b: '2', a: '1' });
175
+ const out = m.render();
176
+ // Only one series line — both inc calls collapsed.
177
+ const matches = out.match(/^mixed_total\{/gm) || [];
178
+ assert.equal(matches.length, 1);
179
+ assert.match(out, /^mixed_total\{a="1",b="2"\} 2$/m);
180
+ } finally {
181
+ m.reset();
182
+ }
183
+ });
@@ -312,7 +312,7 @@ describe('Bug S3 — chat.mjs per-session delta cap', () => {
312
312
  );
313
313
  assert.match(src, /CHAT_DELTA_BUFFER_CAP\s*=\s*1000/,
314
314
  'cap constant should be set to 1000');
315
- assert.match(src, /console\.warn\(/,
316
- 'cap-exceeded path should log a warning');
315
+ assert.match(src, /logWarn\(/,
316
+ 'cap-exceeded path should log a warning via the structured logger');
317
317
  });
318
318
  });
@@ -0,0 +1,7 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { cleanup } from '@testing-library/react';
3
+ import { afterEach } from 'vitest';
4
+
5
+ afterEach(() => {
6
+ cleanup();
7
+ });
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ test: {
7
+ environment: 'jsdom',
8
+ globals: true,
9
+ setupFiles: ['./tests/setup.ts'],
10
+ include: ['tests/**/*.test.{ts,tsx}'],
11
+ exclude: ['tests/**/*.test.mjs'],
12
+ },
13
+ });