@yivan-lab/pretty-please 1.4.0 → 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.
- package/README.md +30 -2
- package/bin/pls.tsx +153 -35
- package/dist/bin/pls.js +126 -23
- package/dist/package.json +10 -2
- package/dist/src/__integration__/command-generation.test.d.ts +5 -0
- package/dist/src/__integration__/command-generation.test.js +508 -0
- package/dist/src/__integration__/error-recovery.test.d.ts +5 -0
- package/dist/src/__integration__/error-recovery.test.js +511 -0
- package/dist/src/__integration__/shell-hook-workflow.test.d.ts +5 -0
- package/dist/src/__integration__/shell-hook-workflow.test.js +375 -0
- package/dist/src/__tests__/alias.test.d.ts +5 -0
- package/dist/src/__tests__/alias.test.js +421 -0
- package/dist/src/__tests__/chat-history.test.d.ts +5 -0
- package/dist/src/__tests__/chat-history.test.js +372 -0
- package/dist/src/__tests__/config.test.d.ts +5 -0
- package/dist/src/__tests__/config.test.js +822 -0
- package/dist/src/__tests__/history.test.d.ts +5 -0
- package/dist/src/__tests__/history.test.js +439 -0
- package/dist/src/__tests__/remote-history.test.d.ts +5 -0
- package/dist/src/__tests__/remote-history.test.js +641 -0
- package/dist/src/__tests__/remote.test.d.ts +5 -0
- package/dist/src/__tests__/remote.test.js +689 -0
- package/dist/src/__tests__/shell-hook-install.test.d.ts +5 -0
- package/dist/src/__tests__/shell-hook-install.test.js +413 -0
- package/dist/src/__tests__/shell-hook-remote.test.d.ts +5 -0
- package/dist/src/__tests__/shell-hook-remote.test.js +507 -0
- package/dist/src/__tests__/shell-hook.test.d.ts +5 -0
- package/dist/src/__tests__/shell-hook.test.js +440 -0
- package/dist/src/__tests__/sysinfo.test.d.ts +5 -0
- package/dist/src/__tests__/sysinfo.test.js +572 -0
- package/dist/src/__tests__/system-history.test.d.ts +5 -0
- package/dist/src/__tests__/system-history.test.js +457 -0
- package/dist/src/components/Chat.js +9 -28
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +30 -2
- package/dist/src/mastra-chat.js +6 -3
- package/dist/src/multi-step.js +6 -3
- package/dist/src/project-context.d.ts +22 -0
- package/dist/src/project-context.js +168 -0
- package/dist/src/prompts.d.ts +4 -4
- package/dist/src/prompts.js +23 -6
- package/dist/src/shell-hook.d.ts +13 -0
- package/dist/src/shell-hook.js +163 -33
- package/dist/src/sysinfo.d.ts +38 -9
- package/dist/src/sysinfo.js +245 -21
- package/dist/src/system-history.d.ts +5 -0
- package/dist/src/system-history.js +64 -18
- package/dist/src/ui/__tests__/theme.test.d.ts +5 -0
- package/dist/src/ui/__tests__/theme.test.js +688 -0
- package/dist/src/upgrade.js +3 -0
- package/dist/src/user-preferences.d.ts +44 -0
- package/dist/src/user-preferences.js +147 -0
- package/dist/src/utils/__tests__/platform-capabilities.test.d.ts +5 -0
- package/dist/src/utils/__tests__/platform-capabilities.test.js +214 -0
- package/dist/src/utils/__tests__/platform-exec.test.d.ts +5 -0
- package/dist/src/utils/__tests__/platform-exec.test.js +212 -0
- package/dist/src/utils/__tests__/platform-shell.test.d.ts +5 -0
- package/dist/src/utils/__tests__/platform-shell.test.js +300 -0
- package/dist/src/utils/__tests__/platform.test.d.ts +5 -0
- package/dist/src/utils/__tests__/platform.test.js +137 -0
- package/dist/src/utils/platform.d.ts +88 -0
- package/dist/src/utils/platform.js +331 -0
- package/package.json +10 -2
- package/src/__integration__/command-generation.test.ts +602 -0
- package/src/__integration__/error-recovery.test.ts +620 -0
- package/src/__integration__/shell-hook-workflow.test.ts +457 -0
- package/src/__tests__/alias.test.ts +545 -0
- package/src/__tests__/chat-history.test.ts +462 -0
- package/src/__tests__/config.test.ts +1043 -0
- package/src/__tests__/history.test.ts +538 -0
- package/src/__tests__/remote-history.test.ts +791 -0
- package/src/__tests__/remote.test.ts +866 -0
- package/src/__tests__/shell-hook-install.test.ts +510 -0
- package/src/__tests__/shell-hook-remote.test.ts +679 -0
- package/src/__tests__/shell-hook.test.ts +564 -0
- package/src/__tests__/sysinfo.test.ts +718 -0
- package/src/__tests__/system-history.test.ts +608 -0
- package/src/components/Chat.tsx +10 -37
- package/src/config.ts +29 -2
- package/src/mastra-chat.ts +8 -3
- package/src/multi-step.ts +7 -2
- package/src/project-context.ts +191 -0
- package/src/prompts.ts +26 -5
- package/src/shell-hook.ts +179 -33
- package/src/sysinfo.ts +326 -25
- package/src/system-history.ts +67 -14
- package/src/ui/__tests__/theme.test.ts +869 -0
- package/src/upgrade.ts +5 -0
- package/src/user-preferences.ts +178 -0
- package/src/utils/__tests__/platform-capabilities.test.ts +265 -0
- package/src/utils/__tests__/platform-exec.test.ts +278 -0
- package/src/utils/__tests__/platform-shell.test.ts +353 -0
- package/src/utils/__tests__/platform.test.ts +170 -0
- package/src/utils/platform.ts +431 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程执行器模块测试
|
|
3
|
+
* 测试 SSH 连接管理、远程命令执行、系统信息采集等功能
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
// Mock child_process 模块
|
|
7
|
+
vi.mock('child_process', () => ({
|
|
8
|
+
spawn: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
// Mock fs 模块
|
|
11
|
+
vi.mock('fs', () => ({
|
|
12
|
+
default: {
|
|
13
|
+
existsSync: vi.fn(),
|
|
14
|
+
readFileSync: vi.fn(),
|
|
15
|
+
writeFileSync: vi.fn(),
|
|
16
|
+
mkdirSync: vi.fn(),
|
|
17
|
+
rmSync: vi.fn(),
|
|
18
|
+
unlinkSync: vi.fn(),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
// Mock os 模块
|
|
22
|
+
vi.mock('os', () => ({
|
|
23
|
+
default: {
|
|
24
|
+
homedir: vi.fn(() => '/home/testuser'),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
// Mock config 模块
|
|
28
|
+
vi.mock('../config.js', () => ({
|
|
29
|
+
getConfig: vi.fn(() => ({
|
|
30
|
+
remotes: {},
|
|
31
|
+
defaultRemote: undefined,
|
|
32
|
+
})),
|
|
33
|
+
saveConfig: vi.fn(),
|
|
34
|
+
CONFIG_DIR: '/home/testuser/.please',
|
|
35
|
+
}));
|
|
36
|
+
// Mock theme 模块
|
|
37
|
+
vi.mock('../ui/theme.js', () => ({
|
|
38
|
+
getCurrentTheme: vi.fn(() => ({
|
|
39
|
+
primary: '#007acc',
|
|
40
|
+
secondary: '#6c757d',
|
|
41
|
+
success: '#4caf50',
|
|
42
|
+
error: '#f44336',
|
|
43
|
+
warning: '#ff9800',
|
|
44
|
+
text: {
|
|
45
|
+
muted: '#666666',
|
|
46
|
+
},
|
|
47
|
+
})),
|
|
48
|
+
}));
|
|
49
|
+
// Mock chalk
|
|
50
|
+
vi.mock('chalk', () => ({
|
|
51
|
+
default: {
|
|
52
|
+
bold: vi.fn((s) => s),
|
|
53
|
+
gray: vi.fn((s) => s),
|
|
54
|
+
hex: vi.fn(() => (s) => s),
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
import { spawn } from 'child_process';
|
|
58
|
+
import fs from 'fs';
|
|
59
|
+
import os from 'os';
|
|
60
|
+
import { getConfig, saveConfig } from '../config.js';
|
|
61
|
+
// 获取 mock 函数引用
|
|
62
|
+
const mockSpawn = vi.mocked(spawn);
|
|
63
|
+
const mockFs = vi.mocked(fs);
|
|
64
|
+
const mockOs = vi.mocked(os);
|
|
65
|
+
const mockGetConfig = vi.mocked(getConfig);
|
|
66
|
+
const mockSaveConfig = vi.mocked(saveConfig);
|
|
67
|
+
// 创建 mock child process
|
|
68
|
+
function createMockChildProcess(options) {
|
|
69
|
+
const stdoutCallbacks = [];
|
|
70
|
+
const stderrCallbacks = [];
|
|
71
|
+
const closeCallbacks = [];
|
|
72
|
+
const errorCallbacks = [];
|
|
73
|
+
const mockChild = {
|
|
74
|
+
stdout: {
|
|
75
|
+
on: vi.fn((event, cb) => {
|
|
76
|
+
if (event === 'data')
|
|
77
|
+
stdoutCallbacks.push(cb);
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
stderr: {
|
|
81
|
+
on: vi.fn((event, cb) => {
|
|
82
|
+
if (event === 'data')
|
|
83
|
+
stderrCallbacks.push(cb);
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
stdin: {
|
|
87
|
+
write: vi.fn(),
|
|
88
|
+
end: vi.fn(),
|
|
89
|
+
},
|
|
90
|
+
on: vi.fn((event, cb) => {
|
|
91
|
+
if (event === 'close')
|
|
92
|
+
closeCallbacks.push(cb);
|
|
93
|
+
if (event === 'error')
|
|
94
|
+
errorCallbacks.push(cb);
|
|
95
|
+
}),
|
|
96
|
+
kill: vi.fn(),
|
|
97
|
+
};
|
|
98
|
+
// 模拟异步执行
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
if (options.error) {
|
|
101
|
+
errorCallbacks.forEach(cb => cb(options.error));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
if (options.stdout) {
|
|
105
|
+
stdoutCallbacks.forEach(cb => cb(Buffer.from(options.stdout)));
|
|
106
|
+
}
|
|
107
|
+
if (options.stderr) {
|
|
108
|
+
stderrCallbacks.forEach(cb => cb(Buffer.from(options.stderr)));
|
|
109
|
+
}
|
|
110
|
+
closeCallbacks.forEach(cb => cb(options.exitCode ?? 0));
|
|
111
|
+
}
|
|
112
|
+
}, 10);
|
|
113
|
+
return mockChild;
|
|
114
|
+
}
|
|
115
|
+
// 模块状态重置辅助
|
|
116
|
+
async function resetRemoteModule() {
|
|
117
|
+
vi.resetModules();
|
|
118
|
+
return await import('../remote.js');
|
|
119
|
+
}
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.clearAllMocks();
|
|
122
|
+
mockOs.homedir.mockReturnValue('/home/testuser');
|
|
123
|
+
mockGetConfig.mockReturnValue({
|
|
124
|
+
remotes: {},
|
|
125
|
+
defaultRemote: undefined,
|
|
126
|
+
});
|
|
127
|
+
mockSaveConfig.mockImplementation(() => { });
|
|
128
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
129
|
+
mockFs.writeFileSync.mockImplementation(() => { });
|
|
130
|
+
mockFs.mkdirSync.mockImplementation(() => undefined);
|
|
131
|
+
});
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
vi.restoreAllMocks();
|
|
134
|
+
});
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// 远程服务器管理测试
|
|
137
|
+
// ============================================================================
|
|
138
|
+
describe('getRemotes', () => {
|
|
139
|
+
it('应该返回所有远程服务器配置', async () => {
|
|
140
|
+
mockGetConfig.mockReturnValue({
|
|
141
|
+
remotes: {
|
|
142
|
+
server1: { host: '192.168.1.100', user: 'root', port: 22 },
|
|
143
|
+
server2: { host: '192.168.1.101', user: 'admin', port: 2222 },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const { getRemotes } = await resetRemoteModule();
|
|
147
|
+
const remotes = getRemotes();
|
|
148
|
+
expect(Object.keys(remotes)).toHaveLength(2);
|
|
149
|
+
expect(remotes.server1.host).toBe('192.168.1.100');
|
|
150
|
+
expect(remotes.server2.port).toBe(2222);
|
|
151
|
+
});
|
|
152
|
+
it('无远程服务器时应该返回空对象', async () => {
|
|
153
|
+
mockGetConfig.mockReturnValue({});
|
|
154
|
+
const { getRemotes } = await resetRemoteModule();
|
|
155
|
+
const remotes = getRemotes();
|
|
156
|
+
expect(remotes).toEqual({});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('getRemote', () => {
|
|
160
|
+
it('应该返回指定的远程服务器配置', async () => {
|
|
161
|
+
mockGetConfig.mockReturnValue({
|
|
162
|
+
remotes: {
|
|
163
|
+
myserver: { host: '10.0.0.1', user: 'deploy', port: 22 },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const { getRemote } = await resetRemoteModule();
|
|
167
|
+
const remote = getRemote('myserver');
|
|
168
|
+
expect(remote).not.toBeNull();
|
|
169
|
+
expect(remote?.host).toBe('10.0.0.1');
|
|
170
|
+
expect(remote?.user).toBe('deploy');
|
|
171
|
+
});
|
|
172
|
+
it('服务器不存在时应该返回 null', async () => {
|
|
173
|
+
mockGetConfig.mockReturnValue({ remotes: {} });
|
|
174
|
+
const { getRemote } = await resetRemoteModule();
|
|
175
|
+
const remote = getRemote('nonexistent');
|
|
176
|
+
expect(remote).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// addRemote 测试
|
|
181
|
+
// ============================================================================
|
|
182
|
+
describe('addRemote', () => {
|
|
183
|
+
describe('user@host:port 格式解析', () => {
|
|
184
|
+
it('应该解析 user@host 格式', async () => {
|
|
185
|
+
const { addRemote } = await resetRemoteModule();
|
|
186
|
+
addRemote('test', 'root@192.168.1.100');
|
|
187
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
188
|
+
expect(savedConfig.remotes.test.user).toBe('root');
|
|
189
|
+
expect(savedConfig.remotes.test.host).toBe('192.168.1.100');
|
|
190
|
+
expect(savedConfig.remotes.test.port).toBe(22);
|
|
191
|
+
});
|
|
192
|
+
it('应该解析 user@host:port 格式', async () => {
|
|
193
|
+
const { addRemote } = await resetRemoteModule();
|
|
194
|
+
addRemote('test', 'admin@server.com:2222');
|
|
195
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
196
|
+
expect(savedConfig.remotes.test.user).toBe('admin');
|
|
197
|
+
expect(savedConfig.remotes.test.host).toBe('server.com');
|
|
198
|
+
expect(savedConfig.remotes.test.port).toBe(2222);
|
|
199
|
+
});
|
|
200
|
+
it('应该正确处理 IPv6 地址', async () => {
|
|
201
|
+
const { addRemote } = await resetRemoteModule();
|
|
202
|
+
addRemote('test', 'root@::1:22');
|
|
203
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
204
|
+
expect(savedConfig.remotes.test.host).toBe('::1');
|
|
205
|
+
expect(savedConfig.remotes.test.port).toBe(22);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('验证', () => {
|
|
209
|
+
it('空名称应该抛出错误', async () => {
|
|
210
|
+
const { addRemote } = await resetRemoteModule();
|
|
211
|
+
expect(() => addRemote('', 'root@host'))
|
|
212
|
+
.toThrow('服务器名称不能为空');
|
|
213
|
+
});
|
|
214
|
+
it('无效名称字符应该抛出错误', async () => {
|
|
215
|
+
const { addRemote } = await resetRemoteModule();
|
|
216
|
+
expect(() => addRemote('server name', 'root@host'))
|
|
217
|
+
.toThrow('服务器名称只能包含字母、数字、下划线和连字符');
|
|
218
|
+
});
|
|
219
|
+
it('缺少用户名应该抛出错误', async () => {
|
|
220
|
+
const { addRemote } = await resetRemoteModule();
|
|
221
|
+
expect(() => addRemote('test', 'host.com'))
|
|
222
|
+
.toThrow('用户名不能为空');
|
|
223
|
+
});
|
|
224
|
+
it('缺少主机地址应该抛出错误', async () => {
|
|
225
|
+
const { addRemote } = await resetRemoteModule();
|
|
226
|
+
expect(() => addRemote('test', 'root@'))
|
|
227
|
+
.toThrow('主机地址不能为空');
|
|
228
|
+
});
|
|
229
|
+
it('密钥文件不存在应该抛出错误', async () => {
|
|
230
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
231
|
+
if (path.includes('.ssh/nonexistent'))
|
|
232
|
+
return false;
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
235
|
+
const { addRemote } = await resetRemoteModule();
|
|
236
|
+
expect(() => addRemote('test', 'root@host', { key: '~/.ssh/nonexistent' }))
|
|
237
|
+
.toThrow('密钥文件不存在');
|
|
238
|
+
});
|
|
239
|
+
it('服务器已存在应该抛出错误', async () => {
|
|
240
|
+
mockGetConfig.mockReturnValue({
|
|
241
|
+
remotes: {
|
|
242
|
+
existing: { host: 'host', user: 'root', port: 22 },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
const { addRemote } = await resetRemoteModule();
|
|
246
|
+
expect(() => addRemote('existing', 'root@newhost'))
|
|
247
|
+
.toThrow('服务器 "existing" 已存在');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
describe('选项', () => {
|
|
251
|
+
it('应该保存密钥路径', async () => {
|
|
252
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
253
|
+
const { addRemote } = await resetRemoteModule();
|
|
254
|
+
addRemote('test', 'root@host', { key: '~/.ssh/id_rsa' });
|
|
255
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
256
|
+
expect(savedConfig.remotes.test.key).toBe('~/.ssh/id_rsa');
|
|
257
|
+
});
|
|
258
|
+
it('应该保存密码认证标记', async () => {
|
|
259
|
+
const { addRemote } = await resetRemoteModule();
|
|
260
|
+
addRemote('test', 'root@host', { password: true });
|
|
261
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
262
|
+
expect(savedConfig.remotes.test.password).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
it('应该创建数据目录', async () => {
|
|
266
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
267
|
+
const { addRemote } = await resetRemoteModule();
|
|
268
|
+
addRemote('test', 'root@host');
|
|
269
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('test'), { recursive: true });
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// removeRemote 测试
|
|
274
|
+
// ============================================================================
|
|
275
|
+
describe('removeRemote', () => {
|
|
276
|
+
it('应该删除存在的服务器', async () => {
|
|
277
|
+
mockGetConfig.mockReturnValue({
|
|
278
|
+
remotes: {
|
|
279
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
const { removeRemote } = await resetRemoteModule();
|
|
283
|
+
const result = removeRemote('myserver');
|
|
284
|
+
expect(result).toBe(true);
|
|
285
|
+
expect(mockSaveConfig).toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
it('不存在的服务器应该返回 false', async () => {
|
|
288
|
+
mockGetConfig.mockReturnValue({ remotes: {} });
|
|
289
|
+
const { removeRemote } = await resetRemoteModule();
|
|
290
|
+
const result = removeRemote('nonexistent');
|
|
291
|
+
expect(result).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
it('应该删除数据目录', async () => {
|
|
294
|
+
mockGetConfig.mockReturnValue({
|
|
295
|
+
remotes: {
|
|
296
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
300
|
+
const { removeRemote } = await resetRemoteModule();
|
|
301
|
+
removeRemote('myserver');
|
|
302
|
+
expect(mockFs.rmSync).toHaveBeenCalledWith(expect.stringContaining('myserver'), { recursive: true });
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
// ============================================================================
|
|
306
|
+
// 工作目录测试
|
|
307
|
+
// ============================================================================
|
|
308
|
+
describe('setRemoteWorkDir', () => {
|
|
309
|
+
it('应该设置工作目录', async () => {
|
|
310
|
+
mockGetConfig.mockReturnValue({
|
|
311
|
+
remotes: {
|
|
312
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const { setRemoteWorkDir } = await resetRemoteModule();
|
|
316
|
+
setRemoteWorkDir('myserver', '/home/deploy/app');
|
|
317
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
318
|
+
expect(savedConfig.remotes.myserver.workDir).toBe('/home/deploy/app');
|
|
319
|
+
});
|
|
320
|
+
it('应该清除工作目录(传入空字符串或 -)', async () => {
|
|
321
|
+
mockGetConfig.mockReturnValue({
|
|
322
|
+
remotes: {
|
|
323
|
+
myserver: { host: 'host', user: 'root', port: 22, workDir: '/old' },
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
const { setRemoteWorkDir } = await resetRemoteModule();
|
|
327
|
+
setRemoteWorkDir('myserver', '-');
|
|
328
|
+
const savedConfig = mockSaveConfig.mock.calls[0][0];
|
|
329
|
+
expect(savedConfig.remotes.myserver.workDir).toBeUndefined();
|
|
330
|
+
});
|
|
331
|
+
it('服务器不存在应该抛出错误', async () => {
|
|
332
|
+
mockGetConfig.mockReturnValue({ remotes: {} });
|
|
333
|
+
const { setRemoteWorkDir } = await resetRemoteModule();
|
|
334
|
+
expect(() => setRemoteWorkDir('nonexistent', '/path'))
|
|
335
|
+
.toThrow('远程服务器 "nonexistent" 不存在');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
describe('getRemoteWorkDir', () => {
|
|
339
|
+
it('应该返回工作目录', async () => {
|
|
340
|
+
mockGetConfig.mockReturnValue({
|
|
341
|
+
remotes: {
|
|
342
|
+
myserver: { host: 'host', user: 'root', port: 22, workDir: '/home/app' },
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const { getRemoteWorkDir } = await resetRemoteModule();
|
|
346
|
+
const workDir = getRemoteWorkDir('myserver');
|
|
347
|
+
expect(workDir).toBe('/home/app');
|
|
348
|
+
});
|
|
349
|
+
it('无工作目录应该返回 undefined', async () => {
|
|
350
|
+
mockGetConfig.mockReturnValue({
|
|
351
|
+
remotes: {
|
|
352
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const { getRemoteWorkDir } = await resetRemoteModule();
|
|
356
|
+
const workDir = getRemoteWorkDir('myserver');
|
|
357
|
+
expect(workDir).toBeUndefined();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
// ============================================================================
|
|
361
|
+
// sshExec 测试
|
|
362
|
+
// ============================================================================
|
|
363
|
+
describe('sshExec', () => {
|
|
364
|
+
it('服务器不存在应该抛出错误', async () => {
|
|
365
|
+
mockGetConfig.mockReturnValue({ remotes: {} });
|
|
366
|
+
const { sshExec } = await resetRemoteModule();
|
|
367
|
+
await expect(sshExec('nonexistent', 'ls'))
|
|
368
|
+
.rejects.toThrow('远程服务器 "nonexistent" 不存在');
|
|
369
|
+
});
|
|
370
|
+
it('应该执行 SSH 命令并返回结果', async () => {
|
|
371
|
+
mockGetConfig.mockReturnValue({
|
|
372
|
+
remotes: {
|
|
373
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
mockFs.existsSync.mockReturnValue(false); // No ControlMaster socket
|
|
377
|
+
const mockChild = createMockChildProcess({
|
|
378
|
+
stdout: 'command output\n',
|
|
379
|
+
exitCode: 0,
|
|
380
|
+
});
|
|
381
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
382
|
+
const { sshExec } = await resetRemoteModule();
|
|
383
|
+
const result = await sshExec('myserver', 'ls -la');
|
|
384
|
+
expect(result.exitCode).toBe(0);
|
|
385
|
+
expect(result.stdout).toContain('command output');
|
|
386
|
+
});
|
|
387
|
+
it('应该处理命令失败', async () => {
|
|
388
|
+
mockGetConfig.mockReturnValue({
|
|
389
|
+
remotes: {
|
|
390
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
394
|
+
const mockChild = createMockChildProcess({
|
|
395
|
+
stderr: 'command not found',
|
|
396
|
+
exitCode: 127,
|
|
397
|
+
});
|
|
398
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
399
|
+
const { sshExec } = await resetRemoteModule();
|
|
400
|
+
const result = await sshExec('myserver', 'invalid-command');
|
|
401
|
+
expect(result.exitCode).toBe(127);
|
|
402
|
+
expect(result.stderr).toContain('command not found');
|
|
403
|
+
});
|
|
404
|
+
it('应该调用 onStdout 回调', async () => {
|
|
405
|
+
mockGetConfig.mockReturnValue({
|
|
406
|
+
remotes: {
|
|
407
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
411
|
+
const mockChild = createMockChildProcess({
|
|
412
|
+
stdout: 'streaming output',
|
|
413
|
+
exitCode: 0,
|
|
414
|
+
});
|
|
415
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
416
|
+
const stdoutData = [];
|
|
417
|
+
const { sshExec } = await resetRemoteModule();
|
|
418
|
+
await sshExec('myserver', 'ls', {
|
|
419
|
+
onStdout: (data) => stdoutData.push(data),
|
|
420
|
+
});
|
|
421
|
+
expect(stdoutData.length).toBeGreaterThan(0);
|
|
422
|
+
});
|
|
423
|
+
it('应该使用自定义端口', async () => {
|
|
424
|
+
mockGetConfig.mockReturnValue({
|
|
425
|
+
remotes: {
|
|
426
|
+
myserver: { host: 'host', user: 'root', port: 2222 },
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
430
|
+
const mockChild = createMockChildProcess({ stdout: '', exitCode: 0 });
|
|
431
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
432
|
+
const { sshExec } = await resetRemoteModule();
|
|
433
|
+
await sshExec('myserver', 'ls');
|
|
434
|
+
expect(mockSpawn).toHaveBeenCalledWith('ssh', expect.arrayContaining(['-p', '2222']), expect.any(Object));
|
|
435
|
+
});
|
|
436
|
+
it('应该使用密钥文件', async () => {
|
|
437
|
+
mockGetConfig.mockReturnValue({
|
|
438
|
+
remotes: {
|
|
439
|
+
myserver: { host: 'host', user: 'root', port: 22, key: '~/.ssh/mykey' },
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
443
|
+
const mockChild = createMockChildProcess({ stdout: '', exitCode: 0 });
|
|
444
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
445
|
+
const { sshExec } = await resetRemoteModule();
|
|
446
|
+
await sshExec('myserver', 'ls');
|
|
447
|
+
expect(mockSpawn).toHaveBeenCalledWith('ssh', expect.arrayContaining(['-i', '/home/testuser/.ssh/mykey']), expect.any(Object));
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// testRemoteConnection 测试
|
|
452
|
+
// ============================================================================
|
|
453
|
+
describe('testRemoteConnection', () => {
|
|
454
|
+
it('连接成功应该返回 success: true', async () => {
|
|
455
|
+
mockGetConfig.mockReturnValue({
|
|
456
|
+
remotes: {
|
|
457
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
461
|
+
const mockChild = createMockChildProcess({
|
|
462
|
+
stdout: 'pls-connection-test\n',
|
|
463
|
+
exitCode: 0,
|
|
464
|
+
});
|
|
465
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
466
|
+
const { testRemoteConnection } = await resetRemoteModule();
|
|
467
|
+
const result = await testRemoteConnection('myserver');
|
|
468
|
+
expect(result.success).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
it('连接失败应该返回 success: false', async () => {
|
|
471
|
+
mockGetConfig.mockReturnValue({
|
|
472
|
+
remotes: {
|
|
473
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
477
|
+
const mockChild = createMockChildProcess({
|
|
478
|
+
stderr: 'Connection refused',
|
|
479
|
+
exitCode: 255,
|
|
480
|
+
});
|
|
481
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
482
|
+
const { testRemoteConnection } = await resetRemoteModule();
|
|
483
|
+
const result = await testRemoteConnection('myserver');
|
|
484
|
+
expect(result.success).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
// ============================================================================
|
|
488
|
+
// 系统信息采集测试
|
|
489
|
+
// ============================================================================
|
|
490
|
+
describe('getRemoteSysInfo', () => {
|
|
491
|
+
it('应该返回缓存的系统信息', async () => {
|
|
492
|
+
const cachedInfo = {
|
|
493
|
+
os: 'Linux',
|
|
494
|
+
osVersion: '5.4.0',
|
|
495
|
+
shell: 'bash',
|
|
496
|
+
hostname: 'myhost',
|
|
497
|
+
cachedAt: new Date().toISOString(),
|
|
498
|
+
};
|
|
499
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
500
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(cachedInfo));
|
|
501
|
+
const { getRemoteSysInfo } = await resetRemoteModule();
|
|
502
|
+
const info = getRemoteSysInfo('myserver');
|
|
503
|
+
expect(info).not.toBeNull();
|
|
504
|
+
expect(info?.os).toBe('Linux');
|
|
505
|
+
expect(info?.shell).toBe('bash');
|
|
506
|
+
});
|
|
507
|
+
it('缓存不存在应该返回 null', async () => {
|
|
508
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
509
|
+
const { getRemoteSysInfo } = await resetRemoteModule();
|
|
510
|
+
const info = getRemoteSysInfo('myserver');
|
|
511
|
+
expect(info).toBeNull();
|
|
512
|
+
});
|
|
513
|
+
it('JSON 损坏应该返回 null', async () => {
|
|
514
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
515
|
+
mockFs.readFileSync.mockReturnValue('{invalid json');
|
|
516
|
+
const { getRemoteSysInfo } = await resetRemoteModule();
|
|
517
|
+
const info = getRemoteSysInfo('myserver');
|
|
518
|
+
expect(info).toBeNull();
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
describe('collectRemoteSysInfo', () => {
|
|
522
|
+
it('应该使用未过期的缓存', async () => {
|
|
523
|
+
const cachedInfo = {
|
|
524
|
+
os: 'Linux',
|
|
525
|
+
osVersion: '5.4.0',
|
|
526
|
+
shell: 'bash',
|
|
527
|
+
hostname: 'myhost',
|
|
528
|
+
cachedAt: new Date().toISOString(), // 刚刚缓存
|
|
529
|
+
};
|
|
530
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
531
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(cachedInfo));
|
|
532
|
+
const { collectRemoteSysInfo } = await resetRemoteModule();
|
|
533
|
+
const info = await collectRemoteSysInfo('myserver');
|
|
534
|
+
expect(info.os).toBe('Linux');
|
|
535
|
+
// 不应该执行 SSH
|
|
536
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
537
|
+
});
|
|
538
|
+
it('force=true 应该忽略缓存', async () => {
|
|
539
|
+
mockGetConfig.mockReturnValue({
|
|
540
|
+
remotes: {
|
|
541
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
// 有缓存但 force=true
|
|
545
|
+
const cachedInfo = {
|
|
546
|
+
os: 'Linux',
|
|
547
|
+
osVersion: '5.4.0',
|
|
548
|
+
shell: 'bash',
|
|
549
|
+
hostname: 'oldhost',
|
|
550
|
+
cachedAt: new Date().toISOString(),
|
|
551
|
+
};
|
|
552
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
553
|
+
if (path.includes('sysinfo.json'))
|
|
554
|
+
return true;
|
|
555
|
+
if (path.includes('ssh.sock'))
|
|
556
|
+
return false;
|
|
557
|
+
return true;
|
|
558
|
+
});
|
|
559
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(cachedInfo));
|
|
560
|
+
const mockChild = createMockChildProcess({
|
|
561
|
+
stdout: 'OS:Linux\nOS_VERSION:5.10.0\nSHELL:zsh\nHOSTNAME:newhost\n',
|
|
562
|
+
exitCode: 0,
|
|
563
|
+
});
|
|
564
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
565
|
+
const { collectRemoteSysInfo } = await resetRemoteModule();
|
|
566
|
+
const info = await collectRemoteSysInfo('myserver', true);
|
|
567
|
+
expect(info.hostname).toBe('newhost');
|
|
568
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
569
|
+
});
|
|
570
|
+
it('缓存过期应该重新采集', async () => {
|
|
571
|
+
mockGetConfig.mockReturnValue({
|
|
572
|
+
remotes: {
|
|
573
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
// 缓存 10 天前
|
|
577
|
+
const oldDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
|
578
|
+
const cachedInfo = {
|
|
579
|
+
os: 'Linux',
|
|
580
|
+
osVersion: '5.4.0',
|
|
581
|
+
shell: 'bash',
|
|
582
|
+
hostname: 'oldhost',
|
|
583
|
+
cachedAt: oldDate,
|
|
584
|
+
};
|
|
585
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
586
|
+
if (path.includes('sysinfo.json'))
|
|
587
|
+
return true;
|
|
588
|
+
if (path.includes('ssh.sock'))
|
|
589
|
+
return false;
|
|
590
|
+
return true;
|
|
591
|
+
});
|
|
592
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(cachedInfo));
|
|
593
|
+
const mockChild = createMockChildProcess({
|
|
594
|
+
stdout: 'OS:Linux\nOS_VERSION:5.10.0\nSHELL:zsh\nHOSTNAME:newhost\n',
|
|
595
|
+
exitCode: 0,
|
|
596
|
+
});
|
|
597
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
598
|
+
const { collectRemoteSysInfo } = await resetRemoteModule();
|
|
599
|
+
const info = await collectRemoteSysInfo('myserver');
|
|
600
|
+
expect(info.hostname).toBe('newhost');
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
describe('formatRemoteSysInfoForAI', () => {
|
|
604
|
+
it('应该格式化系统信息供 AI 使用', async () => {
|
|
605
|
+
mockGetConfig.mockReturnValue({
|
|
606
|
+
remotes: {
|
|
607
|
+
myserver: { host: '192.168.1.100', user: 'root', port: 22 },
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
const sysInfo = {
|
|
611
|
+
os: 'Linux',
|
|
612
|
+
osVersion: '5.4.0',
|
|
613
|
+
shell: 'bash',
|
|
614
|
+
hostname: 'myhost',
|
|
615
|
+
cachedAt: new Date().toISOString(),
|
|
616
|
+
};
|
|
617
|
+
const { formatRemoteSysInfoForAI } = await resetRemoteModule();
|
|
618
|
+
const formatted = formatRemoteSysInfoForAI('myserver', sysInfo);
|
|
619
|
+
expect(formatted).toContain('myserver');
|
|
620
|
+
expect(formatted).toContain('root@192.168.1.100');
|
|
621
|
+
expect(formatted).toContain('Linux 5.4.0');
|
|
622
|
+
expect(formatted).toContain('bash');
|
|
623
|
+
});
|
|
624
|
+
it('应该包含工作目录信息', async () => {
|
|
625
|
+
mockGetConfig.mockReturnValue({
|
|
626
|
+
remotes: {
|
|
627
|
+
myserver: { host: 'host', user: 'root', port: 22, workDir: '/home/app' },
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
const sysInfo = {
|
|
631
|
+
os: 'Linux',
|
|
632
|
+
osVersion: '5.4.0',
|
|
633
|
+
shell: 'bash',
|
|
634
|
+
hostname: 'myhost',
|
|
635
|
+
cachedAt: new Date().toISOString(),
|
|
636
|
+
};
|
|
637
|
+
const { formatRemoteSysInfoForAI } = await resetRemoteModule();
|
|
638
|
+
const formatted = formatRemoteSysInfoForAI('myserver', sysInfo);
|
|
639
|
+
expect(formatted).toContain('/home/app');
|
|
640
|
+
});
|
|
641
|
+
it('服务器不存在应该返回空字符串', async () => {
|
|
642
|
+
mockGetConfig.mockReturnValue({ remotes: {} });
|
|
643
|
+
const sysInfo = {
|
|
644
|
+
os: 'Linux',
|
|
645
|
+
osVersion: '5.4.0',
|
|
646
|
+
shell: 'bash',
|
|
647
|
+
hostname: 'myhost',
|
|
648
|
+
cachedAt: new Date().toISOString(),
|
|
649
|
+
};
|
|
650
|
+
const { formatRemoteSysInfoForAI } = await resetRemoteModule();
|
|
651
|
+
const formatted = formatRemoteSysInfoForAI('nonexistent', sysInfo);
|
|
652
|
+
expect(formatted).toBe('');
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
// ============================================================================
|
|
656
|
+
// closeControlMaster 测试
|
|
657
|
+
// ============================================================================
|
|
658
|
+
describe('closeControlMaster', () => {
|
|
659
|
+
it('服务器不存在应该直接返回', async () => {
|
|
660
|
+
mockGetConfig.mockReturnValue({ remotes: {} });
|
|
661
|
+
const { closeControlMaster } = await resetRemoteModule();
|
|
662
|
+
await closeControlMaster('nonexistent');
|
|
663
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
664
|
+
});
|
|
665
|
+
it('socket 不存在应该直接返回', async () => {
|
|
666
|
+
mockGetConfig.mockReturnValue({
|
|
667
|
+
remotes: {
|
|
668
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
672
|
+
const { closeControlMaster } = await resetRemoteModule();
|
|
673
|
+
await closeControlMaster('myserver');
|
|
674
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
675
|
+
});
|
|
676
|
+
it('应该执行 ssh -O exit 关闭连接', async () => {
|
|
677
|
+
mockGetConfig.mockReturnValue({
|
|
678
|
+
remotes: {
|
|
679
|
+
myserver: { host: 'host', user: 'root', port: 22 },
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
683
|
+
const mockChild = createMockChildProcess({ exitCode: 0 });
|
|
684
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
685
|
+
const { closeControlMaster } = await resetRemoteModule();
|
|
686
|
+
await closeControlMaster('myserver');
|
|
687
|
+
expect(mockSpawn).toHaveBeenCalledWith('ssh', expect.arrayContaining(['-O', 'exit']), expect.any(Object));
|
|
688
|
+
});
|
|
689
|
+
});
|