@mingxy/cerebro 1.20.5 → 1.20.6
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.
- package/package.json +7 -3
- package/src/client.test.ts +373 -0
- package/src/config.test.ts +405 -0
- package/src/hooks-tier1.test.ts +220 -0
- package/src/hooks-tier2.test.ts +275 -0
- package/src/hooks-tier3.test.ts +461 -0
- package/src/hooks.ts +48 -12
- package/src/index.test.ts +190 -0
- package/src/index.ts +12 -2
- package/src/keywords.test.ts +283 -0
- package/src/logger.test.ts +640 -0
- package/src/privacy.test.ts +128 -0
- package/src/tags.test.ts +86 -0
- package/src/tools.test.ts +508 -0
- package/src/tools.ts +34 -0
- package/src/updater.test.ts +380 -0
- package/src/web-server.test.ts +740 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Mocks (hoisted so they exist before module import) ──────────────────
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
mockAppendFileSync,
|
|
7
|
+
mockMkdirSync,
|
|
8
|
+
mockExistsSync,
|
|
9
|
+
mockStatSync,
|
|
10
|
+
mockRenameSync,
|
|
11
|
+
mockReaddirSync,
|
|
12
|
+
mockUnlinkSync,
|
|
13
|
+
mockLoadPluginConfig,
|
|
14
|
+
importTimeUnlinkCalls,
|
|
15
|
+
importTimeStatCalls,
|
|
16
|
+
} = vi.hoisted(() => {
|
|
17
|
+
// Capture calls that happen during module import (cleanupOldLogs)
|
|
18
|
+
const importTimeUnlinkCalls: string[] = [];
|
|
19
|
+
const importTimeStatCalls: string[] = [];
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
mockAppendFileSync: vi.fn(),
|
|
23
|
+
mockMkdirSync: vi.fn(),
|
|
24
|
+
mockExistsSync: vi.fn<() => boolean>().mockReturnValue(true),
|
|
25
|
+
mockStatSync: vi.fn().mockImplementation(() => {
|
|
26
|
+
throw new Error("ENOENT");
|
|
27
|
+
}),
|
|
28
|
+
mockRenameSync: vi.fn(),
|
|
29
|
+
mockReaddirSync: vi.fn<() => string[]>().mockReturnValue([]),
|
|
30
|
+
mockUnlinkSync: vi.fn((p: string) => {
|
|
31
|
+
importTimeUnlinkCalls.push(p);
|
|
32
|
+
}),
|
|
33
|
+
mockLoadPluginConfig: vi.fn().mockReturnValue({
|
|
34
|
+
logging: {
|
|
35
|
+
logEnabled: true,
|
|
36
|
+
logLevel: "INFO",
|
|
37
|
+
logDir: "/tmp/test-cerebro-logs",
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
importTimeUnlinkCalls,
|
|
41
|
+
importTimeStatCalls,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
vi.mock("node:fs", () => ({
|
|
46
|
+
appendFileSync: mockAppendFileSync,
|
|
47
|
+
mkdirSync: mockMkdirSync,
|
|
48
|
+
existsSync: mockExistsSync,
|
|
49
|
+
statSync: mockStatSync,
|
|
50
|
+
renameSync: mockRenameSync,
|
|
51
|
+
readdirSync: mockReaddirSync,
|
|
52
|
+
unlinkSync: mockUnlinkSync,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock("./config.js", () => ({
|
|
56
|
+
loadPluginConfig: mockLoadPluginConfig,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const {
|
|
60
|
+
logInfo,
|
|
61
|
+
logWarn,
|
|
62
|
+
logError,
|
|
63
|
+
logDebug,
|
|
64
|
+
setOpencodeClient,
|
|
65
|
+
} = await import("./logger.js");
|
|
66
|
+
|
|
67
|
+
function lastFileWrite(): string {
|
|
68
|
+
const calls = mockAppendFileSync.mock.calls;
|
|
69
|
+
if (calls.length === 0) return "";
|
|
70
|
+
return calls[calls.length - 1][1] as string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("logger", () => {
|
|
74
|
+
// Cumulative offset ensures each test's fake time is far enough ahead
|
|
75
|
+
// to expire the previous test's TTL cache (30s). 120s margin is safe.
|
|
76
|
+
let timeOffset = 0;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
timeOffset += 120_000;
|
|
80
|
+
vi.useFakeTimers({ now: new Date(Date.now() + timeOffset) });
|
|
81
|
+
|
|
82
|
+
mockAppendFileSync.mockReset();
|
|
83
|
+
mockMkdirSync.mockReset();
|
|
84
|
+
mockExistsSync.mockReset().mockReturnValue(true);
|
|
85
|
+
mockStatSync.mockReset().mockImplementation(() => {
|
|
86
|
+
throw new Error("ENOENT");
|
|
87
|
+
});
|
|
88
|
+
mockRenameSync.mockReset();
|
|
89
|
+
mockReaddirSync.mockReset().mockReturnValue([]);
|
|
90
|
+
mockUnlinkSync.mockReset();
|
|
91
|
+
mockLoadPluginConfig.mockReset().mockReturnValue({
|
|
92
|
+
logging: {
|
|
93
|
+
logEnabled: true,
|
|
94
|
+
logLevel: "INFO",
|
|
95
|
+
logDir: "/tmp/test-cerebro-logs",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Warmup: first log call triggers config refresh (previous cache expired
|
|
100
|
+
// since we jumped 120s ahead) and resets lastLogTime for delta tests.
|
|
101
|
+
logInfo("warmup");
|
|
102
|
+
|
|
103
|
+
// Reset write-related mocks for clean test state
|
|
104
|
+
mockAppendFileSync.mockReset();
|
|
105
|
+
mockMkdirSync.mockReset();
|
|
106
|
+
mockExistsSync.mockReset().mockReturnValue(true);
|
|
107
|
+
mockStatSync.mockReset().mockImplementation(() => {
|
|
108
|
+
throw new Error("ENOENT");
|
|
109
|
+
});
|
|
110
|
+
mockRenameSync.mockReset();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
setOpencodeClient(null);
|
|
115
|
+
vi.useRealTimers();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── Level filtering ────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("level filtering", () => {
|
|
121
|
+
it("writes INFO messages at INFO level", () => {
|
|
122
|
+
logInfo("hello");
|
|
123
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
124
|
+
expect(lastFileWrite()).toContain("INFO");
|
|
125
|
+
expect(lastFileWrite()).toContain("hello");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("writes WARN messages at INFO level", () => {
|
|
129
|
+
logWarn("warning msg");
|
|
130
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
131
|
+
expect(lastFileWrite()).toContain("WARN");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("writes ERROR messages at INFO level", () => {
|
|
135
|
+
logError("error msg");
|
|
136
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
137
|
+
expect(lastFileWrite()).toContain("ERROR");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("suppresses DEBUG messages at INFO level", () => {
|
|
141
|
+
logDebug("debug msg");
|
|
142
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("writes DEBUG messages at DEBUG level", () => {
|
|
146
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
147
|
+
logging: { logEnabled: true, logLevel: "DEBUG", logDir: "/tmp/test-logs" },
|
|
148
|
+
});
|
|
149
|
+
vi.advanceTimersByTime(31_000);
|
|
150
|
+
logDebug("debug msg");
|
|
151
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
152
|
+
expect(lastFileWrite()).toContain("DEBUG");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("suppresses INFO messages at WARN level", () => {
|
|
156
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
157
|
+
logging: { logEnabled: true, logLevel: "WARN", logDir: "/tmp/test-logs" },
|
|
158
|
+
});
|
|
159
|
+
vi.advanceTimersByTime(31_000);
|
|
160
|
+
logInfo("should be suppressed");
|
|
161
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("suppresses INFO and DEBUG at ERROR level", () => {
|
|
165
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
166
|
+
logging: { logEnabled: true, logLevel: "ERROR", logDir: "/tmp/test-logs" },
|
|
167
|
+
});
|
|
168
|
+
vi.advanceTimersByTime(31_000);
|
|
169
|
+
|
|
170
|
+
logDebug("debug suppressed");
|
|
171
|
+
logInfo("info suppressed");
|
|
172
|
+
logWarn("warn suppressed");
|
|
173
|
+
|
|
174
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
175
|
+
|
|
176
|
+
logError("error passes");
|
|
177
|
+
expect(mockAppendFileSync).toHaveBeenCalledTimes(1);
|
|
178
|
+
expect(lastFileWrite()).toContain("ERROR");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("writes all levels at DEBUG level", () => {
|
|
182
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
183
|
+
logging: { logEnabled: true, logLevel: "DEBUG", logDir: "/tmp/test-logs" },
|
|
184
|
+
});
|
|
185
|
+
vi.advanceTimersByTime(31_000);
|
|
186
|
+
|
|
187
|
+
logDebug("d");
|
|
188
|
+
logInfo("i");
|
|
189
|
+
logWarn("w");
|
|
190
|
+
logError("e");
|
|
191
|
+
|
|
192
|
+
expect(mockAppendFileSync).toHaveBeenCalledTimes(4);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("defaults to INFO for unknown log level string", () => {
|
|
196
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
197
|
+
logging: { logEnabled: true, logLevel: "VERBOSE" as "DEBUG" | "INFO" | "WARN" | "ERROR", logDir: "/tmp/test-logs" },
|
|
198
|
+
});
|
|
199
|
+
vi.advanceTimersByTime(31_000);
|
|
200
|
+
|
|
201
|
+
logInfo("passes at default INFO");
|
|
202
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
203
|
+
|
|
204
|
+
mockAppendFileSync.mockClear();
|
|
205
|
+
logDebug("suppressed at default INFO");
|
|
206
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Log disabled ──────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("log disabled", () => {
|
|
213
|
+
it("does not write when logEnabled=false", () => {
|
|
214
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
215
|
+
logging: { logEnabled: false, logLevel: "INFO", logDir: "/tmp/test-logs" },
|
|
216
|
+
});
|
|
217
|
+
vi.advanceTimersByTime(31_000);
|
|
218
|
+
logError("should not appear");
|
|
219
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Log format ────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe("log format", () => {
|
|
226
|
+
it("includes level, timestamp, delta, service=cerebro, and message", () => {
|
|
227
|
+
logInfo("test message");
|
|
228
|
+
const line = lastFileWrite();
|
|
229
|
+
expect(line).toMatch(/^INFO /);
|
|
230
|
+
expect(line).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
|
231
|
+
expect(line).toMatch(/\+[\d.]+s/);
|
|
232
|
+
expect(line).toContain("service=cerebro");
|
|
233
|
+
expect(line).toContain("test message");
|
|
234
|
+
expect(line).toMatch(/\n$/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("includes field key=value pairs", () => {
|
|
238
|
+
logInfo("msg", { key1: "val1", count: 42 });
|
|
239
|
+
const line = lastFileWrite();
|
|
240
|
+
expect(line).toContain("key1=val1");
|
|
241
|
+
expect(line).toContain("count=42");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("JSON-stringifies non-string field values", () => {
|
|
245
|
+
logInfo("msg", { obj: { a: 1 } });
|
|
246
|
+
const line = lastFileWrite();
|
|
247
|
+
expect(line).toContain('obj={"a":1}');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("pads level to 5 characters", () => {
|
|
251
|
+
logInfo("pad test");
|
|
252
|
+
expect(lastFileWrite()).toMatch(/^INFO /);
|
|
253
|
+
|
|
254
|
+
logWarn("warn pad");
|
|
255
|
+
expect(lastFileWrite()).toMatch(/^WARN /);
|
|
256
|
+
|
|
257
|
+
logError("error pad");
|
|
258
|
+
expect(lastFileWrite()).toMatch(/^ERROR /);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Log file path ─────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
describe("log file path", () => {
|
|
265
|
+
it("uses cerebro.log by default", () => {
|
|
266
|
+
logInfo("default path");
|
|
267
|
+
const filePath = mockAppendFileSync.mock.calls[0][0] as string;
|
|
268
|
+
expect(filePath).toMatch(/cerebro\.log$/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("uses cerebro-{sessionId}.log when sessionId is provided in fields", () => {
|
|
272
|
+
logInfo("session log", { sessionId: "abc-123" });
|
|
273
|
+
const filePath = mockAppendFileSync.mock.calls[0][0] as string;
|
|
274
|
+
expect(filePath).toMatch(/cerebro-abc-123\.log$/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("recognizes sessionID (uppercase D) field", () => {
|
|
278
|
+
logInfo("alt session field", { sessionID: "xyz-789" });
|
|
279
|
+
const filePath = mockAppendFileSync.mock.calls[0][0] as string;
|
|
280
|
+
expect(filePath).toMatch(/cerebro-xyz-789\.log$/);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── Log rotation ──────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe("log rotation", () => {
|
|
287
|
+
it("renames file when size > 5MB", () => {
|
|
288
|
+
mockStatSync.mockReturnValue({ size: 6 * 1024 * 1024 });
|
|
289
|
+
logInfo("trigger rotation");
|
|
290
|
+
expect(mockRenameSync).toHaveBeenCalled();
|
|
291
|
+
const [oldPath, newPath] = mockRenameSync.mock.calls[0];
|
|
292
|
+
expect(oldPath).toMatch(/cerebro\.log$/);
|
|
293
|
+
expect(newPath).toMatch(/cerebro\.old\.log$/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("does not rename when file is under 5MB", () => {
|
|
297
|
+
mockStatSync.mockReturnValue({ size: 4 * 1024 * 1024 });
|
|
298
|
+
logInfo("no rotation");
|
|
299
|
+
expect(mockRenameSync).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("handles statSync throwing (file not found) gracefully", () => {
|
|
303
|
+
mockStatSync.mockImplementation(() => {
|
|
304
|
+
throw new Error("ENOENT");
|
|
305
|
+
});
|
|
306
|
+
expect(() => logInfo("first write")).not.toThrow();
|
|
307
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("rotation happens before file write", () => {
|
|
311
|
+
mockStatSync.mockReturnValue({ size: 6 * 1024 * 1024 });
|
|
312
|
+
logInfo("rotation order test");
|
|
313
|
+
expect(mockRenameSync).toHaveBeenCalled();
|
|
314
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ── Directory creation ────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
describe("directory creation", () => {
|
|
321
|
+
it("creates log directory if it does not exist", () => {
|
|
322
|
+
mockExistsSync.mockReturnValue(false);
|
|
323
|
+
logInfo("ensure dir");
|
|
324
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
325
|
+
expect.any(String),
|
|
326
|
+
{ recursive: true },
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("skips mkdir when directory exists", () => {
|
|
331
|
+
mockExistsSync.mockReturnValue(true);
|
|
332
|
+
logInfo("dir exists");
|
|
333
|
+
expect(mockMkdirSync).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── Dual-track: opencodeClient ────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
describe("dual-track logging (opencodeClient)", () => {
|
|
340
|
+
it("calls opencodeClient.app.log() when client is set", () => {
|
|
341
|
+
const mockLog = vi.fn(() => ({ catch: vi.fn() }));
|
|
342
|
+
const client = { app: { log: mockLog } };
|
|
343
|
+
setOpencodeClient(client);
|
|
344
|
+
|
|
345
|
+
logInfo("dual track");
|
|
346
|
+
|
|
347
|
+
expect(mockLog).toHaveBeenCalledWith({
|
|
348
|
+
body: {
|
|
349
|
+
service: "cerebro",
|
|
350
|
+
level: "info",
|
|
351
|
+
message: "dual track",
|
|
352
|
+
extra: undefined,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("passes fields as extra to opencodeClient", () => {
|
|
358
|
+
const mockLog = vi.fn(() => ({ catch: vi.fn() }));
|
|
359
|
+
const client = { app: { log: mockLog } };
|
|
360
|
+
setOpencodeClient(client);
|
|
361
|
+
|
|
362
|
+
const fields = { foo: "bar" };
|
|
363
|
+
logWarn("with fields", fields);
|
|
364
|
+
|
|
365
|
+
expect(mockLog).toHaveBeenCalledWith({
|
|
366
|
+
body: {
|
|
367
|
+
service: "cerebro",
|
|
368
|
+
level: "warn",
|
|
369
|
+
message: "with fields",
|
|
370
|
+
extra: fields,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("uses lowercase level for opencodeClient", () => {
|
|
376
|
+
const mockLog = vi.fn(() => ({ catch: vi.fn() }));
|
|
377
|
+
const client = { app: { log: mockLog } };
|
|
378
|
+
setOpencodeClient(client);
|
|
379
|
+
|
|
380
|
+
logError("error test");
|
|
381
|
+
const call = mockLog.mock.calls[0][0];
|
|
382
|
+
expect(call.body.level).toBe("error");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("does not crash when opencodeClient throws", () => {
|
|
386
|
+
const badClient = {
|
|
387
|
+
app: {
|
|
388
|
+
log: vi.fn(() => {
|
|
389
|
+
throw new Error("client broken");
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
setOpencodeClient(badClient);
|
|
394
|
+
|
|
395
|
+
expect(() => logInfo("safe")).not.toThrow();
|
|
396
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("does not crash when opencodeClient is null", () => {
|
|
400
|
+
setOpencodeClient(null);
|
|
401
|
+
expect(() => logInfo("no client")).not.toThrow();
|
|
402
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ── setOpencodeClient ─────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
describe("setOpencodeClient", () => {
|
|
409
|
+
it("stores the client reference for subsequent log calls", () => {
|
|
410
|
+
const mockLog = vi.fn(() => ({ catch: vi.fn() }));
|
|
411
|
+
const client = { app: { log: mockLog } };
|
|
412
|
+
setOpencodeClient(client);
|
|
413
|
+
|
|
414
|
+
logInfo("verify stored");
|
|
415
|
+
expect(mockLog).toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("can be replaced with a different client", () => {
|
|
419
|
+
const mockLog1 = vi.fn(() => ({ catch: vi.fn() }));
|
|
420
|
+
const client1 = { app: { log: mockLog1 } };
|
|
421
|
+
setOpencodeClient(client1);
|
|
422
|
+
logInfo("first client");
|
|
423
|
+
|
|
424
|
+
const mockLog2 = vi.fn(() => ({ catch: vi.fn() }));
|
|
425
|
+
const client2 = { app: { log: mockLog2 } };
|
|
426
|
+
setOpencodeClient(client2);
|
|
427
|
+
logInfo("second client");
|
|
428
|
+
|
|
429
|
+
expect(mockLog1).toHaveBeenCalledTimes(1);
|
|
430
|
+
expect(mockLog2).toHaveBeenCalledTimes(1);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ── opencodeClient .catch() rejection prevention ──────────────────
|
|
435
|
+
|
|
436
|
+
describe("opencodeClient .catch() rejection prevention", () => {
|
|
437
|
+
it("calls .catch() on the returned promise-like object", () => {
|
|
438
|
+
const mockCatch = vi.fn();
|
|
439
|
+
const mockLog = vi.fn(() => ({ catch: mockCatch }));
|
|
440
|
+
const client = { app: { log: mockLog } };
|
|
441
|
+
setOpencodeClient(client);
|
|
442
|
+
|
|
443
|
+
logInfo("verify catch");
|
|
444
|
+
|
|
445
|
+
expect(mockCatch).toHaveBeenCalledTimes(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("handles app.log() returning undefined (no promise)", () => {
|
|
449
|
+
const mockLog = vi.fn(() => undefined);
|
|
450
|
+
const client = { app: { log: mockLog } };
|
|
451
|
+
setOpencodeClient(client);
|
|
452
|
+
|
|
453
|
+
expect(() => logInfo("no promise")).not.toThrow();
|
|
454
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("handles client with no app property", () => {
|
|
458
|
+
setOpencodeClient({});
|
|
459
|
+
|
|
460
|
+
expect(() => logInfo("no app")).not.toThrow();
|
|
461
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("handles client with app.log not being a function", () => {
|
|
465
|
+
setOpencodeClient({ app: { log: "not a function" } });
|
|
466
|
+
|
|
467
|
+
expect(() => logInfo("bad log")).not.toThrow();
|
|
468
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ── TTL config cache (30-second) ────────────────────────────────
|
|
473
|
+
|
|
474
|
+
describe("TTL config cache (30-second)", () => {
|
|
475
|
+
it("caches config within TTL window", () => {
|
|
476
|
+
logInfo("call 1");
|
|
477
|
+
expect(mockLoadPluginConfig).toHaveBeenCalledTimes(1);
|
|
478
|
+
logInfo("call 2");
|
|
479
|
+
expect(mockLoadPluginConfig).toHaveBeenCalledTimes(1);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("refreshes config after TTL expires", () => {
|
|
483
|
+
logInfo("cached call");
|
|
484
|
+
expect(mockLoadPluginConfig).toHaveBeenCalledTimes(1);
|
|
485
|
+
|
|
486
|
+
vi.advanceTimersByTime(31_000);
|
|
487
|
+
|
|
488
|
+
logInfo("refreshed call");
|
|
489
|
+
expect(mockLoadPluginConfig).toHaveBeenCalledTimes(2);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ── Startup cleanup (7-day) ────────────────────────────────────────
|
|
494
|
+
// cleanupOldLogs() runs only once at module load. We test it by
|
|
495
|
+
// re-importing the module with vi.resetModules().
|
|
496
|
+
|
|
497
|
+
describe("startup cleanup (delete old logs)", () => {
|
|
498
|
+
it("deletes .log files older than 7 days on import", async () => {
|
|
499
|
+
const cutoffTime = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
|
500
|
+
|
|
501
|
+
// Set up mocks for re-import
|
|
502
|
+
mockReaddirSync.mockReturnValue(["old.log", "recent.log"]);
|
|
503
|
+
mockStatSync.mockImplementation((fp: string) => {
|
|
504
|
+
if (fp.endsWith("old.log")) return { size: 100, mtimeMs: cutoffTime };
|
|
505
|
+
return { size: 100, mtimeMs: Date.now() };
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
vi.resetModules();
|
|
509
|
+
await import("./logger.js");
|
|
510
|
+
|
|
511
|
+
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
512
|
+
const deletedPaths = mockUnlinkSync.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
513
|
+
expect(deletedPaths.some((p: string) => p.endsWith("old.log"))).toBe(true);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("deletes .old.log files older than 7 days on import", async () => {
|
|
517
|
+
const cutoffTime = Date.now() - 10 * 24 * 60 * 60 * 1000;
|
|
518
|
+
|
|
519
|
+
mockReaddirSync.mockReturnValue(["rotated.old.log"]);
|
|
520
|
+
mockStatSync.mockReturnValue({ size: 100, mtimeMs: cutoffTime });
|
|
521
|
+
|
|
522
|
+
vi.resetModules();
|
|
523
|
+
await import("./logger.js");
|
|
524
|
+
|
|
525
|
+
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
526
|
+
const deletedPaths = mockUnlinkSync.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
527
|
+
expect(deletedPaths.some((p: string) => p.endsWith("rotated.old.log"))).toBe(true);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("keeps files newer than 7 days", async () => {
|
|
531
|
+
mockReaddirSync.mockReturnValue(["fresh.log"]);
|
|
532
|
+
mockStatSync.mockReturnValue({ size: 100, mtimeMs: Date.now() - 1 * 24 * 60 * 60 * 1000 });
|
|
533
|
+
|
|
534
|
+
vi.resetModules();
|
|
535
|
+
await import("./logger.js");
|
|
536
|
+
|
|
537
|
+
expect(mockUnlinkSync).not.toHaveBeenCalled();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("skips non-log files during cleanup", async () => {
|
|
541
|
+
mockReaddirSync.mockReturnValue(["readme.txt", "data.json"]);
|
|
542
|
+
|
|
543
|
+
vi.resetModules();
|
|
544
|
+
await import("./logger.js");
|
|
545
|
+
|
|
546
|
+
// statSync should NOT be called for non-log files
|
|
547
|
+
const statCalls = mockStatSync.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
548
|
+
expect(statCalls.every((p: string) => !p.endsWith(".txt") && !p.endsWith(".json"))).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("handles readdirSync throwing gracefully", async () => {
|
|
552
|
+
mockReaddirSync.mockImplementation(() => {
|
|
553
|
+
throw new Error("ENOENT");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
vi.resetModules();
|
|
557
|
+
// Should not throw — module import succeeds
|
|
558
|
+
await expect(import("./logger.js")).resolves.toBeDefined();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("handles statSync throwing for individual files gracefully", async () => {
|
|
562
|
+
mockReaddirSync.mockReturnValue(["broken.log"]);
|
|
563
|
+
mockStatSync.mockImplementation(() => {
|
|
564
|
+
throw new Error("permission denied");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
vi.resetModules();
|
|
568
|
+
await expect(import("./logger.js")).resolves.toBeDefined();
|
|
569
|
+
// Should NOT delete since we couldn't stat
|
|
570
|
+
expect(mockUnlinkSync).not.toHaveBeenCalled();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ── Delta tracking ───────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
describe("delta tracking", () => {
|
|
577
|
+
it("shows increasing delta between log calls", () => {
|
|
578
|
+
logInfo("first");
|
|
579
|
+
const line1 = lastFileWrite();
|
|
580
|
+
|
|
581
|
+
vi.advanceTimersByTime(1500);
|
|
582
|
+
|
|
583
|
+
logInfo("second");
|
|
584
|
+
const line2 = lastFileWrite();
|
|
585
|
+
|
|
586
|
+
const delta1 = parseFloat(line1.match(/\+([\d.]+)s/)![1]);
|
|
587
|
+
const delta2 = parseFloat(line2.match(/\+([\d.]+)s/)![1]);
|
|
588
|
+
expect(delta2).toBeGreaterThanOrEqual(delta1 + 1.4);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("first log after warmup shows near-zero delta", () => {
|
|
592
|
+
logInfo("first");
|
|
593
|
+
const line = lastFileWrite();
|
|
594
|
+
const delta = parseFloat(line.match(/\+([\d.]+)s/)![1]);
|
|
595
|
+
expect(delta).toBeLessThan(0.1);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ── Edge cases ────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
describe("edge cases", () => {
|
|
602
|
+
it("handles appendFileSync throwing gracefully", () => {
|
|
603
|
+
mockAppendFileSync.mockImplementation(() => {
|
|
604
|
+
throw new Error("disk full");
|
|
605
|
+
});
|
|
606
|
+
expect(() => logInfo("safe")).not.toThrow();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("handles fields being undefined", () => {
|
|
610
|
+
logInfo("no fields", undefined);
|
|
611
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
612
|
+
const line = lastFileWrite();
|
|
613
|
+
expect(line).toContain("no fields");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("handles empty fields object", () => {
|
|
617
|
+
logInfo("empty fields", {});
|
|
618
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("handles mkdirSync throwing gracefully", () => {
|
|
622
|
+
mockExistsSync.mockReturnValue(false);
|
|
623
|
+
mockMkdirSync.mockImplementation(() => {
|
|
624
|
+
throw new Error("permission denied");
|
|
625
|
+
});
|
|
626
|
+
expect(() => logInfo("mkdir fails")).not.toThrow();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("writes to correct logDir from config after TTL expiry", () => {
|
|
630
|
+
mockLoadPluginConfig.mockReturnValue({
|
|
631
|
+
logging: { logEnabled: true, logLevel: "INFO", logDir: "/custom/log/path" },
|
|
632
|
+
});
|
|
633
|
+
vi.advanceTimersByTime(31_000);
|
|
634
|
+
|
|
635
|
+
logInfo("custom dir");
|
|
636
|
+
const filePath = mockAppendFileSync.mock.calls[0][0] as string;
|
|
637
|
+
expect(filePath).toContain("/custom/log/path/cerebro.log");
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
});
|