-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathself_command.test.ts
More file actions
233 lines (192 loc) · 7.91 KB
/
self_command.test.ts
File metadata and controls
233 lines (192 loc) · 7.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
/**
* @license
* Copyright 2026 Steven A. Thompson
* SPDX-License-Identifier: MIT
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execSync, spawn } from 'child_process';
import path from 'path';
import { EventEmitter } from 'events';
// Hoist mocks to ensure they are available for vi.mock
const mocks = vi.hoisted(() => ({
registerTool: vi.fn(),
connect: vi.fn(),
spawn: vi.fn(), // We will configure the return value in tests
existsSync: vi.fn().mockReturnValue(true),
openSync: vi.fn(),
closeSync: vi.fn(),
unlinkSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock('fs', () => ({
default: {
existsSync: mocks.existsSync,
openSync: mocks.openSync,
closeSync: mocks.closeSync,
unlinkSync: mocks.unlinkSync,
writeFileSync: mocks.writeFileSync,
},
existsSync: mocks.existsSync,
openSync: mocks.openSync,
closeSync: mocks.closeSync,
unlinkSync: mocks.unlinkSync,
writeFileSync: mocks.writeFileSync,
}));
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: vi.fn().mockImplementation(() => ({
registerTool: mocks.registerTool,
connect: mocks.connect,
})),
}));
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn(),
}));
// Mock child_process
vi.mock('child_process', () => ({
execSync: vi.fn(),
spawn: mocks.spawn,
}));
describe('self_command MCP Server', () => {
let selfCommandFn: Function;
let yieldTurnFn: Function;
let geminiSleepFn: Function;
let watchLogFn: Function;
let runLongCommandFn: Function;
const ORIGINAL_ENV = process.env;
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
process.env = { ...ORIGINAL_ENV }; // Clone env
mocks.existsSync.mockReturnValue(true); // Default to file existing
// Default spawn mock for simple tools (just unref)
mocks.spawn.mockReturnValue({ unref: vi.fn() });
// Dynamically import to trigger tool registration
await import('./self_command.js');
// Find the tool handlers from the mock calls
const calls = (mocks.registerTool as Mock).mock.calls;
const selfCommandCall = calls.find(call => call[0] === 'self_command');
const yieldTurnCall = calls.find(call => call[0] === 'yield_turn');
const geminiSleepCall = calls.find(call => call[0] === 'gemini_sleep');
const watchLogCall = calls.find(call => call[0] === 'watch_log');
const runLongCommandCall = calls.find(call => call[0] === 'run_long_command');
if (selfCommandCall) selfCommandFn = selfCommandCall[2];
if (yieldTurnCall) yieldTurnFn = yieldTurnCall[2];
if (geminiSleepCall) geminiSleepFn = geminiSleepCall[2];
if (watchLogCall) watchLogFn = watchLogCall[2];
if (runLongCommandCall) runLongCommandFn = runLongCommandCall[2];
});
afterEach(() => {
vi.resetModules();
vi.useRealTimers();
process.env = ORIGINAL_ENV; // Restore env
});
it('should register all tools', () => {
expect(mocks.registerTool).toHaveBeenCalledWith('self_command', expect.any(Object), expect.any(Function));
expect(mocks.registerTool).toHaveBeenCalledWith('yield_turn', expect.any(Object), expect.any(Function));
expect(mocks.registerTool).toHaveBeenCalledWith('gemini_sleep', expect.any(Object), expect.any(Function));
expect(mocks.registerTool).toHaveBeenCalledWith('watch_log', expect.any(Object), expect.any(Function));
expect(mocks.registerTool).toHaveBeenCalledWith('run_long_command', expect.any(Object), expect.any(Function));
});
it('should fail self_command if TMUX env var is missing', async () => {
delete process.env.TMUX;
const result = await selfCommandFn({ command: 'help' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error: Not running inside tmux session");
});
it('should fail self_command if tmux session name does not match', async () => {
process.env.TMUX = '/tmp/tmux-1000/default,1234,0';
(execSync as Mock).mockReturnValue('other-session\n');
const result = await selfCommandFn({ command: 'help' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error: Not running inside tmux session");
});
it('self_command should return immediately and spawn the worker process', async () => {
process.env.TMUX = '/tmp/tmux-1000/default,1234,0';
(execSync as Mock).mockReturnValue('gemini-cli\n');
const command = 'echo hello';
const result = await selfCommandFn({ command });
expect(result.content[0].text).toContain('Will execute "echo hello" in ~3 seconds');
// Verify spawn was called
expect(mocks.spawn).toHaveBeenCalledWith(
process.execPath,
expect.arrayContaining([expect.stringContaining('delayed_submit.js'),Buffer.from(command).toString('base64')]),
expect.objectContaining({
detached: true,
stdio: 'ignore',
cwd: expect.any(String)
})
);
});
it('run_long_command should spawn process and notify on completion', async () => {
process.env.TMUX = '/tmp/tmux-1000/default,1234,0';
(execSync as Mock).mockReturnValue('gemini-cli\n');
// Setup complex spawn mock for run_long_command
const mockChild = new EventEmitter() as any;
mockChild.stdout = new EventEmitter();
mockChild.stderr = new EventEmitter();
mockChild.unref = vi.fn();
mockChild.pid = 12345;
mocks.spawn.mockReturnValue(mockChild);
const command = 'sleep 5';
const result = await runLongCommandFn({ command });
// Verify initial return
expect(result.content[0].text).toContain('started in the background');
expect(result.content[0].text).toContain('PID: 12345');
// Simulate process output and completion
mockChild.stdout.emit('data', 'some output');
mockChild.emit('close', 0);
// Fast-forward timers to handle notification delay
await vi.runAllTimersAsync();
// Verify notification was sent via tmux
// Logic: it calls sendNotification which calls execSync with tmux send-keys
expect(execSync).toHaveBeenCalledWith(expect.stringContaining('tmux send-keys'));
});
it('yield_turn should spawn the yield worker process', async () => {
process.env.TMUX = '/tmp/tmux-1000/default,1234,0';
(execSync as Mock).mockReturnValue('gemini-cli\n');
const result = await yieldTurnFn({});
expect(result.content[0].text).toContain('Yielding turn');
expect(mocks.spawn).toHaveBeenCalledWith(
process.execPath,
expect.arrayContaining([expect.stringContaining('instant_yield.js')]),
expect.objectContaining({ detached: true })
);
});
it('gemini_sleep should spawn the sleep worker process', async () => {
process.env.TMUX = '/tmp/tmux-1000/default,1234,0';
(execSync as Mock).mockReturnValue('gemini-cli\n');
const result = await geminiSleepFn({ seconds: 10 });
expect(result.content[0].text).toContain('Will sleep for 10 seconds');
expect(mocks.spawn).toHaveBeenCalledWith(
process.execPath,
expect.arrayContaining([expect.stringContaining('delayed_sleep.js'), '10']),
expect.objectContaining({ detached: true })
);
});
it('watch_log should spawn the watch worker process with defaults', async () => {
process.env.TMUX = '/tmp/tmux-1000/default,1234,0';
(execSync as Mock).mockReturnValue('gemini-cli\n');
const result = await watchLogFn({ file_path: '/tmp/log.txt' });
expect(result.content[0].text).toContain('Log monitor background task');
expect(result.content[0].text).toContain('started for /tmp/log.txt');
expect(mocks.spawn).toHaveBeenCalledWith(
process.execPath,
expect.arrayContaining([
expect.stringContaining('delayed_watch.js'),
'/tmp/log.txt',
'', // No regex
'true' // Default wake_on_change
]),
expect.objectContaining({ detached: true })
);
});
});