@yivan-lab/pretty-please 1.3.1 → 1.5.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 (94) hide show
  1. package/README.md +250 -620
  2. package/bin/pls.tsx +178 -40
  3. package/dist/bin/pls.js +149 -27
  4. package/dist/package.json +10 -2
  5. package/dist/src/__integration__/command-generation.test.d.ts +5 -0
  6. package/dist/src/__integration__/command-generation.test.js +508 -0
  7. package/dist/src/__integration__/error-recovery.test.d.ts +5 -0
  8. package/dist/src/__integration__/error-recovery.test.js +511 -0
  9. package/dist/src/__integration__/shell-hook-workflow.test.d.ts +5 -0
  10. package/dist/src/__integration__/shell-hook-workflow.test.js +375 -0
  11. package/dist/src/__tests__/alias.test.d.ts +5 -0
  12. package/dist/src/__tests__/alias.test.js +421 -0
  13. package/dist/src/__tests__/chat-history.test.d.ts +5 -0
  14. package/dist/src/__tests__/chat-history.test.js +372 -0
  15. package/dist/src/__tests__/config.test.d.ts +5 -0
  16. package/dist/src/__tests__/config.test.js +822 -0
  17. package/dist/src/__tests__/history.test.d.ts +5 -0
  18. package/dist/src/__tests__/history.test.js +439 -0
  19. package/dist/src/__tests__/remote-history.test.d.ts +5 -0
  20. package/dist/src/__tests__/remote-history.test.js +641 -0
  21. package/dist/src/__tests__/remote.test.d.ts +5 -0
  22. package/dist/src/__tests__/remote.test.js +689 -0
  23. package/dist/src/__tests__/shell-hook-install.test.d.ts +5 -0
  24. package/dist/src/__tests__/shell-hook-install.test.js +413 -0
  25. package/dist/src/__tests__/shell-hook-remote.test.d.ts +5 -0
  26. package/dist/src/__tests__/shell-hook-remote.test.js +507 -0
  27. package/dist/src/__tests__/shell-hook.test.d.ts +5 -0
  28. package/dist/src/__tests__/shell-hook.test.js +440 -0
  29. package/dist/src/__tests__/sysinfo.test.d.ts +5 -0
  30. package/dist/src/__tests__/sysinfo.test.js +572 -0
  31. package/dist/src/__tests__/system-history.test.d.ts +5 -0
  32. package/dist/src/__tests__/system-history.test.js +457 -0
  33. package/dist/src/components/Chat.js +9 -28
  34. package/dist/src/config.d.ts +2 -0
  35. package/dist/src/config.js +30 -2
  36. package/dist/src/mastra-chat.js +10 -6
  37. package/dist/src/multi-step.js +10 -8
  38. package/dist/src/project-context.d.ts +22 -0
  39. package/dist/src/project-context.js +168 -0
  40. package/dist/src/prompts.d.ts +4 -4
  41. package/dist/src/prompts.js +23 -6
  42. package/dist/src/shell-hook.d.ts +32 -0
  43. package/dist/src/shell-hook.js +226 -33
  44. package/dist/src/sysinfo.d.ts +38 -9
  45. package/dist/src/sysinfo.js +245 -21
  46. package/dist/src/system-history.d.ts +18 -0
  47. package/dist/src/system-history.js +151 -0
  48. package/dist/src/ui/__tests__/theme.test.d.ts +5 -0
  49. package/dist/src/ui/__tests__/theme.test.js +688 -0
  50. package/dist/src/upgrade.js +3 -0
  51. package/dist/src/user-preferences.d.ts +44 -0
  52. package/dist/src/user-preferences.js +147 -0
  53. package/dist/src/utils/__tests__/platform-capabilities.test.d.ts +5 -0
  54. package/dist/src/utils/__tests__/platform-capabilities.test.js +214 -0
  55. package/dist/src/utils/__tests__/platform-exec.test.d.ts +5 -0
  56. package/dist/src/utils/__tests__/platform-exec.test.js +212 -0
  57. package/dist/src/utils/__tests__/platform-shell.test.d.ts +5 -0
  58. package/dist/src/utils/__tests__/platform-shell.test.js +300 -0
  59. package/dist/src/utils/__tests__/platform.test.d.ts +5 -0
  60. package/dist/src/utils/__tests__/platform.test.js +137 -0
  61. package/dist/src/utils/platform.d.ts +88 -0
  62. package/dist/src/utils/platform.js +331 -0
  63. package/package.json +10 -2
  64. package/src/__integration__/command-generation.test.ts +602 -0
  65. package/src/__integration__/error-recovery.test.ts +620 -0
  66. package/src/__integration__/shell-hook-workflow.test.ts +457 -0
  67. package/src/__tests__/alias.test.ts +545 -0
  68. package/src/__tests__/chat-history.test.ts +462 -0
  69. package/src/__tests__/config.test.ts +1043 -0
  70. package/src/__tests__/history.test.ts +538 -0
  71. package/src/__tests__/remote-history.test.ts +791 -0
  72. package/src/__tests__/remote.test.ts +866 -0
  73. package/src/__tests__/shell-hook-install.test.ts +510 -0
  74. package/src/__tests__/shell-hook-remote.test.ts +679 -0
  75. package/src/__tests__/shell-hook.test.ts +564 -0
  76. package/src/__tests__/sysinfo.test.ts +718 -0
  77. package/src/__tests__/system-history.test.ts +608 -0
  78. package/src/components/Chat.tsx +10 -37
  79. package/src/config.ts +29 -2
  80. package/src/mastra-chat.ts +12 -5
  81. package/src/multi-step.ts +11 -5
  82. package/src/project-context.ts +191 -0
  83. package/src/prompts.ts +26 -5
  84. package/src/shell-hook.ts +254 -32
  85. package/src/sysinfo.ts +326 -25
  86. package/src/system-history.ts +170 -0
  87. package/src/ui/__tests__/theme.test.ts +869 -0
  88. package/src/upgrade.ts +5 -0
  89. package/src/user-preferences.ts +178 -0
  90. package/src/utils/__tests__/platform-capabilities.test.ts +265 -0
  91. package/src/utils/__tests__/platform-exec.test.ts +278 -0
  92. package/src/utils/__tests__/platform-shell.test.ts +353 -0
  93. package/src/utils/__tests__/platform.test.ts +170 -0
  94. package/src/utils/platform.ts +431 -0
