claw-dashboard 1.9.0 → 2.1.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 (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5285 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -6
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1934 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1056 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
package/tests/utils.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Test utilities extracted from index.js
3
+ * These are pure functions that can be unit tested without the Dashboard class
4
+ */
5
+
6
+ import os from 'os';
7
+ import { resolve, join } from 'path';
8
+
9
+ // Color constants (same as in index.js)
10
+ const C = {
11
+ green: 'green', brightGreen: 'bright-green',
12
+ yellow: 'yellow', brightYellow: 'bright-yellow',
13
+ red: 'red', brightRed: 'bright-red',
14
+ cyan: 'cyan', brightCyan: 'bright-cyan',
15
+ magenta: 'magenta', brightMagenta: 'bright-magenta',
16
+ blue: 'blue', brightBlue: 'bright-blue',
17
+ white: 'white', brightWhite: 'bright-white',
18
+ gray: 'gray', black: 'black'
19
+ };
20
+
21
+ /**
22
+ * Generate a gauge bar visualization
23
+ * @param {number} percent - Percentage (0-100)
24
+ * @param {number} width - Width of the gauge in characters
25
+ * @returns {string} Gauge visualization
26
+ */
27
+ function gauge(percent, width = 15) {
28
+ const filled = Math.round((percent / 100) * width);
29
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
30
+ }
31
+
32
+ /**
33
+ * Generate a sparkline from data points
34
+ * @param {number[]} data - Array of numeric values
35
+ * @param {number} width - Max width of sparkline
36
+ * @returns {string} Sparkline visualization
37
+ */
38
+ function sparkline(data, width = 15) {
39
+ if (!data || data.length === 0) return '─'.repeat(width);
40
+ const chars = '▁▂▃▄▅▆▇█';
41
+ const max = Math.max(...data, 1);
42
+ const recent = data.slice(-width);
43
+ return recent.map(v => {
44
+ const normalized = Math.max(0, Math.min(1, v / max));
45
+ return chars[Math.floor(normalized * (chars.length - 1))];
46
+ }).join('');
47
+ }
48
+
49
+ /**
50
+ * Get color based on percentage threshold
51
+ * @param {number} percent - Percentage value
52
+ * @returns {string} Color name
53
+ */
54
+ function getColor(percent) {
55
+ if (percent >= 80) return C.red;
56
+ if (percent >= 60) return C.yellow;
57
+ return C.green;
58
+ }
59
+
60
+ /**
61
+ * Format bytes to human-readable string
62
+ * @param {number} bytes - Number of bytes
63
+ * @returns {string} Formatted string (e.g., "1.5 GB")
64
+ */
65
+ function formatBytes(bytes) {
66
+ if (bytes === 0) return '0 B';
67
+ const k = 1024;
68
+ const sizes = ['B', 'KB', 'MB', 'GB'];
69
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
70
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
71
+ }
72
+
73
+ /**
74
+ * Format bytes per second to bits per second
75
+ * @param {number} bytesPerSec - Bytes per second
76
+ * @returns {string} Formatted string (e.g., "1.5M")
77
+ */
78
+ function formatBitsPerSecond(bytesPerSec) {
79
+ const bitsPerSec = bytesPerSec * 8;
80
+ if (bitsPerSec === 0) return '0';
81
+ if (bitsPerSec < 1000) return Math.round(bitsPerSec) + 'b';
82
+ if (bitsPerSec < 1000000) return (bitsPerSec / 1000).toFixed(0) + 'K';
83
+ return (bitsPerSec / 1000000).toFixed(1) + 'M';
84
+ }
85
+
86
+ /**
87
+ * Format duration in seconds to human-readable string
88
+ * @param {number} seconds - Duration in seconds
89
+ * @returns {string} Formatted duration (e.g., "2d 3h")
90
+ */
91
+ function formatDuration(seconds) {
92
+ if (!seconds || seconds < 0) return '--';
93
+ const days = Math.floor(seconds / 86400);
94
+ const hours = Math.floor((seconds % 86400) / 3600);
95
+ const mins = Math.floor((seconds % 3600) / 60);
96
+ if (days > 0) return `${days}d ${hours}h`;
97
+ if (hours > 0) return `${hours}h ${mins}m`;
98
+ return `${mins}m`;
99
+ }
100
+
101
+ /**
102
+ * Calculate tokens per second between two sessions
103
+ * @param {object} session - Current session
104
+ * @param {object} prevSession - Previous session
105
+ * @param {number} elapsedMs - Elapsed time in milliseconds
106
+ * @returns {number|null} Tokens per second or null
107
+ */
108
+ function calcTPS(session, prevSession, elapsedMs) {
109
+ if (!session || !prevSession || elapsedMs < 100) return null;
110
+ const currTokens = session.totalTokens || 0;
111
+ const prevTokens = prevSession.totalTokens || 0;
112
+ const diff = currTokens - prevTokens;
113
+ if (diff <= 0) return null;
114
+ const tps = diff / (elapsedMs / 1000);
115
+ return tps > 0 ? parseFloat(tps.toFixed(1)) : null;
116
+ }
117
+
118
+ /**
119
+ * Validate file path for security
120
+ * @param {string} filePath - Path to validate
121
+ * @param {string[]} allowedDirs - Additional allowed directories
122
+ * @returns {object} Validation result
123
+ */
124
+ function validateFilePath(filePath, allowedDirs = []) {
125
+ try {
126
+ if (!filePath || typeof filePath !== 'string') {
127
+ return { valid: false, resolvedPath: filePath, error: "Invalid file path" };
128
+ }
129
+
130
+ const normalizedPath = filePath.startsWith('~')
131
+ ? join(os.homedir(), filePath.slice(1))
132
+ : filePath;
133
+
134
+ const resolvedPath = resolve(normalizedPath);
135
+
136
+ if (filePath.includes("..")) {
137
+ return { valid: false, resolvedPath, error: "Path traversal not allowed" };
138
+ }
139
+
140
+ const homeDir = os.homedir();
141
+ const defaultAllowedDirs = [
142
+ homeDir,
143
+ homeDir + "/.openclaw",
144
+ homeDir + "/.openclaw/agents",
145
+ "/tmp"
146
+ ];
147
+
148
+ const allAllowedDirs = [...defaultAllowedDirs, ...allowedDirs];
149
+
150
+ const isAllowed = allAllowedDirs.some(allowedDir => {
151
+ const resolvedAllowed = resolve(allowedDir);
152
+ return resolvedPath.startsWith(resolvedAllowed + "/") || resolvedPath === resolvedAllowed;
153
+ });
154
+
155
+ if (!isAllowed) {
156
+ return { valid: false, resolvedPath, error: "Path not in allowed directories" };
157
+ }
158
+
159
+ return { valid: true, resolvedPath };
160
+ } catch (err) {
161
+ return { valid: false, resolvedPath: filePath, error: err.message };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Colorize log line based on log level
167
+ * @param {string} line - Log line to colorize
168
+ * @returns {string} Colorized log line
169
+ */
170
+ function colorizeLogLine(line) {
171
+ if (!line || typeof line !== 'string') return line;
172
+
173
+ let matchedLevel = null;
174
+ let levelStart = -1;
175
+ let levelEnd = -1;
176
+
177
+ for (const level of ['error', 'warn', 'info', 'debug']) {
178
+ const escapedLevel = level.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
179
+ const pattern = new RegExp(`\\[${escapedLevel.toUpperCase()}\\]`, 'i');
180
+ const match = line.match(pattern);
181
+ if (match) {
182
+ matchedLevel = level;
183
+ levelStart = match.index;
184
+ levelEnd = levelStart + match[0].length;
185
+ break;
186
+ }
187
+ }
188
+
189
+ if (!matchedLevel) {
190
+ for (const level of ['error', 'warn', 'info', 'debug']) {
191
+ const escapedLevel = level.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
192
+ const pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}[T ].*?${escapedLevel}:`, 'i');
193
+ const match = line.match(pattern);
194
+ if (match) {
195
+ matchedLevel = level;
196
+ levelStart = match.index;
197
+ const afterMatch = line.slice(match.index + match[0].length);
198
+ const spaceMatch = afterMatch.match(/^(\s*)/);
199
+ levelEnd = match.index + match[0].length + (spaceMatch ? spaceMatch[1].length : 0);
200
+ break;
201
+ }
202
+ }
203
+ }
204
+
205
+ if (!matchedLevel) return line;
206
+
207
+ const LOG_COLORS = {
208
+ error: C.brightRed,
209
+ warn: C.brightYellow,
210
+ info: C.cyan,
211
+ debug: C.gray
212
+ };
213
+
214
+ const color = LOG_COLORS[matchedLevel] || C.white;
215
+ const colorTag = `{${color}-fg}`;
216
+ const resetTag = '{/}';
217
+
218
+ return line.slice(0, levelStart) + colorTag + line.slice(levelStart, levelEnd) + resetTag + line.slice(levelEnd);
219
+ }
220
+
221
+ /**
222
+ * Convert camelCase to dash-case for tag colors
223
+ * @param {string} color - Color name in camelCase
224
+ * @returns {string} Color name in dash-case
225
+ */
226
+ function toTagColor(color) {
227
+ return color.replace(/([A-Z])/g, '-$1').toLowerCase();
228
+ }
229
+
230
+ export {
231
+ C,
232
+ gauge,
233
+ sparkline,
234
+ getColor,
235
+ formatBytes,
236
+ formatBitsPerSecond,
237
+ formatDuration,
238
+ calcTPS,
239
+ validateFilePath,
240
+ colorizeLogLine,
241
+ toTagColor
242
+ };
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Unit tests for utility functions
3
+ */
4
+
5
+ import {
6
+ gauge,
7
+ sparkline,
8
+ getColor,
9
+ formatBytes,
10
+ formatBitsPerSecond,
11
+ formatDuration,
12
+ calcTPS,
13
+ validateFilePath,
14
+ colorizeLogLine,
15
+ toTagColor
16
+ } from './utils.js';
17
+
18
+ describe('gauge', () => {
19
+ test('returns empty gauge for 0%', () => {
20
+ const result = gauge(0, 10);
21
+ expect(result).toBe('░'.repeat(10));
22
+ });
23
+
24
+ test('returns full gauge for 100%', () => {
25
+ const result = gauge(100, 10);
26
+ expect(result).toBe('█'.repeat(10));
27
+ });
28
+
29
+ test('returns half gauge for 50%', () => {
30
+ const result = gauge(50, 10);
31
+ expect(result).toBe('█'.repeat(5) + '░'.repeat(5));
32
+ });
33
+
34
+ test('handles decimal percentages correctly', () => {
35
+ const result = gauge(33, 10);
36
+ // 33% of 10 = 3.3 rounded = 3 filled
37
+ expect(result.startsWith('███')).toBe(true);
38
+ });
39
+
40
+ test('defaults to width 15', () => {
41
+ const result = gauge(50);
42
+ expect(result.length).toBe(15);
43
+ });
44
+
45
+ test('handles boundary values', () => {
46
+ expect(gauge(1, 10).length).toBe(10);
47
+ expect(gauge(99, 10).length).toBe(10);
48
+ });
49
+ });
50
+
51
+ describe('sparkline', () => {
52
+ test('returns dashes for empty array', () => {
53
+ const result = sparkline([]);
54
+ expect(result).toBe('─'.repeat(15));
55
+ });
56
+
57
+ test('returns dashes for null/undefined', () => {
58
+ expect(sparkline(null)).toBe('─'.repeat(15));
59
+ expect(sparkline(undefined)).toBe('─'.repeat(15));
60
+ });
61
+
62
+ test('returns single character for single value', () => {
63
+ const result = sparkline([100], 10);
64
+ expect(result.length).toBe(1);
65
+ expect(result).toBe('█');
66
+ });
67
+
68
+ test('handles increasing values', () => {
69
+ const data = [10, 20, 30, 40, 50];
70
+ const result = sparkline(data, 5);
71
+ expect(result.length).toBe(5);
72
+ });
73
+
74
+ test('handles decreasing values', () => {
75
+ const data = [50, 40, 30, 20, 10];
76
+ const result = sparkline(data, 5);
77
+ expect(result.length).toBe(5);
78
+ });
79
+
80
+ test('uses only recent data when array is larger than width', () => {
81
+ const data = Array.from({ length: 30 }, (_, i) => i * 10);
82
+ const result = sparkline(data, 10);
83
+ expect(result.length).toBe(10);
84
+ });
85
+
86
+ test('handles all same values', () => {
87
+ const data = [50, 50, 50, 50, 50];
88
+ const result = sparkline(data, 5);
89
+ // Should use middle character for 50% value
90
+ expect(result.length).toBe(5);
91
+ });
92
+ });
93
+
94
+ describe('getColor', () => {
95
+ test('returns green for low percentages', () => {
96
+ expect(getColor(0)).toBe('green');
97
+ expect(getColor(30)).toBe('green');
98
+ expect(getColor(59)).toBe('green');
99
+ });
100
+
101
+ test('returns yellow for medium percentages', () => {
102
+ expect(getColor(60)).toBe('yellow');
103
+ expect(getColor(70)).toBe('yellow');
104
+ expect(getColor(79)).toBe('yellow');
105
+ });
106
+
107
+ test('returns red for high percentages', () => {
108
+ expect(getColor(80)).toBe('red');
109
+ expect(getColor(90)).toBe('red');
110
+ expect(getColor(100)).toBe('red');
111
+ });
112
+ });
113
+
114
+ describe('formatBytes', () => {
115
+ test('formats 0 bytes', () => {
116
+ expect(formatBytes(0)).toBe('0 B');
117
+ });
118
+
119
+ test('formats bytes correctly', () => {
120
+ expect(formatBytes(512)).toBe('512 B');
121
+ expect(formatBytes(1023)).toBe('1023 B');
122
+ });
123
+
124
+ test('formats kilobytes correctly', () => {
125
+ expect(formatBytes(1024)).toBe('1 KB');
126
+ expect(formatBytes(1536)).toBe('1.5 KB');
127
+ expect(formatBytes(10240)).toBe('10 KB');
128
+ });
129
+
130
+ test('formats megabytes correctly', () => {
131
+ expect(formatBytes(1048576)).toBe('1 MB');
132
+ expect(formatBytes(1572864)).toBe('1.5 MB');
133
+ });
134
+
135
+ test('formats gigabytes correctly', () => {
136
+ expect(formatBytes(1073741824)).toBe('1 GB');
137
+ });
138
+
139
+ test('handles large values', () => {
140
+ const result = formatBytes(5000000000);
141
+ expect(result).toContain('GB');
142
+ });
143
+ });
144
+
145
+ describe('formatBitsPerSecond', () => {
146
+ test('formats 0 bps', () => {
147
+ expect(formatBitsPerSecond(0)).toBe('0');
148
+ });
149
+
150
+ test('formats bits correctly', () => {
151
+ expect(formatBitsPerSecond(1)).toBe('8b');
152
+ expect(formatBitsPerSecond(100)).toBe('800b');
153
+ expect(formatBitsPerSecond(125)).toBe('1K');
154
+ });
155
+
156
+ test('formats kilobits correctly', () => {
157
+ expect(formatBitsPerSecond(125)).toBe('1K');
158
+ expect(formatBitsPerSecond(1250)).toBe('10K');
159
+ });
160
+
161
+ test('formats megabits correctly', () => {
162
+ expect(formatBitsPerSecond(125000)).toBe('1.0M');
163
+ expect(formatBitsPerSecond(1250000)).toBe('10.0M');
164
+ });
165
+ });
166
+
167
+ describe('formatDuration', () => {
168
+ test('returns -- for null/undefined', () => {
169
+ expect(formatDuration(null)).toBe('--');
170
+ expect(formatDuration(undefined)).toBe('--');
171
+ });
172
+
173
+ test('returns -- for negative values', () => {
174
+ expect(formatDuration(-1)).toBe('--');
175
+ });
176
+
177
+ test('formats minutes correctly', () => {
178
+ expect(formatDuration(60)).toBe('1m');
179
+ expect(formatDuration(300)).toBe('5m');
180
+ expect(formatDuration(3599)).toBe('59m');
181
+ });
182
+
183
+ test('formats hours correctly', () => {
184
+ expect(formatDuration(3600)).toBe('1h 0m');
185
+ expect(formatDuration(7200)).toBe('2h 0m');
186
+ expect(formatDuration(3660)).toBe('1h 1m');
187
+ });
188
+
189
+ test('formats days correctly', () => {
190
+ expect(formatDuration(86400)).toBe('1d 0h');
191
+ expect(formatDuration(90000)).toBe('1d 1h');
192
+ expect(formatDuration(172800)).toBe('2d 0h');
193
+ });
194
+ });
195
+
196
+ describe('calcTPS', () => {
197
+ test('returns null for missing sessions', () => {
198
+ expect(calcTPS(null, {}, 1000)).toBeNull();
199
+ expect(calcTPS({}, null, 1000)).toBeNull();
200
+ expect(calcTPS({}, {}, 1000)).toBeNull();
201
+ });
202
+
203
+ test('returns null for elapsed time less than 100ms', () => {
204
+ const session = { totalTokens: 100 };
205
+ const prevSession = { totalTokens: 0 };
206
+ expect(calcTPS(session, prevSession, 50)).toBeNull();
207
+ expect(calcTPS(session, prevSession, 99)).toBeNull();
208
+ });
209
+
210
+ test('returns null when tokens decreased', () => {
211
+ const session = { totalTokens: 50 };
212
+ const prevSession = { totalTokens: 100 };
213
+ expect(calcTPS(session, prevSession, 1000)).toBeNull();
214
+ });
215
+
216
+ test('returns null when tokens are equal', () => {
217
+ const session = { totalTokens: 100 };
218
+ const prevSession = { totalTokens: 100 };
219
+ expect(calcTPS(session, prevSession, 1000)).toBeNull();
220
+ });
221
+
222
+ test('calculates TPS correctly', () => {
223
+ const session = { totalTokens: 1000 };
224
+ const prevSession = { totalTokens: 0 };
225
+ // 1000 tokens in 1 second = 1000 TPS
226
+ const result = calcTPS(session, prevSession, 1000);
227
+ expect(result).toBe(1000);
228
+ });
229
+
230
+ test('handles missing totalTokens property (defaults to 0)', () => {
231
+ const session = {};
232
+ const prevSession = {};
233
+ expect(calcTPS(session, prevSession, 1000)).toBeNull(); // 0 - 0 = 0, so null
234
+ });
235
+ });
236
+
237
+ describe('validateFilePath', () => {
238
+ // Note: validateFilePath now uses ESM imports instead of require()
239
+ // These tests verify basic behavior
240
+
241
+ test('rejects null/undefined paths', () => {
242
+ expect(validateFilePath(null).valid).toBe(false);
243
+ expect(validateFilePath(undefined).valid).toBe(false);
244
+ expect(validateFilePath('').valid).toBe(false);
245
+ });
246
+
247
+ test('rejects path traversal attempts', () => {
248
+ // This works because it checks for ".." in the path
249
+ const result = validateFilePath('/home/user/../../../etc/passwd');
250
+ expect(result.valid).toBe(false);
251
+ });
252
+
253
+ test('rejects paths outside allowed directories', () => {
254
+ const result = validateFilePath('/usr/bin/test');
255
+ expect(result.valid).toBe(false);
256
+ });
257
+ });
258
+
259
+ describe('colorizeLogLine', () => {
260
+ test('returns unchanged for null/undefined', () => {
261
+ expect(colorizeLogLine(null)).toBeNull();
262
+ expect(colorizeLogLine(undefined)).toBeUndefined();
263
+ });
264
+
265
+ test('returns unchanged for non-string', () => {
266
+ expect(colorizeLogLine(123)).toBe(123);
267
+ });
268
+
269
+ test('colorizes error lines with brackets', () => {
270
+ const line = '[ERROR] Something went wrong';
271
+ const result = colorizeLogLine(line);
272
+ expect(result).toContain('{bright-red-fg}');
273
+ expect(result).toContain('{/}');
274
+ });
275
+
276
+ test('colorizes warn lines with brackets', () => {
277
+ const line = '[WARN] Warning message';
278
+ const result = colorizeLogLine(line);
279
+ expect(result).toContain('{bright-yellow-fg}');
280
+ });
281
+
282
+ test('colorizes info lines with brackets', () => {
283
+ const line = '[INFO] Info message';
284
+ const result = colorizeLogLine(line);
285
+ expect(result).toContain('{cyan-fg}');
286
+ });
287
+
288
+ test('colorizes debug lines with brackets', () => {
289
+ const line = '[DEBUG] Debug message';
290
+ const result = colorizeLogLine(line);
291
+ expect(result).toContain('{gray-fg}');
292
+ });
293
+
294
+ test('handles lowercase brackets', () => {
295
+ const line = '[error] Lowercase error';
296
+ const result = colorizeLogLine(line);
297
+ expect(result).toContain('{bright-red-fg}');
298
+ });
299
+
300
+ test('returns unchanged for lines without log levels', () => {
301
+ const line = 'Just a regular message';
302
+ const result = colorizeLogLine(line);
303
+ expect(result).toBe(line);
304
+ });
305
+ });
306
+
307
+ describe('toTagColor', () => {
308
+ test('converts camelCase to dash-case', () => {
309
+ expect(toTagColor('brightRed')).toBe('bright-red');
310
+ expect(toTagColor('brightGreen')).toBe('bright-green');
311
+ expect(toTagColor('brightYellow')).toBe('bright-yellow');
312
+ });
313
+
314
+ test('handles single words', () => {
315
+ expect(toTagColor('red')).toBe('red');
316
+ });
317
+ });