@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,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
+ });
@@ -0,0 +1,298 @@
1
+ /**
2
+ * tests/rate-limit.test.mjs
3
+ *
4
+ * v4.8.0 — Tests for src/server/lib/rate-limit.mjs.
5
+ *
6
+ * Run with:
7
+ * node --test tests/rate-limit.test.mjs
8
+ *
9
+ * Covers:
10
+ * - Request within capacity returns 200 + X-RateLimit-* headers
11
+ * - Request over capacity returns 429 with Retry-After + JSON body
12
+ * - Bucket refills over time (verified via mock.timers tick)
13
+ * - Different IPs have independent buckets
14
+ * - Different scopes (chat vs event) have independent buckets
15
+ * - clearBuckets() resets state between tests
16
+ * - Constructor validates inputs
17
+ * - 429 response carries X-RateLimit-Scope so server.mjs can log it
18
+ */
19
+ import test from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { mock } from 'node:test';
22
+ import express from 'express';
23
+
24
+ import {
25
+ createRateLimiter,
26
+ clearBuckets,
27
+ _peekBucket,
28
+ _bucketSize,
29
+ } from '../src/server/lib/rate-limit.mjs';
30
+
31
+ // ── Helpers ───────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Build a minimal Express app with one route guarded by the given
35
+ * limiter. We override `req.ip` via the `trust proxy` setting on the
36
+ * app instance so tests can set it deterministically.
37
+ */
38
+ function buildApp({ capacity, refillPerSecond, scope = 'test' }) {
39
+ const app = express();
40
+ app.set('trust proxy', true);
41
+ const limiter = createRateLimiter({ capacity, refillPerSecond, scope });
42
+ app.get('/ping', limiter, (_req, res) => res.json({ ok: true }));
43
+ return app;
44
+ }
45
+
46
+ /**
47
+ * Drive the Express app without binding a TCP socket. Returns the
48
+ * (status, headers, body) of the response so tests can assert without
49
+ * the noise of supertest.
50
+ */
51
+ function drive(app, { ip = '127.0.0.1', method = 'GET', path = '/ping' } = {}) {
52
+ return new Promise((resolve, reject) => {
53
+ const req = {
54
+ method,
55
+ url: path,
56
+ ip,
57
+ socket: { remoteAddress: ip },
58
+ headers: {},
59
+ };
60
+ const resHeaders = {};
61
+ const res = {
62
+ statusCode: 200,
63
+ headersSent: false,
64
+ finished: false,
65
+ set(name, value) {
66
+ resHeaders[name.toLowerCase()] = value;
67
+ return this;
68
+ },
69
+ setHeader(name, value) {
70
+ resHeaders[name.toLowerCase()] = value;
71
+ return this;
72
+ },
73
+ getHeader(name) {
74
+ return resHeaders[name.toLowerCase()];
75
+ },
76
+ status(code) {
77
+ this.statusCode = code;
78
+ return this;
79
+ },
80
+ json(body) {
81
+ this.headersSent = true;
82
+ this.finished = true;
83
+ resolve({ status: this.statusCode, headers: resHeaders, body });
84
+ return this;
85
+ },
86
+ send(body) {
87
+ this.headersSent = true;
88
+ this.finished = true;
89
+ resolve({ status: this.statusCode, headers: resHeaders, body });
90
+ return this;
91
+ },
92
+ end(body) {
93
+ this.headersSent = true;
94
+ this.finished = true;
95
+ resolve({ status: this.statusCode, headers: resHeaders, body });
96
+ return this;
97
+ },
98
+ on() {
99
+ return this;
100
+ },
101
+ once() {
102
+ return this;
103
+ },
104
+ emit() {
105
+ return true;
106
+ },
107
+ };
108
+ try {
109
+ app._router.handle(req, res, (err) => {
110
+ if (err) reject(err);
111
+ else if (!res.finished) {
112
+ // Should not happen — limiter always responds.
113
+ reject(new Error('handler fell through without responding'));
114
+ }
115
+ });
116
+ } catch (err) {
117
+ reject(err);
118
+ }
119
+ });
120
+ }
121
+
122
+ // ── Tests ─────────────────────────────────────────────────────────────────
123
+
124
+ test('accepts request within capacity and emits X-RateLimit-* headers', async () => {
125
+ clearBuckets();
126
+ const app = buildApp({ capacity: 5, refillPerSecond: 1, scope: 'test1' });
127
+ const r = await drive(app, { ip: '10.0.0.1' });
128
+ assert.equal(r.status, 200);
129
+ assert.deepEqual(r.body, { ok: true });
130
+ assert.equal(r.headers['x-ratelimit-limit'], '5');
131
+ // 1 token consumed from a full bucket of 5 → 4 remain.
132
+ assert.equal(r.headers['x-ratelimit-remaining'], '4');
133
+ assert.equal(r.headers['x-ratelimit-scope'], 'test1');
134
+ assert.equal(r.headers['retry-after'], undefined);
135
+ });
136
+
137
+ test('rejects over-capacity requests with 429 + Retry-After + JSON body', async () => {
138
+ clearBuckets();
139
+ const app = buildApp({ capacity: 2, refillPerSecond: 0.5, scope: 'test2' });
140
+ // Drain the bucket.
141
+ await drive(app, { ip: '10.0.0.2' });
142
+ await drive(app, { ip: '10.0.0.2' });
143
+ // Third request must be throttled.
144
+ const r = await drive(app, { ip: '10.0.0.2' });
145
+ assert.equal(r.status, 429);
146
+ assert.equal(r.body.error, 'rate_limited');
147
+ assert.equal(r.body.scope, 'test2');
148
+ assert.ok(typeof r.body.retryAfter === 'number' && r.body.retryAfter >= 1);
149
+ assert.equal(r.headers['x-ratelimit-limit'], '2');
150
+ assert.equal(r.headers['x-ratelimit-remaining'], '0');
151
+ assert.equal(r.headers['x-ratelimit-scope'], 'test2');
152
+ assert.ok(r.headers['retry-after']);
153
+ // retry-after is a string of an integer ≥ 1.
154
+ assert.match(r.headers['retry-after'], /^[1-9]\d*$/);
155
+ });
156
+
157
+ test('bucket refills over time', () => {
158
+ clearBuckets();
159
+ mock.timers.enable({ apis: ['Date'] });
160
+ try {
161
+ const limiter = createRateLimiter({
162
+ capacity: 5,
163
+ refillPerSecond: 1,
164
+ scope: 'refill',
165
+ });
166
+ // Build a minimal req/res pair; we'll call the middleware directly.
167
+ function fakeRes() {
168
+ const headers = {};
169
+ let status = 200;
170
+ return {
171
+ statusCode: status,
172
+ set(name, value) { headers[name.toLowerCase()] = value; return this; },
173
+ getHeader(name) { return headers[name.toLowerCase()]; },
174
+ status(code) { this.statusCode = code; return this; },
175
+ json() { /* swallow */ },
176
+ headers,
177
+ };
178
+ }
179
+ function fakeReq(ip) {
180
+ return { ip, socket: { remoteAddress: ip } };
181
+ }
182
+
183
+ // Drain 5 tokens instantly.
184
+ for (let i = 0; i < 5; i++) {
185
+ limiter(fakeReq('10.0.0.3'), fakeRes(), () => {});
186
+ }
187
+ // The 6th request must be rejected.
188
+ const denied = fakeRes();
189
+ let nextCalled = false;
190
+ limiter(fakeReq('10.0.0.3'), denied, () => { nextCalled = true; });
191
+ assert.equal(denied.statusCode, 429);
192
+ assert.equal(nextCalled, false);
193
+
194
+ // Advance time by 1 second → 1 token regenerated.
195
+ mock.timers.tick(1000);
196
+ const accepted = fakeRes();
197
+ let acceptedNext = false;
198
+ limiter(fakeReq('10.0.0.3'), accepted, () => { acceptedNext = true; });
199
+ assert.equal(accepted.statusCode, 200);
200
+ assert.equal(acceptedNext, true);
201
+
202
+ // Bucket state sanity check.
203
+ const bucket = _peekBucket('refill:10.0.0.3');
204
+ assert.ok(bucket, 'expected bucket to exist');
205
+ assert.ok(bucket.tokens >= 0 && bucket.tokens < 5);
206
+ } finally {
207
+ mock.timers.reset();
208
+ clearBuckets();
209
+ }
210
+ });
211
+
212
+ test('different IPs have independent buckets', async () => {
213
+ clearBuckets();
214
+ const app = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'iso-ip' });
215
+ // Drain IP A.
216
+ const a1 = await drive(app, { ip: '10.0.0.10' });
217
+ assert.equal(a1.status, 200);
218
+ const a2 = await drive(app, { ip: '10.0.0.10' });
219
+ assert.equal(a2.status, 429);
220
+ // IP B still has full budget.
221
+ const b1 = await drive(app, { ip: '10.0.0.11' });
222
+ assert.equal(b1.status, 200);
223
+ assert.equal(b1.headers['x-ratelimit-remaining'], '0');
224
+ });
225
+
226
+ test('different scopes (chat vs event) have independent buckets', async () => {
227
+ clearBuckets();
228
+ const chatApp = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'chat' });
229
+ const eventApp = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'event' });
230
+ // Drain chat for this IP.
231
+ const c1 = await drive(chatApp, { ip: '10.0.0.20' });
232
+ assert.equal(c1.status, 200);
233
+ const c2 = await drive(chatApp, { ip: '10.0.0.20' });
234
+ assert.equal(c2.status, 429);
235
+ assert.equal(c2.body.scope, 'chat');
236
+ // Event scope for the SAME ip still has full budget.
237
+ const e1 = await drive(eventApp, { ip: '10.0.0.20' });
238
+ assert.equal(e1.status, 200);
239
+ assert.equal(e1.headers['x-ratelimit-scope'], 'event');
240
+ });
241
+
242
+ test('clearBuckets() resets state', async () => {
243
+ clearBuckets();
244
+ const app = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'clear' });
245
+ await drive(app, { ip: '10.0.0.30' });
246
+ assert.equal(_bucketSize(), 1);
247
+ // Bucket exhausted — next request 429.
248
+ const r = await drive(app, { ip: '10.0.0.30' });
249
+ assert.equal(r.status, 429);
250
+ // Reset and retry — fresh budget.
251
+ clearBuckets();
252
+ assert.equal(_bucketSize(), 0);
253
+ const r2 = await drive(app, { ip: '10.0.0.30' });
254
+ assert.equal(r2.status, 200);
255
+ });
256
+
257
+ test('rejects invalid constructor arguments', () => {
258
+ assert.throws(() => createRateLimiter({ capacity: 0, refillPerSecond: 1 }), /capacity/);
259
+ assert.throws(() => createRateLimiter({ capacity: -1, refillPerSecond: 1 }), /capacity/);
260
+ assert.throws(() => createRateLimiter({ capacity: 'x', refillPerSecond: 1 }), /capacity/);
261
+ assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: 0 }), /refillPerSecond/);
262
+ assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: -1 }), /refillPerSecond/);
263
+ assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: 1, scope: '' }), /scope/);
264
+ assert.throws(() => createRateLimiter({ capacity: 5, refillPerSecond: 1, scope: 42 }), /scope/);
265
+ });
266
+
267
+ test('falls back to socket.remoteAddress when req.ip missing', async () => {
268
+ clearBuckets();
269
+ const app = buildApp({ capacity: 1, refillPerSecond: 0.01, scope: 'sock' });
270
+ // Build a request WITHOUT ip but WITH socket.remoteAddress. We have
271
+ // to drive it manually because buildApp always sets ip.
272
+ const limiter = createRateLimiter({
273
+ capacity: 1,
274
+ refillPerSecond: 0.01,
275
+ scope: 'sock',
276
+ });
277
+ function fakeRes() {
278
+ const headers = {};
279
+ return {
280
+ statusCode: 200,
281
+ set(name, value) { headers[name.toLowerCase()] = value; return this; },
282
+ getHeader(name) { return headers[name.toLowerCase()]; },
283
+ status(code) { this.statusCode = code; return this; },
284
+ json() { /* swallow */ },
285
+ headers,
286
+ };
287
+ }
288
+ // No req.ip, no socket — must default to 'unknown'.
289
+ const req = {};
290
+ const res1 = fakeRes();
291
+ limiter(req, res1, () => {});
292
+ assert.equal(res1.statusCode, 200);
293
+ const res2 = fakeRes();
294
+ limiter(req, res2, () => {});
295
+ assert.equal(res2.statusCode, 429);
296
+ // The unknown-IP bucket must exist.
297
+ assert.ok(_peekBucket('sock:unknown'));
298
+ });
@@ -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
  });