@@ -0,0 +1,791 @@
1
+ /**
2
+ * 远程服务器历史管理模块测试
3
+ * 测试远程命令历史的读写、Shell 历史获取、格式化等功能
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
7
+
8
+ // Mock fs 模块
9
+ vi.mock('fs', () => ({
10
+ default: {
11
+ existsSync: vi.fn(),
12
+ readFileSync: vi.fn(),
13
+ writeFileSync: vi.fn(),
14
+ mkdirSync: vi.fn(),
15
+ unlinkSync: vi.fn(),
16
+ },
17
+ }))
18
+
19
+ // Mock os 模块
20
+ vi.mock('os', () => ({
21
+ default: {
22
+ homedir: vi.fn(() => '/home/testuser'),
23
+ },
24
+ }))
25
+
26
+ // Mock config 模块
27
+ vi.mock('../config.js', () => ({
28
+ getConfig: vi.fn(() => ({
29
+ commandHistoryLimit: 10,
30
+ shellHistoryLimit: 15,
31
+ })),
32
+ CONFIG_DIR: '/home/testuser/.please',
33
+ }))
34
+
35
+ // Mock remote 模块
36
+ vi.mock('../remote.js', () => ({
37
+ sshExec: vi.fn(),
38
+ getRemote: vi.fn(),
39
+ }))
40
+
41
+ // Mock theme 模块
42
+ vi.mock('../ui/theme.js', () => ({
43
+ getCurrentTheme: vi.fn(() => ({
44
+ primary: '#007acc',
45
+ secondary: '#6c757d',
46
+ success: '#4caf50',
47
+ error: '#f44336',
48
+ warning: '#ff9800',
49
+ text: {
50
+ muted: '#666666',
51
+ },
52
+ })),
53
+ }))
54
+
55
+ // Mock chalk
56
+ vi.mock('chalk', () => ({
57
+ default: {
58
+ bold: vi.fn((s: string) => s),
59
+ gray: vi.fn((s: string) => s),
60
+ dim: vi.fn((s: string) => s),
61
+ hex: vi.fn(() => (s: string) => s),
62
+ },
63
+ }))
64
+
65
+ import fs from 'fs'
66
+ import { getConfig, CONFIG_DIR } from '../config.js'
67
+ import { sshExec, getRemote } from '../remote.js'
68
+
69
+ // 获取 mock 函数引用
70
+ const mockFs = vi.mocked(fs)
71
+ const mockGetConfig = vi.mocked(getConfig)
72
+ const mockSshExec = vi.mocked(sshExec)
73
+ const mockGetRemote = vi.mocked(getRemote)
74
+
75
+ // 模块状态重置辅助
76
+ async function resetRemoteHistoryModule() {
77
+ vi.resetModules()
78
+ return await import('../remote-history.js')
79
+ }
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks()
83
+ mockGetConfig.mockReturnValue({
84
+ commandHistoryLimit: 10,
85
+ shellHistoryLimit: 15,
86
+ } as any)
87
+ mockFs.existsSync.mockReturnValue(true)
88
+ mockFs.writeFileSync.mockImplementation(() => {})
89
+ mockGetRemote.mockReturnValue({
90
+ name: 'server1',
91
+ host: '192.168.1.100',
92
+ user: 'root',
93
+ } as any)
94
+ })
95
+
96
+ afterEach(() => {
97
+ vi.restoreAllMocks()
98
+ })
99
+
100
+ // ============================================================================
101
+ // getRemoteHistory 测试
102
+ // ============================================================================
103
+
104
+ describe('getRemoteHistory', () => {
105
+ it('应该返回远程命令历史数组', async () => {
106
+ const mockHistory = [
107
+ {
108
+ userPrompt: '检查磁盘',
109
+ command: 'df -h',
110
+ executed: true,
111
+ exitCode: 0,
112
+ output: '',
113
+ timestamp: '2024-01-01T00:00:00.000Z',
114
+ },
115
+ ]
116
+ mockFs.existsSync.mockReturnValue(true)
117
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockHistory))
118
+
119
+ const { getRemoteHistory } = await resetRemoteHistoryModule()
120
+ const history = getRemoteHistory('server1')
121
+
122
+ expect(history).toHaveLength(1)
123
+ expect(history[0].command).toBe('df -h')
124
+ })
125
+
126
+ it('文件不存在时应该返回空数组', async () => {
127
+ mockFs.existsSync.mockReturnValue(false)
128
+
129
+ const { getRemoteHistory } = await resetRemoteHistoryModule()
130
+ const history = getRemoteHistory('server1')
131
+
132
+ expect(history).toEqual([])
133
+ })
134
+
135
+ it('JSON 损坏时应该返回空数组', async () => {
136
+ mockFs.existsSync.mockReturnValue(true)
137
+ mockFs.readFileSync.mockReturnValue('{invalid json')
138
+
139
+ const { getRemoteHistory } = await resetRemoteHistoryModule()
140
+ const history = getRemoteHistory('server1')
141
+
142
+ expect(history).toEqual([])
143
+ })
144
+ })
145
+
146
+ // ============================================================================
147
+ // addRemoteHistory 测试
148
+ // ============================================================================
149
+
150
+ describe('addRemoteHistory', () => {
151
+ it('应该添加远程命令历史记录', async () => {
152
+ mockFs.existsSync.mockReturnValue(false) // 历史文件不存在
153
+
154
+ let writtenContent: string = ''
155
+ mockFs.writeFileSync.mockImplementation((path: any, content: any) => {
156
+ writtenContent = content
157
+ })
158
+
159
+ const { addRemoteHistory } = await resetRemoteHistoryModule()
160
+ addRemoteHistory('server1', {
161
+ userPrompt: '检查磁盘',
162
+ command: 'df -h',
163
+ executed: true,
164
+ exitCode: 0,
165
+ output: 'Filesystem Size Used',
166
+ })
167
+
168
+ const saved = JSON.parse(writtenContent)
169
+ expect(saved).toHaveLength(1)
170
+ expect(saved[0].command).toBe('df -h')
171
+ expect(saved[0].timestamp).toBeDefined()
172
+ })
173
+
174
+ it('应该创建服务器目录(如果不存在)', async () => {
175
+ mockFs.existsSync.mockReturnValue(false)
176
+
177
+ const { addRemoteHistory } = await resetRemoteHistoryModule()
178
+ addRemoteHistory('server1', {
179
+ userPrompt: '测试',
180
+ command: 'test',
181
+ executed: true,
182
+ exitCode: 0,
183
+ output: '',
184
+ })
185
+
186
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
187
+ expect.stringContaining('server1'),
188
+ { recursive: true }
189
+ )
190
+ })
191
+
192
+ it('应该限制历史数量(commandHistoryLimit)', async () => {
193
+ mockGetConfig.mockReturnValue({ commandHistoryLimit: 2 } as any)
194
+
195
+ const existingHistory = [
196
+ { userPrompt: '1', command: 'c1', executed: true, exitCode: 0, output: '', timestamp: '2024-01-01' },
197
+ { userPrompt: '2', command: 'c2', executed: true, exitCode: 0, output: '', timestamp: '2024-01-02' },
198
+ ]
199
+ mockFs.existsSync.mockReturnValue(true)
200
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(existingHistory))
201
+
202
+ let writtenContent: string = ''
203
+ mockFs.writeFileSync.mockImplementation((path: any, content: any) => {
204
+ writtenContent = content
205
+ })
206
+
207
+ const { addRemoteHistory } = await resetRemoteHistoryModule()
208
+ addRemoteHistory('server1', {
209
+ userPrompt: '3',
210
+ command: 'c3',
211
+ executed: true,
212
+ exitCode: 0,
213
+ output: '',
214
+ })
215
+
216
+ const saved = JSON.parse(writtenContent)
217
+ expect(saved).toHaveLength(2)
218
+ expect(saved[0].command).toBe('c2') // 最早的被删除
219
+ expect(saved[1].command).toBe('c3')
220
+ })
221
+
222
+ it('应该记录 userModified 和 aiGeneratedCommand', async () => {
223
+ mockFs.existsSync.mockReturnValue(false)
224
+
225
+ let writtenContent: string = ''
226
+ mockFs.writeFileSync.mockImplementation((path: any, content: any) => {
227
+ writtenContent = content
228
+ })
229
+
230
+ const { addRemoteHistory } = await resetRemoteHistoryModule()
231
+ addRemoteHistory('server1', {
232
+ userPrompt: '检查磁盘',
233
+ command: 'df -h /home',
234
+ aiGeneratedCommand: 'df -h',
235
+ userModified: true,
236
+ executed: true,
237
+ exitCode: 0,
238
+ output: '',
239
+ })
240
+
241
+ const saved = JSON.parse(writtenContent)
242
+ expect(saved[0].userModified).toBe(true)
243
+ expect(saved[0].aiGeneratedCommand).toBe('df -h')
244
+ expect(saved[0].command).toBe('df -h /home')
245
+ })
246
+ })
247
+
248
+ // ============================================================================
249
+ // clearRemoteHistory 测试
250
+ // ============================================================================
251
+
252
+ describe('clearRemoteHistory', () => {
253
+ it('应该删除历史文件', async () => {
254
+ mockFs.existsSync.mockReturnValue(true)
255
+
256
+ const { clearRemoteHistory } = await resetRemoteHistoryModule()
257
+ clearRemoteHistory('server1')
258
+
259
+ expect(mockFs.unlinkSync).toHaveBeenCalled()
260
+ })
261
+
262
+ it('文件不存在时应该不报错', async () => {
263
+ mockFs.existsSync.mockReturnValue(false)
264
+
265
+ const { clearRemoteHistory } = await resetRemoteHistoryModule()
266
+
267
+ expect(() => clearRemoteHistory('server1')).not.toThrow()
268
+ expect(mockFs.unlinkSync).not.toHaveBeenCalled()
269
+ })
270
+ })
271
+
272
+ // ============================================================================
273
+ // formatRemoteHistoryForAI 测试
274
+ // ============================================================================
275
+
276
+ describe('formatRemoteHistoryForAI', () => {
277
+ it('应该格式化远程命令历史供 AI 使用', async () => {
278
+ const history = [
279
+ {
280
+ userPrompt: '检查磁盘',
281
+ command: 'df -h',
282
+ executed: true,
283
+ exitCode: 0,
284
+ output: '',
285
+ timestamp: '2024-01-01',
286
+ },
287
+ ]
288
+ mockFs.existsSync.mockReturnValue(true)
289
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
290
+
291
+ const { formatRemoteHistoryForAI } = await resetRemoteHistoryModule()
292
+ const formatted = formatRemoteHistoryForAI('server1')
293
+
294
+ expect(formatted).toContain('检查磁盘')
295
+ expect(formatted).toContain('df -h')
296
+ expect(formatted).toContain('✓')
297
+ })
298
+
299
+ it('空历史应该返回空字符串', async () => {
300
+ mockFs.existsSync.mockReturnValue(true)
301
+ mockFs.readFileSync.mockReturnValue(JSON.stringify([]))
302
+
303
+ const { formatRemoteHistoryForAI } = await resetRemoteHistoryModule()
304
+ const formatted = formatRemoteHistoryForAI('server1')
305
+
306
+ expect(formatted).toBe('')
307
+ })
308
+
309
+ it('失败命令应该显示退出码', async () => {
310
+ const history = [
311
+ {
312
+ userPrompt: '测试',
313
+ command: 'false',
314
+ executed: true,
315
+ exitCode: 1,
316
+ output: '',
317
+ timestamp: '2024-01-01',
318
+ },
319
+ ]
320
+ mockFs.existsSync.mockReturnValue(true)
321
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
322
+
323
+ const { formatRemoteHistoryForAI } = await resetRemoteHistoryModule()
324
+ const formatted = formatRemoteHistoryForAI('server1')
325
+
326
+ expect(formatted).toContain('✗')
327
+ expect(formatted).toContain('退出码:1')
328
+ })
329
+
330
+ it('builtin 命令应该标记未执行', async () => {
331
+ const history = [
332
+ {
333
+ userPrompt: '删除文件',
334
+ command: 'rm -rf *',
335
+ executed: false,
336
+ exitCode: null,
337
+ output: '',
338
+ reason: 'builtin',
339
+ timestamp: '2024-01-01',
340
+ },
341
+ ]
342
+ mockFs.existsSync.mockReturnValue(true)
343
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
344
+
345
+ const { formatRemoteHistoryForAI } = await resetRemoteHistoryModule()
346
+ const formatted = formatRemoteHistoryForAI('server1')
347
+
348
+ expect(formatted).toContain('builtin')
349
+ expect(formatted).toContain('未执行')
350
+ })
351
+
352
+ it('用户修改的命令应该显示 AI 生成和用户修改', async () => {
353
+ const history = [
354
+ {
355
+ userPrompt: '检查磁盘',
356
+ command: 'df -h /home',
357
+ aiGeneratedCommand: 'df -h',
358
+ userModified: true,
359
+ executed: true,
360
+ exitCode: 0,
361
+ output: '',
362
+ timestamp: '2024-01-01',
363
+ },
364
+ ]
365
+ mockFs.existsSync.mockReturnValue(true)
366
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
367
+
368
+ const { formatRemoteHistoryForAI } = await resetRemoteHistoryModule()
369
+ const formatted = formatRemoteHistoryForAI('server1')
370
+
371
+ expect(formatted).toContain('AI 生成')
372
+ expect(formatted).toContain('df -h')
373
+ expect(formatted).toContain('用户修改')
374
+ expect(formatted).toContain('df -h /home')
375
+ })
376
+ })
377
+
378
+ // ============================================================================
379
+ // formatRemoteShellHistoryForAI 测试
380
+ // ============================================================================
381
+
382
+ describe('formatRemoteShellHistoryForAI', () => {
383
+ it('应该格式化远程 Shell 历史供 AI 使用', async () => {
384
+ const items = [
385
+ { cmd: 'ls -la', exit: 0, time: '2024-01-01' },
386
+ { cmd: 'cat /etc/hosts', exit: 0, time: '2024-01-01' },
387
+ ]
388
+
389
+ const { formatRemoteShellHistoryForAI } = await resetRemoteHistoryModule()
390
+ const formatted = formatRemoteShellHistoryForAI(items)
391
+
392
+ expect(formatted).toContain('ls -la')
393
+ expect(formatted).toContain('cat /etc/hosts')
394
+ expect(formatted).toContain('✓')
395
+ })
396
+
397
+ it('空历史应该返回空字符串', async () => {
398
+ const { formatRemoteShellHistoryForAI } = await resetRemoteHistoryModule()
399
+ const formatted = formatRemoteShellHistoryForAI([])
400
+
401
+ expect(formatted).toBe('')
402
+ })
403
+
404
+ it('失败命令应该显示退出码', async () => {
405
+ const items = [{ cmd: 'invalid-cmd', exit: 127, time: '2024-01-01' }]
406
+
407
+ const { formatRemoteShellHistoryForAI } = await resetRemoteHistoryModule()
408
+ const formatted = formatRemoteShellHistoryForAI(items)
409
+
410
+ expect(formatted).toContain('✗')
411
+ expect(formatted).toContain('退出码:127')
412
+ })
413
+ })
414
+
415
+ // ============================================================================
416
+ // fetchRemoteShellHistory 测试
417
+ // ============================================================================
418
+
419
+ // ============================================================================
420
+ // displayRemoteHistory 测试
421
+ // ============================================================================
422
+
423
+ describe('displayRemoteHistory', () => {
424
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>
425
+
426
+ beforeEach(() => {
427
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
428
+ })
429
+
430
+ afterEach(() => {
431
+ consoleLogSpy.mockRestore()
432
+ })
433
+
434
+ it('应该显示远程命令历史', async () => {
435
+ const history = [
436
+ {
437
+ userPrompt: '检查磁盘',
438
+ command: 'df -h',
439
+ executed: true,
440
+ exitCode: 0,
441
+ output: '',
442
+ timestamp: '2024-01-01T00:00:00.000Z',
443
+ },
444
+ ]
445
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
446
+
447
+ const { displayRemoteHistory } = await resetRemoteHistoryModule()
448
+ displayRemoteHistory('server1')
449
+
450
+ expect(consoleLogSpy).toHaveBeenCalled()
451
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
452
+ expect(allCalls).toContain('server1')
453
+ expect(allCalls).toContain('df -h')
454
+ })
455
+
456
+ it('服务器不存在时应该显示错误', async () => {
457
+ mockGetRemote.mockReturnValue(null)
458
+
459
+ const { displayRemoteHistory } = await resetRemoteHistoryModule()
460
+ displayRemoteHistory('nonexistent')
461
+
462
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
463
+ expect(allCalls).toContain('不存在')
464
+ })
465
+
466
+ it('空历史时应该显示提示信息', async () => {
467
+ mockFs.readFileSync.mockReturnValue(JSON.stringify([]))
468
+
469
+ const { displayRemoteHistory } = await resetRemoteHistoryModule()
470
+ displayRemoteHistory('server1')
471
+
472
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
473
+ expect(allCalls).toContain('暂无命令历史')
474
+ })
475
+
476
+ it('应该显示成功和失败的命令状态', async () => {
477
+ const history = [
478
+ {
479
+ userPrompt: '成功命令',
480
+ command: 'ls',
481
+ executed: true,
482
+ exitCode: 0,
483
+ output: '',
484
+ timestamp: '2024-01-01',
485
+ },
486
+ {
487
+ userPrompt: '失败命令',
488
+ command: 'fail',
489
+ executed: true,
490
+ exitCode: 1,
491
+ output: '',
492
+ timestamp: '2024-01-02',
493
+ },
494
+ ]
495
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
496
+
497
+ const { displayRemoteHistory } = await resetRemoteHistoryModule()
498
+ displayRemoteHistory('server1')
499
+
500
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
501
+ expect(allCalls).toContain('✓')
502
+ expect(allCalls).toContain('✗')
503
+ expect(allCalls).toContain('退出码:1')
504
+ })
505
+
506
+ it('应该显示用户修改的命令', async () => {
507
+ const history = [
508
+ {
509
+ userPrompt: '检查磁盘',
510
+ command: 'df -h /home',
511
+ aiGeneratedCommand: 'df -h',
512
+ userModified: true,
513
+ executed: true,
514
+ exitCode: 0,
515
+ output: '',
516
+ timestamp: '2024-01-01',
517
+ },
518
+ ]
519
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
520
+
521
+ const { displayRemoteHistory } = await resetRemoteHistoryModule()
522
+ displayRemoteHistory('server1')
523
+
524
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
525
+ expect(allCalls).toContain('AI 生成')
526
+ expect(allCalls).toContain('用户修改')
527
+ expect(allCalls).toContain('已修改')
528
+ })
529
+
530
+ it('未执行的命令应该显示为未执行状态', async () => {
531
+ const history = [
532
+ {
533
+ userPrompt: '测试',
534
+ command: 'test',
535
+ executed: false,
536
+ exitCode: null,
537
+ output: '',
538
+ timestamp: '2024-01-01',
539
+ },
540
+ ]
541
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(history))
542
+
543
+ const { displayRemoteHistory } = await resetRemoteHistoryModule()
544
+ displayRemoteHistory('server1')
545
+
546
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
547
+ expect(allCalls).toContain('未执行')
548
+ })
549
+ })
550
+
551
+ // ============================================================================
552
+ // displayRemoteShellHistory 测试
553
+ // ============================================================================
554
+
555
+ describe('displayRemoteShellHistory', () => {
556
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>
557
+
558
+ beforeEach(() => {
559
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
560
+ })
561
+
562
+ afterEach(() => {
563
+ consoleLogSpy.mockRestore()
564
+ })
565
+
566
+ it('应该显示远程 Shell 历史', async () => {
567
+ const shellHistory = [
568
+ '{"cmd":"ls -la","exit":0,"time":"2024-01-01"}',
569
+ '{"cmd":"pwd","exit":0,"time":"2024-01-02"}',
570
+ ]
571
+ mockSshExec.mockResolvedValue({
572
+ stdout: shellHistory.join('\n'),
573
+ stderr: '',
574
+ exitCode: 0,
575
+ output: shellHistory.join('\n'),
576
+ })
577
+
578
+ const { displayRemoteShellHistory } = await resetRemoteHistoryModule()
579
+ await displayRemoteShellHistory('server1')
580
+
581
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
582
+ expect(allCalls).toContain('ls -la')
583
+ expect(allCalls).toContain('pwd')
584
+ })
585
+
586
+ it('服务器不存在时应该显示错误', async () => {
587
+ mockGetRemote.mockReturnValue(null)
588
+
589
+ const { displayRemoteShellHistory } = await resetRemoteHistoryModule()
590
+ await displayRemoteShellHistory('nonexistent')
591
+
592
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
593
+ expect(allCalls).toContain('不存在')
594
+ })
595
+
596
+ it('空历史时应该显示提示安装 hook', async () => {
597
+ mockSshExec.mockResolvedValue({
598
+ stdout: '',
599
+ stderr: '',
600
+ exitCode: 0,
601
+ output: '',
602
+ })
603
+
604
+ const { displayRemoteShellHistory } = await resetRemoteHistoryModule()
605
+ await displayRemoteShellHistory('server1')
606
+
607
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
608
+ expect(allCalls).toContain('暂无 shell 历史')
609
+ expect(allCalls).toContain('hook')
610
+ })
611
+
612
+ it('连接失败但有缓存时应该显示缓存内容', async () => {
613
+ mockSshExec.mockRejectedValue(new Error('Connection refused'))
614
+ // 本地有缓存
615
+ const cachedHistory = '{"cmd":"cached-cmd","exit":0,"time":"2024-01-01"}'
616
+ mockFs.existsSync.mockReturnValue(true)
617
+ mockFs.readFileSync.mockReturnValue(cachedHistory)
618
+
619
+ const { displayRemoteShellHistory } = await resetRemoteHistoryModule()
620
+ await displayRemoteShellHistory('server1')
621
+
622
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
623
+ // 应该显示缓存的命令
624
+ expect(allCalls).toContain('cached-cmd')
625
+ })
626
+
627
+ it('应该显示成功和失败的命令状态', async () => {
628
+ const shellHistory = [
629
+ '{"cmd":"success","exit":0,"time":"2024-01-01"}',
630
+ '{"cmd":"fail","exit":1,"time":"2024-01-02"}',
631
+ ]
632
+ mockSshExec.mockResolvedValue({
633
+ stdout: shellHistory.join('\n'),
634
+ stderr: '',
635
+ exitCode: 0,
636
+ output: shellHistory.join('\n'),
637
+ })
638
+
639
+ const { displayRemoteShellHistory } = await resetRemoteHistoryModule()
640
+ await displayRemoteShellHistory('server1')
641
+
642
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
643
+ expect(allCalls).toContain('✓')
644
+ expect(allCalls).toContain('✗')
645
+ })
646
+ })
647
+
648
+ // ============================================================================
649
+ // clearRemoteShellHistory 测试
650
+ // ============================================================================
651
+
652
+ describe('clearRemoteShellHistory', () => {
653
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>
654
+
655
+ beforeEach(() => {
656
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
657
+ })
658
+
659
+ afterEach(() => {
660
+ consoleLogSpy.mockRestore()
661
+ })
662
+
663
+ it('应该清空远程 Shell 历史', async () => {
664
+ mockSshExec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0, output: '' })
665
+ mockFs.existsSync.mockReturnValue(true)
666
+
667
+ const { clearRemoteShellHistory } = await resetRemoteHistoryModule()
668
+ await clearRemoteShellHistory('server1')
669
+
670
+ // 验证执行了远程删除命令
671
+ expect(mockSshExec).toHaveBeenCalledWith(
672
+ 'server1',
673
+ 'rm -f ~/.please/shell_history.jsonl',
674
+ expect.anything()
675
+ )
676
+ // 验证删除了本地缓存
677
+ expect(mockFs.unlinkSync).toHaveBeenCalled()
678
+ // 验证显示成功消息
679
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
680
+ expect(allCalls).toContain('已清空')
681
+ })
682
+
683
+ it('服务器不存在时应该显示错误', async () => {
684
+ mockGetRemote.mockReturnValue(null)
685
+
686
+ const { clearRemoteShellHistory } = await resetRemoteHistoryModule()
687
+ await clearRemoteShellHistory('nonexistent')
688
+
689
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
690
+ expect(allCalls).toContain('不存在')
691
+ })
692
+
693
+ it('本地缓存不存在时不应该报错', async () => {
694
+ mockSshExec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0, output: '' })
695
+ mockFs.existsSync.mockReturnValue(false)
696
+
697
+ const { clearRemoteShellHistory } = await resetRemoteHistoryModule()
698
+ await clearRemoteShellHistory('server1')
699
+
700
+ expect(mockFs.unlinkSync).not.toHaveBeenCalled()
701
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
702
+ expect(allCalls).toContain('已清空')
703
+ })
704
+
705
+ it('SSH 执行失败时应该显示错误信息', async () => {
706
+ mockSshExec.mockRejectedValue(new Error('Permission denied'))
707
+
708
+ const { clearRemoteShellHistory } = await resetRemoteHistoryModule()
709
+ await clearRemoteShellHistory('server1')
710
+
711
+ const allCalls = consoleLogSpy.mock.calls.map(call => call[0]).join('\n')
712
+ expect(allCalls).toContain('无法清空')
713
+ expect(allCalls).toContain('Permission denied')
714
+ })
715
+ })
716
+
717
+ // ============================================================================
718
+ // fetchRemoteShellHistory 测试
719
+ // ============================================================================
720
+
721
+ describe('fetchRemoteShellHistory', () => {
722
+ it('应该从远程服务器获取 Shell 历史', async () => {
723
+ const shellHistoryLines = [
724
+ '{"cmd":"ls -la","exit":0,"time":"2024-01-01"}',
725
+ '{"cmd":"pwd","exit":0,"time":"2024-01-01"}',
726
+ ]
727
+ mockSshExec.mockResolvedValue({
728
+ stdout: shellHistoryLines.join('\n'),
729
+ stderr: '',
730
+ exitCode: 0,
731
+ output: shellHistoryLines.join('\n'),
732
+ })
733
+
734
+ const { fetchRemoteShellHistory } = await resetRemoteHistoryModule()
735
+ const history = await fetchRemoteShellHistory('server1')
736
+
737
+ expect(history).toHaveLength(2)
738
+ expect(history[0].cmd).toBe('ls -la')
739
+ expect(history[1].cmd).toBe('pwd')
740
+ })
741
+
742
+ it('SSH 命令失败时应该返回空数组', async () => {
743
+ mockSshExec.mockResolvedValue({
744
+ stdout: '',
745
+ stderr: 'error',
746
+ exitCode: 1,
747
+ output: 'error',
748
+ })
749
+ // 本地缓存也不存在
750
+ mockFs.existsSync.mockReturnValue(false)
751
+
752
+ const { fetchRemoteShellHistory } = await resetRemoteHistoryModule()
753
+ const history = await fetchRemoteShellHistory('server1')
754
+
755
+ expect(history).toEqual([])
756
+ })
757
+
758
+ it('应该跳过无效的 JSON 行', async () => {
759
+ const shellHistoryLines = [
760
+ '{"cmd":"ls","exit":0,"time":"2024-01-01"}',
761
+ 'invalid json line',
762
+ '{"cmd":"pwd","exit":0,"time":"2024-01-01"}',
763
+ ]
764
+ mockSshExec.mockResolvedValue({
765
+ stdout: shellHistoryLines.join('\n'),
766
+ stderr: '',
767
+ exitCode: 0,
768
+ output: shellHistoryLines.join('\n'),
769
+ })
770
+
771
+ const { fetchRemoteShellHistory } = await resetRemoteHistoryModule()
772
+ const history = await fetchRemoteShellHistory('server1')
773
+
774
+ expect(history).toHaveLength(2)
775
+ })
776
+
777
+ it('连接失败时应该返回本地缓存', async () => {
778
+ mockSshExec.mockRejectedValue(new Error('Connection refused'))
779
+
780
+ // 本地缓存存在
781
+ const cachedHistory = '{"cmd":"cached","exit":0,"time":"2024-01-01"}'
782
+ mockFs.existsSync.mockReturnValue(true)
783
+ mockFs.readFileSync.mockReturnValue(cachedHistory)
784
+
785
+ const { fetchRemoteShellHistory } = await resetRemoteHistoryModule()
786
+ const history = await fetchRemoteShellHistory('server1')
787
+
788
+ expect(history).toHaveLength(1)
789
+ expect(history[0].cmd).toBe('cached')
790
+ })
791
+ })