clawt 2.17.1 → 2.18.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.
@@ -0,0 +1,439 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { IncomingMessage, ClientRequest } from 'node:http';
3
+ import { EventEmitter } from 'node:events';
4
+
5
+ // mock node:fs
6
+ vi.mock('node:fs', () => ({
7
+ readFileSync: vi.fn(),
8
+ writeFileSync: vi.fn(),
9
+ }));
10
+
11
+ // mock node:child_process
12
+ vi.mock('node:child_process', () => ({
13
+ execSync: vi.fn(),
14
+ }));
15
+
16
+ // mock node:https
17
+ vi.mock('node:https', () => ({
18
+ request: vi.fn(),
19
+ }));
20
+
21
+ // mock chalk(测试环境已通过 FORCE_COLOR=0 禁用颜色,但仍需确保不产生转义码)
22
+ vi.mock('chalk', () => ({
23
+ default: {
24
+ red: (s: string) => s,
25
+ green: (s: string) => s,
26
+ cyan: (s: string) => s,
27
+ },
28
+ }));
29
+
30
+ // mock string-width(纯 ASCII 场景下直接返回字符串长度即可)
31
+ vi.mock('string-width', () => ({
32
+ default: (s: string) => s.length,
33
+ }));
34
+
35
+ // mock 常量路径
36
+ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
37
+ const original = await importOriginal<typeof import('../../../src/constants/index.js')>();
38
+ return {
39
+ ...original,
40
+ UPDATE_CHECK_PATH: '/tmp/test-update-check.json',
41
+ };
42
+ });
43
+
44
+ import { readFileSync, writeFileSync } from 'node:fs';
45
+ import { execSync } from 'node:child_process';
46
+ import { request } from 'node:https';
47
+ import { checkForUpdates } from '../../../src/utils/update-checker.js';
48
+
49
+ const mockedReadFileSync = vi.mocked(readFileSync);
50
+ const mockedWriteFileSync = vi.mocked(writeFileSync);
51
+ const mockedExecSync = vi.mocked(execSync);
52
+ const mockedRequest = vi.mocked(request);
53
+
54
+ /**
55
+ * 创建一个模拟的 https 响应,返回指定的 JSON 数据
56
+ * @param {string} body - 响应体内容
57
+ * @returns {{ req: EventEmitter, res: EventEmitter }} 模拟的请求和响应对象
58
+ */
59
+ function createMockHttpResponse(body: string): { req: EventEmitter & { end: ReturnType<typeof vi.fn>; destroy: ReturnType<typeof vi.fn> }; res: EventEmitter } {
60
+ const req = Object.assign(new EventEmitter(), {
61
+ end: vi.fn(),
62
+ destroy: vi.fn(),
63
+ });
64
+ const res = new EventEmitter();
65
+
66
+ mockedRequest.mockImplementation((_url: unknown, _opts: unknown, cb?: (res: IncomingMessage) => void) => {
67
+ // 在下一个微任务中触发回调,模拟异步行为
68
+ queueMicrotask(() => {
69
+ cb?.(res as unknown as IncomingMessage);
70
+ res.emit('data', Buffer.from(body));
71
+ res.emit('end');
72
+ });
73
+ return req as unknown as ClientRequest;
74
+ });
75
+
76
+ return { req, res };
77
+ }
78
+
79
+ /**
80
+ * 创建一个会触发错误的模拟 https 请求
81
+ * @returns {{ req: EventEmitter }} 模拟的请求对象
82
+ */
83
+ function createMockHttpError(): { req: EventEmitter & { end: ReturnType<typeof vi.fn>; destroy: ReturnType<typeof vi.fn> } } {
84
+ const req = Object.assign(new EventEmitter(), {
85
+ end: vi.fn(),
86
+ destroy: vi.fn(),
87
+ });
88
+
89
+ mockedRequest.mockImplementation(() => {
90
+ queueMicrotask(() => {
91
+ req.emit('error', new Error('network error'));
92
+ });
93
+ return req as unknown as ClientRequest;
94
+ });
95
+
96
+ return { req };
97
+ }
98
+
99
+ /**
100
+ * 创建一个会触发超时的模拟 https 请求
101
+ * @returns {{ req: EventEmitter }} 模拟的请求对象
102
+ */
103
+ function createMockHttpTimeout(): { req: EventEmitter & { end: ReturnType<typeof vi.fn>; destroy: ReturnType<typeof vi.fn> } } {
104
+ const req = Object.assign(new EventEmitter(), {
105
+ end: vi.fn(),
106
+ destroy: vi.fn(),
107
+ });
108
+
109
+ mockedRequest.mockImplementation(() => {
110
+ queueMicrotask(() => {
111
+ req.emit('timeout');
112
+ });
113
+ return req as unknown as ClientRequest;
114
+ });
115
+
116
+ return { req };
117
+ }
118
+
119
+ beforeEach(() => {
120
+ vi.spyOn(console, 'log').mockImplementation(() => {});
121
+ });
122
+
123
+ // ========== 缓存读取与过期判断 ==========
124
+
125
+ describe('checkForUpdates - 缓存逻辑', () => {
126
+ it('缓存不存在时请求 registry', async () => {
127
+ // 缓存文件不存在
128
+ mockedReadFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
129
+ createMockHttpResponse(JSON.stringify({ version: '2.17.1' }));
130
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
131
+
132
+ await checkForUpdates('2.17.1');
133
+
134
+ expect(mockedRequest).toHaveBeenCalled();
135
+ });
136
+
137
+ it('缓存有效且无新版本时不打印提示', async () => {
138
+ const cache = {
139
+ lastCheck: Date.now(),
140
+ latestVersion: '2.17.1',
141
+ currentVersion: '2.17.1',
142
+ };
143
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
144
+
145
+ await checkForUpdates('2.17.1');
146
+
147
+ // 不应请求 registry
148
+ expect(mockedRequest).not.toHaveBeenCalled();
149
+ // 不应打印任何内容(无新版本)
150
+ expect(console.log).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('缓存有效且有新版本时打印提示', async () => {
154
+ const cache = {
155
+ lastCheck: Date.now(),
156
+ latestVersion: '2.18.0',
157
+ currentVersion: '2.17.1',
158
+ };
159
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
160
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
161
+
162
+ await checkForUpdates('2.17.1');
163
+
164
+ // 不应请求 registry(缓存有效)
165
+ expect(mockedRequest).not.toHaveBeenCalled();
166
+ // 应打印更新提示
167
+ expect(console.log).toHaveBeenCalled();
168
+ const allOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]).join('\n');
169
+ expect(allOutput).toContain('2.18.0');
170
+ expect(allOutput).toContain('2.17.1');
171
+ });
172
+
173
+ it('缓存过期(超过 24h)时请求 registry', async () => {
174
+ const cache = {
175
+ lastCheck: Date.now() - 25 * 60 * 60 * 1000, // 25 小时前
176
+ latestVersion: '2.17.1',
177
+ currentVersion: '2.17.1',
178
+ };
179
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
180
+ createMockHttpResponse(JSON.stringify({ version: '2.17.1' }));
181
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
182
+
183
+ await checkForUpdates('2.17.1');
184
+
185
+ expect(mockedRequest).toHaveBeenCalled();
186
+ });
187
+
188
+ it('本地版本变化时视为缓存过期', async () => {
189
+ const cache = {
190
+ lastCheck: Date.now(), // 时间未过期
191
+ latestVersion: '2.18.0',
192
+ currentVersion: '2.16.0', // 与当前版本 2.17.1 不一致
193
+ };
194
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
195
+ createMockHttpResponse(JSON.stringify({ version: '2.18.0' }));
196
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
197
+
198
+ await checkForUpdates('2.17.1');
199
+
200
+ // 即使时间未过期,版本不一致也应重新请求
201
+ expect(mockedRequest).toHaveBeenCalled();
202
+ });
203
+
204
+ it('缓存文件损坏时请求 registry', async () => {
205
+ mockedReadFileSync.mockReturnValue('invalid json {{{');
206
+ createMockHttpResponse(JSON.stringify({ version: '2.17.1' }));
207
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
208
+
209
+ await checkForUpdates('2.17.1');
210
+
211
+ expect(mockedRequest).toHaveBeenCalled();
212
+ });
213
+ });
214
+
215
+ // ========== 网络请求逻辑 ==========
216
+
217
+ describe('checkForUpdates - 网络请求', () => {
218
+ beforeEach(() => {
219
+ // 确保缓存不存在,强制走网络请求
220
+ mockedReadFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
221
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
222
+ });
223
+
224
+ it('请求成功且有新版本时写入缓存并打印提示', async () => {
225
+ createMockHttpResponse(JSON.stringify({ version: '2.18.0' }));
226
+
227
+ await checkForUpdates('2.17.1');
228
+
229
+ // 应写入缓存
230
+ expect(mockedWriteFileSync).toHaveBeenCalledWith(
231
+ '/tmp/test-update-check.json',
232
+ expect.stringContaining('"latestVersion": "2.18.0"'),
233
+ 'utf-8',
234
+ );
235
+ // 应打印提示
236
+ expect(console.log).toHaveBeenCalled();
237
+ const allOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]).join('\n');
238
+ expect(allOutput).toContain('2.18.0');
239
+ });
240
+
241
+ it('请求成功但版本相同时写入缓存但不打印提示', async () => {
242
+ createMockHttpResponse(JSON.stringify({ version: '2.17.1' }));
243
+
244
+ await checkForUpdates('2.17.1');
245
+
246
+ // 应写入缓存
247
+ expect(mockedWriteFileSync).toHaveBeenCalled();
248
+ // 不应打印提示(版本相同)
249
+ expect(console.log).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('网络请求失败时静默忽略', async () => {
253
+ createMockHttpError();
254
+
255
+ await checkForUpdates('2.17.1');
256
+
257
+ // 不应写入缓存
258
+ expect(mockedWriteFileSync).not.toHaveBeenCalled();
259
+ // 不应打印提示
260
+ expect(console.log).not.toHaveBeenCalled();
261
+ });
262
+
263
+ it('网络请求超时时静默忽略并销毁连接', async () => {
264
+ const { req } = createMockHttpTimeout();
265
+
266
+ await checkForUpdates('2.17.1');
267
+
268
+ expect(req.destroy).toHaveBeenCalled();
269
+ expect(mockedWriteFileSync).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it('registry 返回无效 JSON 时静默忽略', async () => {
273
+ createMockHttpResponse('not valid json');
274
+
275
+ await checkForUpdates('2.17.1');
276
+
277
+ expect(mockedWriteFileSync).not.toHaveBeenCalled();
278
+ expect(console.log).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it('registry 返回的 JSON 中无 version 字段时静默忽略', async () => {
282
+ createMockHttpResponse(JSON.stringify({ name: 'clawt' }));
283
+
284
+ await checkForUpdates('2.17.1');
285
+
286
+ expect(mockedWriteFileSync).not.toHaveBeenCalled();
287
+ });
288
+ });
289
+
290
+ // ========== 版本比较逻辑 ==========
291
+
292
+ describe('checkForUpdates - 版本比较', () => {
293
+ beforeEach(() => {
294
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
295
+ });
296
+
297
+ it('major 版本更高时提示更新', async () => {
298
+ const cache = { lastCheck: Date.now(), latestVersion: '3.0.0', currentVersion: '2.17.1' };
299
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
300
+
301
+ await checkForUpdates('2.17.1');
302
+
303
+ const allOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]).join('\n');
304
+ expect(allOutput).toContain('3.0.0');
305
+ });
306
+
307
+ it('minor 版本更高时提示更新', async () => {
308
+ const cache = { lastCheck: Date.now(), latestVersion: '2.18.0', currentVersion: '2.17.1' };
309
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
310
+
311
+ await checkForUpdates('2.17.1');
312
+
313
+ expect(console.log).toHaveBeenCalled();
314
+ });
315
+
316
+ it('patch 版本更高时提示更新', async () => {
317
+ const cache = { lastCheck: Date.now(), latestVersion: '2.17.2', currentVersion: '2.17.1' };
318
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
319
+
320
+ await checkForUpdates('2.17.1');
321
+
322
+ expect(console.log).toHaveBeenCalled();
323
+ });
324
+
325
+ it('版本相同时不提示', async () => {
326
+ const cache = { lastCheck: Date.now(), latestVersion: '2.17.1', currentVersion: '2.17.1' };
327
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
328
+
329
+ await checkForUpdates('2.17.1');
330
+
331
+ expect(console.log).not.toHaveBeenCalled();
332
+ });
333
+
334
+ it('本地版本更高时不提示', async () => {
335
+ const cache = { lastCheck: Date.now(), latestVersion: '2.16.0', currentVersion: '2.17.1' };
336
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
337
+
338
+ await checkForUpdates('2.17.1');
339
+
340
+ expect(console.log).not.toHaveBeenCalled();
341
+ });
342
+ });
343
+
344
+ // ========== 包管理器检测 ==========
345
+
346
+ describe('checkForUpdates - 包管理器检测', () => {
347
+ beforeEach(() => {
348
+ // 缓存有效且有新版本,确保走到打印提示逻辑
349
+ const cache = { lastCheck: Date.now(), latestVersion: '3.0.0', currentVersion: '2.17.1' };
350
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
351
+ });
352
+
353
+ it('pnpm 全局安装时提示使用 pnpm 命令', async () => {
354
+ mockedExecSync.mockImplementation((cmd: string) => {
355
+ if (typeof cmd === 'string' && cmd.includes('pnpm')) {
356
+ return 'clawt@2.17.1';
357
+ }
358
+ throw new Error('not found');
359
+ });
360
+
361
+ await checkForUpdates('2.17.1');
362
+
363
+ const allOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]).join('\n');
364
+ expect(allOutput).toContain('pnpm add -g clawt');
365
+ });
366
+
367
+ it('yarn 全局安装时提示使用 yarn 命令', async () => {
368
+ mockedExecSync.mockImplementation((cmd: string) => {
369
+ if (typeof cmd === 'string' && cmd.includes('pnpm')) {
370
+ throw new Error('not found');
371
+ }
372
+ if (typeof cmd === 'string' && cmd.includes('yarn')) {
373
+ return 'info "clawt@2.17.1"';
374
+ }
375
+ throw new Error('not found');
376
+ });
377
+
378
+ await checkForUpdates('2.17.1');
379
+
380
+ const allOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]).join('\n');
381
+ expect(allOutput).toContain('yarn global add clawt');
382
+ });
383
+
384
+ it('npm 全局安装时提示使用 npm 命令', async () => {
385
+ mockedExecSync.mockImplementation(() => {
386
+ throw new Error('not found');
387
+ });
388
+
389
+ await checkForUpdates('2.17.1');
390
+
391
+ const allOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]).join('\n');
392
+ expect(allOutput).toContain('npm i -g clawt');
393
+ });
394
+ });
395
+
396
+ // ========== 提示框输出格式 ==========
397
+
398
+ describe('checkForUpdates - 提示框格式', () => {
399
+ it('提示框包含完整的边框结构', async () => {
400
+ const cache = { lastCheck: Date.now(), latestVersion: '2.18.0', currentVersion: '2.17.1' };
401
+ mockedReadFileSync.mockReturnValue(JSON.stringify(cache));
402
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
403
+
404
+ await checkForUpdates('2.17.1');
405
+
406
+ const calls = (console.log as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0]);
407
+ // 应包含顶部和底部边框
408
+ expect(calls.some((line: string) => typeof line === 'string' && line.startsWith('╭') && line.endsWith('╮'))).toBe(true);
409
+ expect(calls.some((line: string) => typeof line === 'string' && line.startsWith('╰') && line.endsWith('╯'))).toBe(true);
410
+ // 应包含版本信息和更新命令
411
+ const allOutput = calls.join('\n');
412
+ expect(allOutput).toContain('2.17.1');
413
+ expect(allOutput).toContain('2.18.0');
414
+ expect(allOutput).toContain('npm i -g clawt');
415
+ });
416
+ });
417
+
418
+ // ========== 容错:异常不影响 CLI ==========
419
+
420
+ describe('checkForUpdates - 容错性', () => {
421
+ it('writeFileSync 抛出异常时不影响执行', async () => {
422
+ mockedReadFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
423
+ createMockHttpResponse(JSON.stringify({ version: '2.18.0' }));
424
+ mockedWriteFileSync.mockImplementation(() => { throw new Error('EACCES'); });
425
+ mockedExecSync.mockImplementation(() => { throw new Error('not found'); });
426
+
427
+ // 不应抛出异常
428
+ await expect(checkForUpdates('2.17.1')).resolves.toBeUndefined();
429
+ // 仍应打印提示
430
+ expect(console.log).toHaveBeenCalled();
431
+ });
432
+
433
+ it('任何未预期的异常都不会导致 checkForUpdates 抛出', async () => {
434
+ mockedReadFileSync.mockImplementation(() => { throw new TypeError('unexpected'); });
435
+ mockedRequest.mockImplementation(() => { throw new Error('unexpected'); });
436
+
437
+ await expect(checkForUpdates('2.17.1')).resolves.toBeUndefined();
438
+ });
439
+ });