clawt 2.17.1 → 2.19.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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration, formatRelativeTime } from '../../../src/utils/formatter.js';
2
+ import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString } from '../../../src/utils/formatter.js';
3
3
  import { createWorktreeStatus } from '../../helpers/fixtures.js';
4
4
 
5
5
  describe('formatWorktreeStatus', () => {
@@ -177,3 +177,93 @@ describe('formatRelativeTime', () => {
177
177
  expect(formatRelativeTime(future.toISOString())).toBe('刚刚');
178
178
  });
179
179
  });
180
+
181
+ describe('formatDiskSize', () => {
182
+ it('0 字节时返回 "0 B"', () => {
183
+ expect(formatDiskSize(0)).toBe('0 B');
184
+ });
185
+
186
+ it('小于 1 KB 时以 B 为单位', () => {
187
+ expect(formatDiskSize(1)).toBe('1 B');
188
+ expect(formatDiskSize(512)).toBe('512 B');
189
+ expect(formatDiskSize(1023)).toBe('1023 B');
190
+ });
191
+
192
+ it('恰好 1 KB 时返回 "1.0 KB"', () => {
193
+ expect(formatDiskSize(1024)).toBe('1.0 KB');
194
+ });
195
+
196
+ it('KB 范围内保留一位小数', () => {
197
+ expect(formatDiskSize(1536)).toBe('1.5 KB');
198
+ expect(formatDiskSize(10240)).toBe('10.0 KB');
199
+ expect(formatDiskSize(1024 * 1024 - 1)).toBe('1024.0 KB');
200
+ });
201
+
202
+ it('恰好 1 MB 时返回 "1.0 MB"', () => {
203
+ expect(formatDiskSize(1024 * 1024)).toBe('1.0 MB');
204
+ });
205
+
206
+ it('MB 范围内保留一位小数', () => {
207
+ expect(formatDiskSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
208
+ expect(formatDiskSize(256 * 1024 * 1024)).toBe('256.0 MB');
209
+ expect(formatDiskSize(1024 * 1024 * 1024 - 1)).toBe('1024.0 MB');
210
+ });
211
+
212
+ it('恰好 1 GB 时返回 "1.0 GB"', () => {
213
+ expect(formatDiskSize(1024 * 1024 * 1024)).toBe('1.0 GB');
214
+ });
215
+
216
+ it('GB 范围内保留一位小数', () => {
217
+ expect(formatDiskSize(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
218
+ expect(formatDiskSize(10 * 1024 * 1024 * 1024)).toBe('10.0 GB');
219
+ });
220
+ });
221
+
222
+ describe('formatLocalISOString', () => {
223
+ it('返回值包含日期和时间部分', () => {
224
+ const date = new Date('2025-06-15T10:30:00Z');
225
+ const result = formatLocalISOString(date);
226
+ // 结果应符合 ISO 8601 带时区偏移的格式
227
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
228
+ });
229
+
230
+ it('返回值不以 Z 结尾(应包含时区偏移)', () => {
231
+ const date = new Date();
232
+ const result = formatLocalISOString(date);
233
+ expect(result).not.toMatch(/Z$/);
234
+ });
235
+
236
+ it('时区偏移格式为 +HH:MM 或 -HH:MM', () => {
237
+ const date = new Date();
238
+ const result = formatLocalISOString(date);
239
+ const tzPart = result.slice(-6);
240
+ expect(tzPart).toMatch(/^[+-]\d{2}:\d{2}$/);
241
+ });
242
+
243
+ it('时区偏移量与本机 getTimezoneOffset 一致', () => {
244
+ const date = new Date('2025-01-01T12:00:00Z');
245
+ const result = formatLocalISOString(date);
246
+ const tzPart = result.slice(-6);
247
+ const sign = tzPart[0];
248
+ const hours = parseInt(tzPart.slice(1, 3), 10);
249
+ const minutes = parseInt(tzPart.slice(4, 6), 10);
250
+
251
+ // 从偏移字符串反推总分钟数
252
+ const totalMinutesFromResult = (sign === '+' ? 1 : -1) * (hours * 60 + minutes);
253
+ const expectedMinutes = -date.getTimezoneOffset();
254
+ expect(totalMinutesFromResult).toBe(expectedMinutes);
255
+ });
256
+
257
+ it('不同日期对象产生不同的输出', () => {
258
+ const date1 = new Date('2025-01-01T00:00:00Z');
259
+ const date2 = new Date('2025-06-15T12:30:00Z');
260
+ expect(formatLocalISOString(date1)).not.toBe(formatLocalISOString(date2));
261
+ });
262
+
263
+ it('毫秒部分被保留', () => {
264
+ const date = new Date('2025-03-20T08:15:30.123Z');
265
+ const result = formatLocalISOString(date);
266
+ // 毫秒值应出现在结果中
267
+ expect(result).toContain('.123');
268
+ });
269
+ });
@@ -6,15 +6,17 @@ vi.mock('node:fs', () => ({
6
6
  mkdirSync: vi.fn(),
7
7
  readdirSync: vi.fn(),
8
8
  rmdirSync: vi.fn(),
9
+ statSync: vi.fn(),
9
10
  }));
10
11
 
11
- import { existsSync, mkdirSync, readdirSync, rmdirSync } from 'node:fs';
12
- import { ensureDir, removeEmptyDir } from '../../../src/utils/fs.js';
12
+ import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
13
+ import { ensureDir, removeEmptyDir, calculateDirSize } from '../../../src/utils/fs.js';
13
14
 
14
15
  const mockedExistsSync = vi.mocked(existsSync);
15
16
  const mockedMkdirSync = vi.mocked(mkdirSync);
16
17
  const mockedReaddirSync = vi.mocked(readdirSync);
17
18
  const mockedRmdirSync = vi.mocked(rmdirSync);
19
+ const mockedStatSync = vi.mocked(statSync);
18
20
 
19
21
  describe('ensureDir', () => {
20
22
  it('目录不存在时创建', () => {
@@ -52,3 +54,124 @@ describe('removeEmptyDir', () => {
52
54
  expect(mockedReaddirSync).not.toHaveBeenCalled();
53
55
  });
54
56
  });
57
+
58
+ /**
59
+ * 创建模拟的 Dirent 对象
60
+ * @param {string} name - 文件/目录名
61
+ * @param {'file' | 'directory'} type - 类型
62
+ * @returns {import('node:fs').Dirent} 模拟的 Dirent 对象
63
+ */
64
+ function createMockDirent(name: string, type: 'file' | 'directory') {
65
+ return {
66
+ name,
67
+ isFile: () => type === 'file',
68
+ isDirectory: () => type === 'directory',
69
+ isBlockDevice: () => false,
70
+ isCharacterDevice: () => false,
71
+ isSymbolicLink: () => false,
72
+ isFIFO: () => false,
73
+ isSocket: () => false,
74
+ parentPath: '',
75
+ path: '',
76
+ } as import('node:fs').Dirent;
77
+ }
78
+
79
+ describe('calculateDirSize', () => {
80
+ beforeEach(() => {
81
+ vi.clearAllMocks();
82
+ });
83
+
84
+ it('空目录返回 0', () => {
85
+ mockedReaddirSync.mockReturnValue([]);
86
+ expect(calculateDirSize('/tmp/empty')).toBe(0);
87
+ });
88
+
89
+ it('只有文件时累加所有文件大小', () => {
90
+ mockedReaddirSync.mockReturnValue([
91
+ createMockDirent('a.txt', 'file'),
92
+ createMockDirent('b.txt', 'file'),
93
+ ]);
94
+ mockedStatSync
95
+ .mockReturnValueOnce({ size: 100 } as import('node:fs').Stats)
96
+ .mockReturnValueOnce({ size: 200 } as import('node:fs').Stats);
97
+
98
+ expect(calculateDirSize('/tmp/dir')).toBe(300);
99
+ });
100
+
101
+ it('递归计算子目录中的文件大小', () => {
102
+ // 第一次调用:顶层目录包含一个文件和一个子目录
103
+ mockedReaddirSync
104
+ .mockReturnValueOnce([
105
+ createMockDirent('file.txt', 'file'),
106
+ createMockDirent('subdir', 'directory'),
107
+ ])
108
+ // 第二次调用:子目录包含一个文件
109
+ .mockReturnValueOnce([
110
+ createMockDirent('nested.txt', 'file'),
111
+ ]);
112
+
113
+ mockedStatSync
114
+ .mockReturnValueOnce({ size: 500 } as import('node:fs').Stats)
115
+ .mockReturnValueOnce({ size: 300 } as import('node:fs').Stats);
116
+
117
+ expect(calculateDirSize('/tmp/dir')).toBe(800);
118
+ });
119
+
120
+ it('readdirSync 抛出异常时返回 0', () => {
121
+ mockedReaddirSync.mockImplementation(() => {
122
+ throw new Error('权限不足');
123
+ });
124
+ expect(calculateDirSize('/tmp/no-access')).toBe(0);
125
+ });
126
+
127
+ it('statSync 对个别文件抛出异常时跳过该文件继续计算', () => {
128
+ mockedReaddirSync.mockReturnValue([
129
+ createMockDirent('good.txt', 'file'),
130
+ createMockDirent('bad.txt', 'file'),
131
+ createMockDirent('ok.txt', 'file'),
132
+ ]);
133
+ mockedStatSync
134
+ .mockReturnValueOnce({ size: 100 } as import('node:fs').Stats)
135
+ .mockImplementationOnce(() => { throw new Error('无法访问'); })
136
+ .mockReturnValueOnce({ size: 200 } as import('node:fs').Stats);
137
+
138
+ expect(calculateDirSize('/tmp/dir')).toBe(300);
139
+ });
140
+
141
+ it('多层嵌套目录正确累加', () => {
142
+ // 顶层:子目录 a
143
+ mockedReaddirSync
144
+ .mockReturnValueOnce([
145
+ createMockDirent('a', 'directory'),
146
+ ])
147
+ // a 目录:文件 + 子目录 b
148
+ .mockReturnValueOnce([
149
+ createMockDirent('x.txt', 'file'),
150
+ createMockDirent('b', 'directory'),
151
+ ])
152
+ // b 目录:一个文件
153
+ .mockReturnValueOnce([
154
+ createMockDirent('y.txt', 'file'),
155
+ ]);
156
+
157
+ mockedStatSync
158
+ .mockReturnValueOnce({ size: 1000 } as import('node:fs').Stats)
159
+ .mockReturnValueOnce({ size: 2000 } as import('node:fs').Stats);
160
+
161
+ expect(calculateDirSize('/tmp/root')).toBe(3000);
162
+ });
163
+
164
+ it('只包含子目录(无直接文件)时正确递归', () => {
165
+ mockedReaddirSync
166
+ .mockReturnValueOnce([
167
+ createMockDirent('sub', 'directory'),
168
+ ])
169
+ .mockReturnValueOnce([
170
+ createMockDirent('inner.txt', 'file'),
171
+ ]);
172
+
173
+ mockedStatSync.mockReturnValueOnce({ size: 42 } as import('node:fs').Stats);
174
+
175
+ expect(calculateDirSize('/tmp/dir')).toBe(42);
176
+ });
177
+ });
@@ -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
+ });