agent-gear 1.0.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/AGENTS.md +67 -0
- package/dist/acp/bridge.d.ts +56 -0
- package/dist/acp/bridge.d.ts.map +1 -0
- package/dist/acp/bridge.js +295 -0
- package/dist/acp/bridge.js.map +1 -0
- package/dist/acp/detector.d.ts +13 -0
- package/dist/acp/detector.d.ts.map +1 -0
- package/dist/acp/detector.js +29 -0
- package/dist/acp/detector.js.map +1 -0
- package/dist/acp/spawner.d.ts +24 -0
- package/dist/acp/spawner.d.ts.map +1 -0
- package/dist/acp/spawner.js +82 -0
- package/dist/acp/spawner.js.map +1 -0
- package/dist/acp/sync-bridge.d.ts +21 -0
- package/dist/acp/sync-bridge.d.ts.map +1 -0
- package/dist/acp/sync-bridge.js +75 -0
- package/dist/acp/sync-bridge.js.map +1 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +32 -0
- package/dist/daemon.js.map +1 -0
- package/dist/git-handler.d.ts +17 -0
- package/dist/git-handler.d.ts.map +1 -0
- package/dist/git-handler.js +60 -0
- package/dist/git-handler.js.map +1 -0
- package/dist/git-handler.test.d.ts +2 -0
- package/dist/git-handler.test.d.ts.map +1 -0
- package/dist/git-handler.test.js +127 -0
- package/dist/git-handler.test.js.map +1 -0
- package/dist/heartbeat.d.ts +6 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +16 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/socketio-client.d.ts +6 -0
- package/dist/socketio-client.d.ts.map +1 -0
- package/dist/socketio-client.js +385 -0
- package/dist/socketio-client.js.map +1 -0
- package/package.json +34 -0
- package/src/acp/bridge.ts +327 -0
- package/src/acp/detector.ts +40 -0
- package/src/acp/spawner.ts +94 -0
- package/src/acp/sync-bridge.ts +111 -0
- package/src/config.ts +75 -0
- package/src/daemon.ts +33 -0
- package/src/git-handler.test.ts +153 -0
- package/src/git-handler.ts +75 -0
- package/src/heartbeat.ts +18 -0
- package/src/index.ts +60 -0
- package/src/socketio-client.ts +497 -0
- package/tests/acp-bridge.test.ts +473 -0
- package/tests/acp-detector.test.ts +70 -0
- package/tests/acp-spawner.test.ts +149 -0
- package/tests/acp-sync-bridge.test.ts +263 -0
- package/tests/config.test.ts +166 -0
- package/tests/socketio-client.test.ts +196 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
function buildMockConn() {
|
|
4
|
+
return {
|
|
5
|
+
initialize: vi.fn<[unknown], Promise<{ protocolVersion: number }>>().mockResolvedValue({ protocolVersion: 1 }),
|
|
6
|
+
newSession: vi.fn<[unknown], Promise<{ sessionId: string }>>().mockResolvedValue({ sessionId: 'sync-sess-1' }),
|
|
7
|
+
listSessions: vi.fn<[unknown], Promise<{ sessions: Array<{ sessionId: string; title: string | null; cwd: string; updatedAt: string | null }> }>>()
|
|
8
|
+
.mockResolvedValue({ sessions: [{ sessionId: 'hist-1', title: 'Session 1', cwd: '/home/user', updatedAt: '2026-01-01T00:00:00Z' }] }),
|
|
9
|
+
loadSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
10
|
+
cancel: vi.fn<[unknown], Promise<void>>().mockResolvedValue(undefined),
|
|
11
|
+
closeSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let mockConnInstance = buildMockConn();
|
|
16
|
+
|
|
17
|
+
vi.mock('@agentclientprotocol/sdk', () => ({
|
|
18
|
+
ndJsonStream: vi.fn(() => ({})),
|
|
19
|
+
ClientSideConnection: vi.fn(function () { return mockConnInstance; }),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { AcpSyncBridge } from '../src/acp/sync-bridge.js';
|
|
23
|
+
|
|
24
|
+
class MockAcpSpawner {
|
|
25
|
+
start = vi.fn();
|
|
26
|
+
stop = vi.fn();
|
|
27
|
+
isRunning = vi.fn().mockReturnValue(true);
|
|
28
|
+
_stdin = new WritableStream();
|
|
29
|
+
_stdout = new ReadableStream();
|
|
30
|
+
private _resolveClosed: (() => void) | null = null;
|
|
31
|
+
private _closed: Promise<void>;
|
|
32
|
+
|
|
33
|
+
constructor() {
|
|
34
|
+
this._closed = new Promise((resolve) => { this._resolveClosed = resolve; });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get stdin() { return this._stdin; }
|
|
38
|
+
get stdout() { return this._stdout; }
|
|
39
|
+
get closed() { return this._closed; }
|
|
40
|
+
|
|
41
|
+
triggerExit() {
|
|
42
|
+
this._resolveClosed?.();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createBridge(spawner?: MockAcpSpawner) {
|
|
47
|
+
return new AcpSyncBridge({ cwd: '/home/user' }, spawner as never);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mc() { return mockConnInstance; }
|
|
51
|
+
|
|
52
|
+
describe('AcpSyncBridge', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
mockConnInstance = buildMockConn();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('connect', () => {
|
|
59
|
+
it('should spawn ACP process and initialize connection', async () => {
|
|
60
|
+
const mockSpawner = new MockAcpSpawner();
|
|
61
|
+
const bridge = createBridge(mockSpawner);
|
|
62
|
+
await bridge.connect();
|
|
63
|
+
|
|
64
|
+
expect(mockSpawner.start).toHaveBeenCalledOnce();
|
|
65
|
+
expect(mc().initialize).toHaveBeenCalledWith({
|
|
66
|
+
protocolVersion: 1,
|
|
67
|
+
clientCapabilities: {},
|
|
68
|
+
clientInfo: { name: 'agent-gear', version: '1.0.0' },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should throw when connection fails', async () => {
|
|
73
|
+
mc().initialize.mockRejectedValue(new Error('connection failed'));
|
|
74
|
+
const mockSpawner = new MockAcpSpawner();
|
|
75
|
+
const bridge = createBridge(mockSpawner);
|
|
76
|
+
|
|
77
|
+
await expect(bridge.connect()).rejects.toThrow('connection failed');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should throw when process exits during connect', async () => {
|
|
81
|
+
mc().initialize.mockReturnValue(new Promise(() => {}));
|
|
82
|
+
const mockSpawner = new MockAcpSpawner();
|
|
83
|
+
const bridge = createBridge(mockSpawner);
|
|
84
|
+
|
|
85
|
+
mockSpawner.triggerExit();
|
|
86
|
+
|
|
87
|
+
await expect(bridge.connect()).rejects.toThrow('ACP process exited unexpectedly');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should throw on connect timeout', async () => {
|
|
91
|
+
vi.useFakeTimers();
|
|
92
|
+
try {
|
|
93
|
+
mc().initialize.mockReturnValue(new Promise(() => {}));
|
|
94
|
+
const mockSpawner = new MockAcpSpawner();
|
|
95
|
+
const bridge = createBridge(mockSpawner);
|
|
96
|
+
|
|
97
|
+
const connectPromise = bridge.connect(1000);
|
|
98
|
+
vi.advanceTimersByTime(1000);
|
|
99
|
+
|
|
100
|
+
await expect(connectPromise).rejects.toThrow('timed out');
|
|
101
|
+
} finally {
|
|
102
|
+
vi.useRealTimers();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('listSessions', () => {
|
|
108
|
+
it('should return session list from ACP', async () => {
|
|
109
|
+
const mockSpawner = new MockAcpSpawner();
|
|
110
|
+
const bridge = createBridge(mockSpawner);
|
|
111
|
+
await bridge.connect();
|
|
112
|
+
|
|
113
|
+
const result = await bridge.listSessions('/home/user');
|
|
114
|
+
|
|
115
|
+
expect(mc().listSessions).toHaveBeenCalledWith({ cwd: '/home/user' });
|
|
116
|
+
expect(result.sessions).toHaveLength(1);
|
|
117
|
+
expect(result.sessions[0].sessionId).toBe('hist-1');
|
|
118
|
+
expect(result.sessions[0].title).toBe('Session 1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return empty list when no sessions', async () => {
|
|
122
|
+
mc().listSessions.mockResolvedValue({ sessions: [] });
|
|
123
|
+
const mockSpawner = new MockAcpSpawner();
|
|
124
|
+
const bridge = createBridge(mockSpawner);
|
|
125
|
+
await bridge.connect();
|
|
126
|
+
|
|
127
|
+
const result = await bridge.listSessions('/home/user');
|
|
128
|
+
|
|
129
|
+
expect(result.sessions).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('loadSession', () => {
|
|
134
|
+
it('should return config and collected updates', async () => {
|
|
135
|
+
mc().loadSession.mockResolvedValue({});
|
|
136
|
+
const mockSpawner = new MockAcpSpawner();
|
|
137
|
+
const bridge = createBridge(mockSpawner);
|
|
138
|
+
await bridge.connect();
|
|
139
|
+
|
|
140
|
+
const result = await bridge.loadSession('hist-1', '/home/user');
|
|
141
|
+
|
|
142
|
+
expect(mc().loadSession).toHaveBeenCalledWith({
|
|
143
|
+
cwd: '/home/user',
|
|
144
|
+
sessionId: 'hist-1',
|
|
145
|
+
mcpServers: [],
|
|
146
|
+
});
|
|
147
|
+
expect(result.updates).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should collect sessionUpdate notifications during load', async () => {
|
|
151
|
+
let bridge!: AcpSyncBridge;
|
|
152
|
+
mc().loadSession.mockImplementation(async () => {
|
|
153
|
+
await bridge.sessionUpdate({
|
|
154
|
+
sessionId: 'hist-1',
|
|
155
|
+
update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } },
|
|
156
|
+
} as never);
|
|
157
|
+
await bridge.sessionUpdate({
|
|
158
|
+
sessionId: 'hist-1',
|
|
159
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi there' } },
|
|
160
|
+
} as never);
|
|
161
|
+
return {};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const mockSpawner = new MockAcpSpawner();
|
|
165
|
+
bridge = createBridge(mockSpawner);
|
|
166
|
+
await bridge.connect();
|
|
167
|
+
|
|
168
|
+
const result = await bridge.loadSession('hist-1', '/home/user');
|
|
169
|
+
|
|
170
|
+
expect(result.updates).toHaveLength(2);
|
|
171
|
+
expect(result.updates[0].update.sessionUpdate).toBe('user_message_chunk');
|
|
172
|
+
expect(result.updates[1].update.sessionUpdate).toBe('agent_message_chunk');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should throw when process exits during loadSession', async () => {
|
|
176
|
+
mc().loadSession.mockReturnValue(new Promise(() => {}));
|
|
177
|
+
const mockSpawner = new MockAcpSpawner();
|
|
178
|
+
const bridge = createBridge(mockSpawner);
|
|
179
|
+
await bridge.connect();
|
|
180
|
+
|
|
181
|
+
mockSpawner.triggerExit();
|
|
182
|
+
|
|
183
|
+
await expect(bridge.loadSession('hist-1', '/home/user')).rejects.toThrow('ACP process exited unexpectedly');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should throw on load-session timeout', async () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
try {
|
|
189
|
+
mc().loadSession.mockReturnValue(new Promise(() => {}));
|
|
190
|
+
const mockSpawner = new MockAcpSpawner();
|
|
191
|
+
const bridge = createBridge(mockSpawner);
|
|
192
|
+
await bridge.connect();
|
|
193
|
+
|
|
194
|
+
const loadPromise = bridge.loadSession('hist-1', '/home/user', 1000);
|
|
195
|
+
vi.advanceTimersByTime(1000);
|
|
196
|
+
|
|
197
|
+
await expect(loadPromise).rejects.toThrow('timed out');
|
|
198
|
+
} finally {
|
|
199
|
+
vi.useRealTimers();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('disconnect', () => {
|
|
205
|
+
it('should close session with given ID and stop spawner', async () => {
|
|
206
|
+
const mockSpawner = new MockAcpSpawner();
|
|
207
|
+
const bridge = createBridge(mockSpawner);
|
|
208
|
+
await bridge.connect();
|
|
209
|
+
|
|
210
|
+
await bridge.disconnect('hist-1');
|
|
211
|
+
|
|
212
|
+
expect(mc().closeSession).toHaveBeenCalledWith({ sessionId: 'hist-1' });
|
|
213
|
+
expect(mockSpawner.stop).toHaveBeenCalledOnce();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should skip closeSession when no sessionId given', async () => {
|
|
217
|
+
const mockSpawner = new MockAcpSpawner();
|
|
218
|
+
const bridge = createBridge(mockSpawner);
|
|
219
|
+
await bridge.connect();
|
|
220
|
+
|
|
221
|
+
await bridge.disconnect();
|
|
222
|
+
|
|
223
|
+
expect(mc().closeSession).not.toHaveBeenCalled();
|
|
224
|
+
expect(mockSpawner.stop).toHaveBeenCalledOnce();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should not throw when not connected', async () => {
|
|
228
|
+
const mockSpawner = new MockAcpSpawner();
|
|
229
|
+
const bridge = createBridge(mockSpawner);
|
|
230
|
+
await expect(bridge.disconnect()).resolves.not.toThrow();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('sessionUpdate', () => {
|
|
235
|
+
it('should collect notifications and return them in loadSession', async () => {
|
|
236
|
+
let bridge!: AcpSyncBridge;
|
|
237
|
+
mc().loadSession.mockImplementation(async () => {
|
|
238
|
+
await bridge.sessionUpdate({
|
|
239
|
+
sessionId: 'hist-1',
|
|
240
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'test' } },
|
|
241
|
+
} as never);
|
|
242
|
+
return {};
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const mockSpawner = new MockAcpSpawner();
|
|
246
|
+
bridge = createBridge(mockSpawner);
|
|
247
|
+
await bridge.connect();
|
|
248
|
+
|
|
249
|
+
const result = await bridge.loadSession('hist-1', '/home/user');
|
|
250
|
+
expect(result.updates).toHaveLength(1);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('requestPermission', () => {
|
|
255
|
+
it('should always reject', async () => {
|
|
256
|
+
const mockSpawner = new MockAcpSpawner();
|
|
257
|
+
const bridge = createBridge(mockSpawner);
|
|
258
|
+
const result = await bridge.requestPermission({} as never);
|
|
259
|
+
|
|
260
|
+
expect(result).toEqual({ outcome: { outcome: 'cancelled' } });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
const TEST_CONFIG_DIR = join(tmpdir(), 'zag-test-config');
|
|
7
|
+
const TEST_CONFIG_FILE = join(TEST_CONFIG_DIR, 'config.toml');
|
|
8
|
+
|
|
9
|
+
vi.mock('smol-toml', () => ({
|
|
10
|
+
parse: vi.fn(),
|
|
11
|
+
stringify: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('Config', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
if (existsSync(TEST_CONFIG_DIR)) {
|
|
17
|
+
rmSync(TEST_CONFIG_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (existsSync(TEST_CONFIG_DIR)) {
|
|
24
|
+
rmSync(TEST_CONFIG_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should read and write config', async () => {
|
|
29
|
+
const { parse, stringify } = await import('smol-toml');
|
|
30
|
+
const configContent = '[key]\nvalue = "zag_k_1_abc123"\n\n[server]\nendpoint = "http://localhost:4001"\n';
|
|
31
|
+
vi.mocked(parse).mockReturnValue({
|
|
32
|
+
key: { value: 'zag_k_1_abc123' },
|
|
33
|
+
server: { endpoint: 'http://localhost:4001' },
|
|
34
|
+
});
|
|
35
|
+
vi.mocked(stringify).mockReturnValue(configContent);
|
|
36
|
+
|
|
37
|
+
writeFileSync(TEST_CONFIG_FILE, configContent, 'utf-8');
|
|
38
|
+
const content = readFileSync(TEST_CONFIG_FILE, 'utf-8');
|
|
39
|
+
const parsed = parse(content);
|
|
40
|
+
|
|
41
|
+
expect(parsed).toEqual({
|
|
42
|
+
key: { value: 'zag_k_1_abc123' },
|
|
43
|
+
server: { endpoint: 'http://localhost:4001' },
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should handle empty config', async () => {
|
|
48
|
+
const { parse } = await import('smol-toml');
|
|
49
|
+
vi.mocked(parse).mockReturnValue({});
|
|
50
|
+
|
|
51
|
+
const config = parse('');
|
|
52
|
+
expect(config).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Config functions', () => {
|
|
57
|
+
describe('resolveKey', () => {
|
|
58
|
+
it('should return cli key when provided', async () => {
|
|
59
|
+
const { resolveKey } = await import('../src/config.js');
|
|
60
|
+
expect(resolveKey('cli-key')).toBe('cli-key');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should return env key when cli key not provided', async () => {
|
|
64
|
+
process.env.ZAG_CONTROLLER_KEY = 'env-key';
|
|
65
|
+
const { resolveKey } = await import('../src/config.js');
|
|
66
|
+
expect(resolveKey()).toBe('env-key');
|
|
67
|
+
delete process.env.ZAG_CONTROLLER_KEY;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('resolveEndpoint', () => {
|
|
72
|
+
it('should return cli endpoint when provided', async () => {
|
|
73
|
+
const { resolveEndpoint } = await import('../src/config.js');
|
|
74
|
+
expect(resolveEndpoint('http://cli:4001')).toBe('http://cli:4001');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return env endpoint when cli endpoint not provided', async () => {
|
|
78
|
+
process.env.ZAG_SERVER_ENDPOINT = 'http://env:4001';
|
|
79
|
+
const { resolveEndpoint } = await import('../src/config.js');
|
|
80
|
+
expect(resolveEndpoint()).toBe('http://env:4001');
|
|
81
|
+
delete process.env.ZAG_SERVER_ENDPOINT;
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('Heartbeat', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
vi.useFakeTimers();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
vi.useRealTimers();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should start and stop heartbeat', async () => {
|
|
96
|
+
const { Heartbeat } = await import('../src/heartbeat.js');
|
|
97
|
+
const heartbeat = new Heartbeat();
|
|
98
|
+
const sendFn = vi.fn();
|
|
99
|
+
|
|
100
|
+
heartbeat.start(sendFn);
|
|
101
|
+
vi.advanceTimersByTime(30_000);
|
|
102
|
+
|
|
103
|
+
expect(sendFn).toHaveBeenCalled();
|
|
104
|
+
heartbeat.stop();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should not call sendFn before interval', async () => {
|
|
108
|
+
const { Heartbeat } = await import('../src/heartbeat.js');
|
|
109
|
+
const heartbeat = new Heartbeat();
|
|
110
|
+
const sendFn = vi.fn();
|
|
111
|
+
|
|
112
|
+
heartbeat.start(sendFn);
|
|
113
|
+
vi.advanceTimersByTime(29_999);
|
|
114
|
+
|
|
115
|
+
expect(sendFn).not.toHaveBeenCalled();
|
|
116
|
+
heartbeat.stop();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should stop calling after stop', async () => {
|
|
120
|
+
const { Heartbeat } = await import('../src/heartbeat.js');
|
|
121
|
+
const heartbeat = new Heartbeat();
|
|
122
|
+
const sendFn = vi.fn();
|
|
123
|
+
|
|
124
|
+
heartbeat.start(sendFn);
|
|
125
|
+
heartbeat.stop();
|
|
126
|
+
vi.advanceTimersByTime(60_000);
|
|
127
|
+
|
|
128
|
+
expect(sendFn).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('Daemon', () => {
|
|
133
|
+
it('should write and read pid', async () => {
|
|
134
|
+
const { writePid, readPid, removePid } = await import('../src/daemon.js');
|
|
135
|
+
writePid();
|
|
136
|
+
expect(readPid()).toBe(process.pid);
|
|
137
|
+
removePid();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return null when no pid file', async () => {
|
|
141
|
+
const { readPid, removePid } = await import('../src/daemon.js');
|
|
142
|
+
removePid();
|
|
143
|
+
expect(readPid()).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should remove pid file', async () => {
|
|
147
|
+
const { writePid, readPid, removePid } = await import('../src/daemon.js');
|
|
148
|
+
writePid();
|
|
149
|
+
expect(readPid()).toBe(process.pid);
|
|
150
|
+
removePid();
|
|
151
|
+
expect(readPid()).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should detect running process', async () => {
|
|
155
|
+
const { writePid, isRunning, removePid } = await import('../src/daemon.js');
|
|
156
|
+
writePid();
|
|
157
|
+
expect(isRunning()).toBe(true);
|
|
158
|
+
removePid();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should detect non-running process', async () => {
|
|
162
|
+
const { isRunning, removePid } = await import('../src/daemon.js');
|
|
163
|
+
removePid();
|
|
164
|
+
expect(isRunning()).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockStart, mockStop, MockHeartbeat } = vi.hoisted(() => {
|
|
4
|
+
const start = vi.fn();
|
|
5
|
+
const stop = vi.fn();
|
|
6
|
+
const HB = vi.fn().mockImplementation(function () {
|
|
7
|
+
return { start, stop };
|
|
8
|
+
});
|
|
9
|
+
return { mockStart: start, mockStop: stop, MockHeartbeat: HB };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
vi.mock('socket.io-client', () => ({
|
|
13
|
+
io: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/heartbeat.js', () => ({
|
|
17
|
+
Heartbeat: MockHeartbeat,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('../src/config.js', () => ({
|
|
21
|
+
clearConfigKey: vi.fn(),
|
|
22
|
+
saveKeyToConfig: vi.fn(),
|
|
23
|
+
saveEndpointToConfig: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('os', () => ({
|
|
27
|
+
hostname: vi.fn().mockReturnValue('test-machine'),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { connectToServer, unregister } from '../src/socketio-client.js';
|
|
31
|
+
import { io } from 'socket.io-client';
|
|
32
|
+
import { clearConfigKey } from '../src/config.js';
|
|
33
|
+
|
|
34
|
+
function createMockSocket() {
|
|
35
|
+
const handlers: Record<string, (...args: unknown[]) => void> = {};
|
|
36
|
+
return {
|
|
37
|
+
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
38
|
+
handlers[event] = handler;
|
|
39
|
+
}),
|
|
40
|
+
emit: vi.fn(),
|
|
41
|
+
disconnect: vi.fn(),
|
|
42
|
+
close: vi.fn(),
|
|
43
|
+
_handlers: handlers,
|
|
44
|
+
_trigger: (event: string, ...args: unknown[]) => {
|
|
45
|
+
handlers[event]?.(...args);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
mockStart.mockClear();
|
|
53
|
+
mockStop.mockClear();
|
|
54
|
+
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
55
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
56
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('SocketIO Client', () => {
|
|
64
|
+
describe('connectToServer', () => {
|
|
65
|
+
it('should create socket with correct options', () => {
|
|
66
|
+
const mockSocket = createMockSocket();
|
|
67
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
68
|
+
|
|
69
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
70
|
+
|
|
71
|
+
expect(io).toHaveBeenCalledWith('http://localhost:4001', {
|
|
72
|
+
transports: ['websocket'],
|
|
73
|
+
reconnection: true,
|
|
74
|
+
reconnectionDelay: 1000,
|
|
75
|
+
reconnectionDelayMax: 60000,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should emit register on connect', () => {
|
|
80
|
+
const mockSocket = createMockSocket();
|
|
81
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
82
|
+
|
|
83
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', { serverName: 'my-machine' });
|
|
84
|
+
|
|
85
|
+
mockSocket._trigger('connect');
|
|
86
|
+
|
|
87
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('register', {
|
|
88
|
+
key: 'zag_k_1_abc123',
|
|
89
|
+
hostname: 'my-machine',
|
|
90
|
+
version: '1.0.0',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should use default hostname when serverName not provided', () => {
|
|
95
|
+
const mockSocket = createMockSocket();
|
|
96
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
97
|
+
|
|
98
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
99
|
+
|
|
100
|
+
mockSocket._trigger('connect');
|
|
101
|
+
|
|
102
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('register', {
|
|
103
|
+
key: 'zag_k_1_abc123',
|
|
104
|
+
hostname: 'test-machine',
|
|
105
|
+
version: '1.0.0',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should start heartbeat on registered', () => {
|
|
110
|
+
const mockSocket = createMockSocket();
|
|
111
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
112
|
+
|
|
113
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
114
|
+
|
|
115
|
+
mockSocket._trigger('registered', { controllerId: 'ctrl123' });
|
|
116
|
+
|
|
117
|
+
expect(MockHeartbeat).toHaveBeenCalled();
|
|
118
|
+
expect(mockStart).toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should disconnect and exit on error', () => {
|
|
122
|
+
const mockSocket = createMockSocket();
|
|
123
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
124
|
+
|
|
125
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
126
|
+
|
|
127
|
+
mockSocket._trigger('error', { code: 'INVALID_KEY', message: 'Invalid key' });
|
|
128
|
+
|
|
129
|
+
expect(mockSocket.disconnect).toHaveBeenCalled();
|
|
130
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should clear config and exit on unregistered', () => {
|
|
134
|
+
const mockSocket = createMockSocket();
|
|
135
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
136
|
+
|
|
137
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
138
|
+
|
|
139
|
+
mockSocket._trigger('unregistered');
|
|
140
|
+
|
|
141
|
+
expect(mockStop).toHaveBeenCalled();
|
|
142
|
+
expect(clearConfigKey).toHaveBeenCalled();
|
|
143
|
+
expect(mockSocket.close).toHaveBeenCalled();
|
|
144
|
+
expect(process.exit).toHaveBeenCalledWith(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should clear config and exit on shutdown', () => {
|
|
148
|
+
const mockSocket = createMockSocket();
|
|
149
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
150
|
+
|
|
151
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
152
|
+
|
|
153
|
+
mockSocket._trigger('shutdown', { reason: 'key_revoked' });
|
|
154
|
+
|
|
155
|
+
expect(mockStop).toHaveBeenCalled();
|
|
156
|
+
expect(clearConfigKey).toHaveBeenCalled();
|
|
157
|
+
expect(mockSocket.close).toHaveBeenCalled();
|
|
158
|
+
expect(process.exit).toHaveBeenCalledWith(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should stop heartbeat on disconnect', () => {
|
|
162
|
+
const mockSocket = createMockSocket();
|
|
163
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
164
|
+
|
|
165
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
166
|
+
|
|
167
|
+
mockSocket._trigger('disconnect', 'transport close');
|
|
168
|
+
|
|
169
|
+
expect(mockStop).toHaveBeenCalled();
|
|
170
|
+
expect(console.log).toHaveBeenCalledWith('Disconnected: transport close');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should log on connect_error', () => {
|
|
174
|
+
const mockSocket = createMockSocket();
|
|
175
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
176
|
+
|
|
177
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
178
|
+
|
|
179
|
+
mockSocket._trigger('connect_error', new Error('connection failed'));
|
|
180
|
+
|
|
181
|
+
expect(console.error).toHaveBeenCalledWith('Connection error: connection failed');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('unregister', () => {
|
|
186
|
+
it('should emit unregister event', () => {
|
|
187
|
+
const mockSocket = createMockSocket();
|
|
188
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
189
|
+
|
|
190
|
+
const socket = connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
191
|
+
unregister(socket);
|
|
192
|
+
|
|
193
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('unregister', { reason: 'user_detach' });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"declaration": true,
|
|
16
|
+
"declarationMap": true,
|
|
17
|
+
"sourceMap": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
reporter: ['text', 'text-summary'],
|
|
10
|
+
include: ['src/**/*.ts'],
|
|
11
|
+
exclude: ['src/**/*.test.ts', 'src/index.ts'],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|