@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.
@@ -0,0 +1,380 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // ── Hoisted mock functions (available to vi.mock factories) ──────────
4
+
5
+ const {
6
+ mockOpenSync,
7
+ mockWriteSync,
8
+ mockCloseSync,
9
+ mockUnlinkSync,
10
+ mockStatSync,
11
+ mockReaddirSync,
12
+ mockMkdtempSync,
13
+ mockRmSync,
14
+ mockExecFile,
15
+ mockLoadPluginConfig,
16
+ mockShowToast,
17
+ mockLogInfo,
18
+ mockLogDebug,
19
+ mockLogError,
20
+ } = vi.hoisted(() => ({
21
+ mockOpenSync: vi.fn(),
22
+ mockWriteSync: vi.fn(),
23
+ mockCloseSync: vi.fn(),
24
+ mockUnlinkSync: vi.fn(),
25
+ mockStatSync: vi.fn(),
26
+ mockReaddirSync: vi.fn(),
27
+ mockMkdtempSync: vi.fn(),
28
+ mockRmSync: vi.fn(),
29
+ mockExecFile: vi.fn(),
30
+ mockLoadPluginConfig: vi.fn(),
31
+ mockShowToast: vi.fn(),
32
+ mockLogInfo: vi.fn(),
33
+ mockLogDebug: vi.fn(),
34
+ mockLogError: vi.fn(),
35
+ }));
36
+
37
+ // ── Mocks ────────────────────────────────────────────────────────────
38
+
39
+ vi.mock("node:child_process", () => ({
40
+ execFile: mockExecFile,
41
+ }));
42
+
43
+ vi.mock("node:util", () => ({
44
+ promisify: (fn: Function) => {
45
+ return (...args: unknown[]) =>
46
+ new Promise((resolve, reject) => {
47
+ fn(...args, (err: Error | null, stdout: string, stderr: string) => {
48
+ if (err) reject(err);
49
+ else resolve({ stdout, stderr });
50
+ });
51
+ });
52
+ },
53
+ }));
54
+
55
+ vi.mock("node:module", () => ({
56
+ createRequire: () => ({
57
+ resolve: vi.fn(),
58
+ }),
59
+ }));
60
+
61
+ vi.mock("node:os", () => ({
62
+ homedir: () => "/home/test",
63
+ tmpdir: () => "/tmp",
64
+ }));
65
+
66
+ vi.mock("node:fs", () => ({
67
+ openSync: mockOpenSync,
68
+ writeSync: mockWriteSync,
69
+ closeSync: mockCloseSync,
70
+ unlinkSync: mockUnlinkSync,
71
+ statSync: mockStatSync,
72
+ readdirSync: mockReaddirSync,
73
+ mkdtempSync: mockMkdtempSync,
74
+ rmSync: mockRmSync,
75
+ }));
76
+
77
+ vi.mock("./config.js", () => ({
78
+ loadPluginConfig: mockLoadPluginConfig,
79
+ }));
80
+
81
+ vi.mock("./hooks.js", () => ({
82
+ showToast: mockShowToast,
83
+ }));
84
+
85
+ vi.mock("./logger.js", () => ({
86
+ logInfo: mockLogInfo,
87
+ logDebug: mockLogDebug,
88
+ logError: mockLogError,
89
+ }));
90
+
91
+ // ── Imports (post-mock) ──────────────────────────────────────────────
92
+
93
+ import { checkAndUpdate } from "./updater.js";
94
+
95
+ // ── Helpers ──────────────────────────────────────────────────────────
96
+
97
+ type CbFn = (err: Error | null, stdout: string, stderr: string) => void;
98
+
99
+ function mockExecFileSequence(responses: Array<{ stdout?: string; err?: Error }>): void {
100
+ let idx = 0;
101
+ mockExecFile.mockImplementation(((...args: unknown[]) => {
102
+ const cb = args[args.length - 1] as CbFn;
103
+ const r = responses[Math.min(idx, responses.length - 1)];
104
+ idx++;
105
+ if (r.err) cb(r.err, "", "");
106
+ else cb(null, r.stdout ?? "", "");
107
+ }) as typeof mockExecFile);
108
+ }
109
+
110
+ function stubFsForLockAndInstall(overrides?: { readdirFiles?: string[] }) {
111
+ mockOpenSync.mockReturnValue(42);
112
+ mockWriteSync.mockReturnValue(4);
113
+ mockCloseSync.mockImplementation(() => {});
114
+ mockUnlinkSync.mockImplementation(() => {});
115
+ mockStatSync.mockImplementation(() => {
116
+ throw new Error("ENOENT");
117
+ });
118
+ mockReaddirSync.mockReturnValue(
119
+ (overrides?.readdirFiles ?? ["mingxy-cerebro-2.0.0.tgz"]) as any,
120
+ );
121
+ mockMkdtempSync.mockReturnValue("/tmp/cerebro-update-xxx");
122
+ mockRmSync.mockImplementation(() => {});
123
+ }
124
+
125
+ function clearFsMocks() {
126
+ [
127
+ mockOpenSync, mockWriteSync, mockCloseSync, mockUnlinkSync,
128
+ mockStatSync, mockReaddirSync, mockMkdtempSync, mockRmSync,
129
+ ].forEach((m) => { m.mockReset(); });
130
+ }
131
+
132
+ function makeConfig(autoUpdate = true) {
133
+ return {
134
+ autoUpdate,
135
+ connection: { apiUrl: "", apiKey: "", requestTimeoutMs: 15000 },
136
+ content: { maxQueryLength: 200, maxContentChars: 30000, maxContentLength: 3000 },
137
+ injection: { recentCount: 5, searchCount: 10, recentTruncateChars: 0, searchTruncateChars: 0 },
138
+ ingest: { autoCaptureThreshold: 5, ingestMode: "smart" as const },
139
+ logging: { logEnabled: true, logLevel: "INFO" as const, logDir: "/tmp/cerebro-test-logs" },
140
+ ui: { toastDelayMs: 7000 },
141
+ web: { enabled: true },
142
+ };
143
+ }
144
+
145
+ // ── Tests ────────────────────────────────────────────────────────────
146
+
147
+ describe("updater", () => {
148
+ beforeEach(() => {
149
+ vi.clearAllMocks();
150
+ mockLoadPluginConfig.mockReturnValue(makeConfig());
151
+ });
152
+
153
+ afterEach(() => {
154
+ clearFsMocks();
155
+ });
156
+
157
+ // ── checkAndUpdate ────────────────────────────────────────────────
158
+
159
+ describe("checkAndUpdate", () => {
160
+ it("skips when autoUpdate is disabled", async () => {
161
+ mockLoadPluginConfig.mockReturnValue(makeConfig(false));
162
+
163
+ await checkAndUpdate({}, "1.0.0");
164
+
165
+ expect(mockLogDebug).toHaveBeenCalledWith("updater: autoUpdate disabled, skipping");
166
+ expect(mockExecFile).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it("skips when latest version fetch fails", async () => {
170
+ mockExecFileSequence([{ err: new Error("network error") }]);
171
+
172
+ await checkAndUpdate({}, "1.0.0");
173
+
174
+ expect(mockLogError).toHaveBeenCalledWith("updater: failed to fetch latest version");
175
+ });
176
+
177
+ it("skips when current version equals latest", async () => {
178
+ mockExecFileSequence([{ stdout: "1.0.0\n" }]);
179
+
180
+ await checkAndUpdate({}, "1.0.0");
181
+
182
+ expect(mockLogInfo).toHaveBeenCalledWith(
183
+ "updater: already up to date",
184
+ expect.objectContaining({ currentVersion: "1.0.0", latest: "1.0.0" }),
185
+ );
186
+ });
187
+
188
+ it("skips when current version is greater than latest", async () => {
189
+ mockExecFileSequence([{ stdout: "0.9.0\n" }]);
190
+
191
+ await checkAndUpdate({}, "1.0.0");
192
+
193
+ expect(mockLogInfo).toHaveBeenCalledWith(
194
+ "updater: already up to date",
195
+ expect.objectContaining({ currentVersion: "1.0.0", latest: "0.9.0" }),
196
+ );
197
+ });
198
+
199
+ it("installs update when newer version is available", async () => {
200
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { stdout: "" }, { stdout: "" }]);
201
+ stubFsForLockAndInstall();
202
+
203
+ const tui = {};
204
+ await checkAndUpdate(tui, "1.0.0");
205
+
206
+ expect(mockLogInfo).toHaveBeenCalledWith(
207
+ "updater: new version available",
208
+ expect.objectContaining({ currentVersion: "1.0.0", latest: "2.0.0" }),
209
+ );
210
+ expect(mockShowToast).toHaveBeenCalledWith(
211
+ tui,
212
+ "🧠 Cerebro Updated",
213
+ expect.stringContaining("1.0.0 → v2.0.0"),
214
+ "info",
215
+ 2000,
216
+ );
217
+ });
218
+
219
+ it("handles install failure gracefully", async () => {
220
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { err: new Error("npm pack failed") }]);
221
+ stubFsForLockAndInstall();
222
+
223
+ await checkAndUpdate({}, "1.0.0");
224
+
225
+ expect(mockLogError).toHaveBeenCalledWith(
226
+ "updater: update failed",
227
+ expect.objectContaining({ targetDir: expect.any(String) }),
228
+ );
229
+ });
230
+ });
231
+
232
+ // ── Lock mechanism ────────────────────────────────────────────────
233
+
234
+ describe("lock mechanism", () => {
235
+ it("acquires and releases lock on success", async () => {
236
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { stdout: "" }, { stdout: "" }]);
237
+ stubFsForLockAndInstall();
238
+
239
+ await checkAndUpdate({}, "1.0.0");
240
+
241
+ expect(mockOpenSync).toHaveBeenCalled();
242
+ expect(mockWriteSync).toHaveBeenCalledWith(42, expect.any(String));
243
+ expect(mockCloseSync).toHaveBeenCalled();
244
+ expect(mockUnlinkSync).toHaveBeenCalled();
245
+ });
246
+
247
+ it("cleans up stale lock before acquiring", async () => {
248
+ const staleTime = Date.now() - 6 * 60 * 1000;
249
+ stubFsForLockAndInstall();
250
+ mockStatSync.mockReset();
251
+ mockStatSync.mockReturnValue({ mtimeMs: staleTime } as any);
252
+
253
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { stdout: "" }, { stdout: "" }]);
254
+
255
+ await checkAndUpdate({}, "1.0.0");
256
+
257
+ // Stale cleanup + final release = 2 unlinkSync calls
258
+ expect(mockUnlinkSync).toHaveBeenCalledTimes(2);
259
+ });
260
+
261
+ it("does not clean fresh lock and acquisition fails", async () => {
262
+ const freshTime = Date.now() - 1000;
263
+ mockStatSync.mockReturnValue({ mtimeMs: freshTime } as any);
264
+ mockOpenSync.mockImplementation(() => {
265
+ throw Object.assign(new Error("EEXIST"), { code: "EEXIST" });
266
+ });
267
+
268
+ mockExecFileSequence([{ stdout: "2.0.0\n" }]);
269
+
270
+ await checkAndUpdate({}, "1.0.0");
271
+
272
+ expect(mockUnlinkSync).not.toHaveBeenCalled();
273
+ expect(mockLogDebug).toHaveBeenCalledWith(
274
+ "updater: lock acquisition failed (another process updating?)",
275
+ );
276
+ });
277
+
278
+ it("releases lock even on install failure", async () => {
279
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { err: new Error("pack failed") }]);
280
+ stubFsForLockAndInstall();
281
+
282
+ await checkAndUpdate({}, "1.0.0");
283
+
284
+ expect(mockCloseSync).toHaveBeenCalled();
285
+ expect(mockUnlinkSync).toHaveBeenCalled();
286
+ });
287
+ });
288
+
289
+ // ── installUpdate error paths ─────────────────────────────────────
290
+
291
+ describe("installUpdate", () => {
292
+ it("handles no tgz found after npm pack", async () => {
293
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { stdout: "" }]);
294
+ stubFsForLockAndInstall({ readdirFiles: ["readme.md", "package.json"] });
295
+
296
+ await checkAndUpdate({}, "1.0.0");
297
+
298
+ expect(mockLogError).toHaveBeenCalledWith(
299
+ "updater: no tgz found after npm pack",
300
+ expect.objectContaining({ tmpDir: "/tmp/cerebro-update-xxx" }),
301
+ );
302
+ });
303
+
304
+ it("handles tar extraction failure", async () => {
305
+ mockExecFileSequence([
306
+ { stdout: "2.0.0\n" },
307
+ { stdout: "" },
308
+ { err: new Error("tar failed") },
309
+ ]);
310
+ stubFsForLockAndInstall();
311
+
312
+ await checkAndUpdate({}, "1.0.0");
313
+
314
+ expect(mockLogError).toHaveBeenCalledWith(
315
+ "updater: install failed",
316
+ expect.objectContaining({ error: expect.any(String) }),
317
+ );
318
+ expect(mockRmSync).toHaveBeenCalledWith("/tmp/cerebro-update-xxx", { recursive: true });
319
+ });
320
+
321
+ it("cleans up temp dir even on failure", async () => {
322
+ mockExecFileSequence([{ stdout: "2.0.0\n" }, { err: new Error("pack failed") }]);
323
+ stubFsForLockAndInstall();
324
+
325
+ await checkAndUpdate({}, "1.0.0");
326
+
327
+ expect(mockRmSync).toHaveBeenCalledWith("/tmp/cerebro-update-xxx", { recursive: true });
328
+ });
329
+ });
330
+
331
+ // ── Version comparison (indirect) ─────────────────────────────────
332
+
333
+ describe("version comparison", () => {
334
+ it("handles v-prefixed versions", async () => {
335
+ mockExecFileSequence([{ stdout: "v2.0.0\n" }, { stdout: "" }, { stdout: "" }]);
336
+ stubFsForLockAndInstall();
337
+
338
+ await checkAndUpdate({}, "v1.0.0");
339
+
340
+ expect(mockLogInfo).toHaveBeenCalledWith(
341
+ "updater: new version available",
342
+ expect.objectContaining({ currentVersion: "v1.0.0", latest: "v2.0.0" }),
343
+ );
344
+ });
345
+
346
+ it("detects patch-level update", async () => {
347
+ mockExecFileSequence([{ stdout: "1.0.1\n" }, { stdout: "" }, { stdout: "" }]);
348
+ stubFsForLockAndInstall();
349
+
350
+ await checkAndUpdate({}, "1.0.0");
351
+
352
+ expect(mockLogInfo).toHaveBeenCalledWith(
353
+ "updater: new version available",
354
+ expect.objectContaining({ currentVersion: "1.0.0", latest: "1.0.1" }),
355
+ );
356
+ });
357
+
358
+ it("detects minor-level update", async () => {
359
+ mockExecFileSequence([{ stdout: "1.1.0\n" }]);
360
+
361
+ await checkAndUpdate({}, "1.0.9");
362
+
363
+ expect(mockLogInfo).toHaveBeenCalledWith(
364
+ "updater: new version available",
365
+ expect.objectContaining({ currentVersion: "1.0.9", latest: "1.1.0" }),
366
+ );
367
+ });
368
+
369
+ it("detects major-level update", async () => {
370
+ mockExecFileSequence([{ stdout: "2.0.0\n" }]);
371
+
372
+ await checkAndUpdate({}, "1.99.99");
373
+
374
+ expect(mockLogInfo).toHaveBeenCalledWith(
375
+ "updater: new version available",
376
+ expect.objectContaining({ currentVersion: "1.99.99", latest: "2.0.0" }),
377
+ );
378
+ });
379
+ });
380
+ });