@openclaw-cloud/agent-controller 0.2.6 → 0.2.7
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/dist/commands/install.js +3 -0
- package/dist/commands/install.js.map +1 -1
- package/dist/config-file.d.ts +9 -0
- package/dist/config-file.js +47 -0
- package/dist/config-file.js.map +1 -0
- package/dist/connection.d.ts +1 -0
- package/dist/connection.js +27 -13
- package/dist/connection.js.map +1 -1
- package/dist/handlers/backup.js +7 -2
- package/dist/handlers/backup.js.map +1 -1
- package/dist/handlers/knowledge-sync.d.ts +2 -0
- package/dist/handlers/knowledge-sync.js +51 -0
- package/dist/handlers/knowledge-sync.js.map +1 -0
- package/dist/heartbeat.d.ts +1 -0
- package/dist/heartbeat.js +30 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/package.json +6 -1
- package/.claude/cc-notify.sh +0 -32
- package/.claude/settings.json +0 -31
- package/.husky/pre-commit +0 -1
- package/BIZPLAN.md +0 -530
- package/CLAUDE.md +0 -172
- package/Dockerfile +0 -9
- package/__tests__/api.test.ts +0 -183
- package/__tests__/backup.test.ts +0 -145
- package/__tests__/board-handler.test.ts +0 -323
- package/__tests__/chat.test.ts +0 -191
- package/__tests__/config.test.ts +0 -100
- package/__tests__/connection.test.ts +0 -289
- package/__tests__/file-delete.test.ts +0 -90
- package/__tests__/file-write.test.ts +0 -119
- package/__tests__/gateway-adapter.test.ts +0 -366
- package/__tests__/gateway-client.test.ts +0 -272
- package/__tests__/handlers.test.ts +0 -150
- package/__tests__/heartbeat.test.ts +0 -124
- package/__tests__/onboarding.test.ts +0 -55
- package/__tests__/package-install.test.ts +0 -109
- package/__tests__/pair.test.ts +0 -60
- package/__tests__/self-update.test.ts +0 -123
- package/__tests__/stop.test.ts +0 -38
- package/jest.config.ts +0 -16
- package/src/api.ts +0 -62
- package/src/commands/install.ts +0 -68
- package/src/commands/self-update.ts +0 -43
- package/src/commands/uninstall.ts +0 -19
- package/src/config-file.ts +0 -56
- package/src/connection.ts +0 -203
- package/src/debug.ts +0 -11
- package/src/handlers/backup.ts +0 -101
- package/src/handlers/board-handler.ts +0 -155
- package/src/handlers/chat.ts +0 -79
- package/src/handlers/config.ts +0 -48
- package/src/handlers/deploy.ts +0 -32
- package/src/handlers/exec.ts +0 -32
- package/src/handlers/file-delete.ts +0 -46
- package/src/handlers/file-write.ts +0 -65
- package/src/handlers/knowledge-sync.ts +0 -53
- package/src/handlers/onboarding.ts +0 -19
- package/src/handlers/package-install.ts +0 -69
- package/src/handlers/pair.ts +0 -26
- package/src/handlers/restart.ts +0 -19
- package/src/handlers/stop.ts +0 -17
- package/src/heartbeat.ts +0 -110
- package/src/index.ts +0 -97
- package/src/openclaw/gateway-adapter.ts +0 -129
- package/src/openclaw/gateway-client.ts +0 -131
- package/src/openclaw/index.ts +0 -17
- package/src/openclaw/types.ts +0 -41
- package/src/platform/linux.ts +0 -108
- package/src/platform/macos.ts +0 -122
- package/src/platform/windows.ts +0 -92
- package/src/types.ts +0 -94
- package/tsconfig.json +0 -18
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
// Mock GatewayClient before importing gateway-adapter
|
|
2
|
-
jest.mock('../src/openclaw/gateway-client');
|
|
3
|
-
|
|
4
|
-
import { GatewayClient } from '../src/openclaw/gateway-client';
|
|
5
|
-
import { OpenclawGatewayAdapter } from '../src/openclaw/gateway-adapter';
|
|
6
|
-
|
|
7
|
-
const MockGatewayClient = GatewayClient as jest.MockedClass<typeof GatewayClient>;
|
|
8
|
-
|
|
9
|
-
type EventHandler = (event: { event: string; payload: any }) => void;
|
|
10
|
-
|
|
11
|
-
function makeAdapter(): {
|
|
12
|
-
adapter: OpenclawGatewayAdapter;
|
|
13
|
-
mock: jest.Mocked<GatewayClient>;
|
|
14
|
-
simulateStreamEvent: (payload: Record<string, unknown>) => void;
|
|
15
|
-
} {
|
|
16
|
-
const mock: jest.Mocked<GatewayClient> = {
|
|
17
|
-
connect: jest.fn().mockResolvedValue(undefined),
|
|
18
|
-
disconnect: jest.fn(),
|
|
19
|
-
isConnected: jest.fn().mockReturnValue(false),
|
|
20
|
-
request: jest.fn().mockResolvedValue(undefined),
|
|
21
|
-
setEventHandler: jest.fn(),
|
|
22
|
-
} as unknown as jest.Mocked<GatewayClient>;
|
|
23
|
-
|
|
24
|
-
MockGatewayClient.mockImplementation(() => mock);
|
|
25
|
-
|
|
26
|
-
const adapter = new OpenclawGatewayAdapter('ws://localhost', 'token');
|
|
27
|
-
|
|
28
|
-
// Capture the event handler registered in the constructor
|
|
29
|
-
const capturedHandler: EventHandler = mock.setEventHandler.mock.calls[0][0];
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
adapter,
|
|
33
|
-
mock,
|
|
34
|
-
simulateStreamEvent: (payload) => capturedHandler({ event: 'chat.stream', payload }),
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
jest.clearAllMocks();
|
|
40
|
-
MockGatewayClient.mockClear();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('OpenclawGatewayAdapter – construction', () => {
|
|
44
|
-
it('creates a GatewayClient and registers an event handler', () => {
|
|
45
|
-
const { mock } = makeAdapter();
|
|
46
|
-
expect(MockGatewayClient).toHaveBeenCalledTimes(1);
|
|
47
|
-
expect(mock.setEventHandler).toHaveBeenCalledTimes(1);
|
|
48
|
-
expect(mock.setEventHandler).toHaveBeenCalledWith(expect.any(Function));
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('OpenclawGatewayAdapter – isConnected()', () => {
|
|
53
|
-
it('delegates to GatewayClient.isConnected()', () => {
|
|
54
|
-
const { adapter, mock } = makeAdapter();
|
|
55
|
-
mock.isConnected.mockReturnValueOnce(false);
|
|
56
|
-
expect(adapter.isConnected()).toBe(false);
|
|
57
|
-
mock.isConnected.mockReturnValueOnce(true);
|
|
58
|
-
expect(adapter.isConnected()).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('OpenclawGatewayAdapter – connect()', () => {
|
|
63
|
-
it('delegates to GatewayClient.connect()', async () => {
|
|
64
|
-
const { adapter, mock } = makeAdapter();
|
|
65
|
-
await adapter.connect();
|
|
66
|
-
expect(mock.connect).toHaveBeenCalledTimes(1);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('propagates rejection from GatewayClient.connect()', async () => {
|
|
70
|
-
const { adapter, mock } = makeAdapter();
|
|
71
|
-
mock.connect.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
72
|
-
await expect(adapter.connect()).rejects.toThrow('ECONNREFUSED');
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('OpenclawGatewayAdapter – disconnect()', () => {
|
|
77
|
-
it('delegates to GatewayClient.disconnect()', () => {
|
|
78
|
-
const { adapter, mock } = makeAdapter();
|
|
79
|
-
adapter.disconnect();
|
|
80
|
-
expect(mock.disconnect).toHaveBeenCalledTimes(1);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('OpenclawGatewayAdapter – listSessions()', () => {
|
|
85
|
-
it('calls request("sessions.list", {}) and maps result', async () => {
|
|
86
|
-
const { adapter, mock } = makeAdapter();
|
|
87
|
-
mock.request.mockResolvedValue({
|
|
88
|
-
sessions: [
|
|
89
|
-
{ key: 's1', sessionId: 'sid-1', kind: 'direct', updatedAt: 1000, model: 'claude-3' },
|
|
90
|
-
{ key: 's2', sessionId: 'sid-2', kind: 'project', updatedAt: 2000 },
|
|
91
|
-
],
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const result = await adapter.listSessions();
|
|
95
|
-
|
|
96
|
-
expect(mock.request).toHaveBeenCalledWith('sessions.list', {});
|
|
97
|
-
expect(result).toHaveLength(2);
|
|
98
|
-
expect(result[0]).toMatchObject({ key: 's1', sessionId: 'sid-1', kind: 'direct', updatedAt: 1000, model: 'claude-3' });
|
|
99
|
-
expect(result[1]).toMatchObject({ key: 's2', sessionId: 'sid-2', kind: 'project', updatedAt: 2000 });
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('uses default values for missing session fields', async () => {
|
|
103
|
-
const { adapter, mock } = makeAdapter();
|
|
104
|
-
mock.request.mockResolvedValue({ sessions: [{ key: 'k1' }] });
|
|
105
|
-
|
|
106
|
-
const result = await adapter.listSessions();
|
|
107
|
-
|
|
108
|
-
expect(result[0]).toMatchObject({ key: 'k1', sessionId: '', kind: 'direct', updatedAt: 0 });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('returns [] when result is null', async () => {
|
|
112
|
-
const { adapter, mock } = makeAdapter();
|
|
113
|
-
mock.request.mockResolvedValue(null);
|
|
114
|
-
|
|
115
|
-
const result = await adapter.listSessions();
|
|
116
|
-
expect(result).toEqual([]);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('returns [] when sessions field is missing', async () => {
|
|
120
|
-
const { adapter, mock } = makeAdapter();
|
|
121
|
-
mock.request.mockResolvedValue({});
|
|
122
|
-
|
|
123
|
-
const result = await adapter.listSessions();
|
|
124
|
-
expect(result).toEqual([]);
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe('OpenclawGatewayAdapter – getHistory()', () => {
|
|
129
|
-
it('calls request("chat.history") with sessionKey and default limit 200', async () => {
|
|
130
|
-
const { adapter, mock } = makeAdapter();
|
|
131
|
-
mock.request.mockResolvedValue({ messages: [] });
|
|
132
|
-
|
|
133
|
-
await adapter.getHistory('sess-1');
|
|
134
|
-
|
|
135
|
-
expect(mock.request).toHaveBeenCalledWith('chat.history', { sessionKey: 'sess-1', limit: 200 });
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('passes custom limit when provided', async () => {
|
|
139
|
-
const { adapter, mock } = makeAdapter();
|
|
140
|
-
mock.request.mockResolvedValue({ messages: [] });
|
|
141
|
-
|
|
142
|
-
await adapter.getHistory('sess-1', 50);
|
|
143
|
-
|
|
144
|
-
expect(mock.request).toHaveBeenCalledWith('chat.history', { sessionKey: 'sess-1', limit: 50 });
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('filters out system and tool messages', async () => {
|
|
148
|
-
const { adapter, mock } = makeAdapter();
|
|
149
|
-
mock.request.mockResolvedValue({
|
|
150
|
-
messages: [
|
|
151
|
-
{ role: 'user', content: 'hello', timestamp: '2026-01-01T00:00:00Z' },
|
|
152
|
-
{ role: 'system', content: 'ignored' },
|
|
153
|
-
{ role: 'assistant', content: 'hi', timestamp: '2026-01-01T00:00:01Z' },
|
|
154
|
-
{ role: 'tool', content: 'ignored' },
|
|
155
|
-
],
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
const result = await adapter.getHistory('sess-1');
|
|
159
|
-
|
|
160
|
-
expect(result).toHaveLength(2);
|
|
161
|
-
expect(result[0].role).toBe('user');
|
|
162
|
-
expect(result[1].role).toBe('assistant');
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('concatenates text blocks when content is an array', async () => {
|
|
166
|
-
const { adapter, mock } = makeAdapter();
|
|
167
|
-
mock.request.mockResolvedValue({
|
|
168
|
-
messages: [
|
|
169
|
-
{
|
|
170
|
-
role: 'user',
|
|
171
|
-
content: [
|
|
172
|
-
{ type: 'text', text: 'foo' },
|
|
173
|
-
{ type: 'image', data: 'ignored' },
|
|
174
|
-
{ type: 'text', text: 'bar' },
|
|
175
|
-
],
|
|
176
|
-
timestamp: 0,
|
|
177
|
-
},
|
|
178
|
-
],
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const result = await adapter.getHistory('sess-1');
|
|
182
|
-
expect(result[0].content).toBe('foobar');
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('uses string content directly', async () => {
|
|
186
|
-
const { adapter, mock } = makeAdapter();
|
|
187
|
-
mock.request.mockResolvedValue({
|
|
188
|
-
messages: [{ role: 'user', content: 'plain text', timestamp: '2026-01-01T00:00:00Z' }],
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const result = await adapter.getHistory('sess-1');
|
|
192
|
-
expect(result[0].content).toBe('plain text');
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('converts numeric timestamp to ISO string', async () => {
|
|
196
|
-
const { adapter, mock } = makeAdapter();
|
|
197
|
-
const ts = Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
198
|
-
mock.request.mockResolvedValue({
|
|
199
|
-
messages: [{ role: 'user', content: 'hi', timestamp: ts }],
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
const result = await adapter.getHistory('sess-1');
|
|
203
|
-
expect(result[0].timestamp).toBe(new Date(ts).toISOString());
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('returns [] when result is null', async () => {
|
|
207
|
-
const { adapter, mock } = makeAdapter();
|
|
208
|
-
mock.request.mockResolvedValue(null);
|
|
209
|
-
|
|
210
|
-
const result = await adapter.getHistory('sess-1');
|
|
211
|
-
expect(result).toEqual([]);
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe('OpenclawGatewayAdapter – sendMessage()', () => {
|
|
216
|
-
it('sends chat.send request with correct payload', async () => {
|
|
217
|
-
const { adapter, mock } = makeAdapter();
|
|
218
|
-
mock.request.mockResolvedValue(undefined);
|
|
219
|
-
|
|
220
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hello', idempotencyKey: 'idem-1' });
|
|
221
|
-
|
|
222
|
-
expect(mock.request).toHaveBeenCalledWith('chat.send', {
|
|
223
|
-
sessionKey: 'sess-1',
|
|
224
|
-
message: 'hello',
|
|
225
|
-
deliver: false,
|
|
226
|
-
idempotencyKey: 'idem-1',
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('includes attachments in payload when provided', async () => {
|
|
231
|
-
const { adapter, mock } = makeAdapter();
|
|
232
|
-
mock.request.mockResolvedValue(undefined);
|
|
233
|
-
const attachments = [{ type: 'image' as const, mimeType: 'image/png', content: 'base64data' }];
|
|
234
|
-
|
|
235
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', attachments });
|
|
236
|
-
|
|
237
|
-
const [, payload] = mock.request.mock.calls[0] as [string, any];
|
|
238
|
-
expect(payload.attachments).toEqual(attachments);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('omits attachments field when not provided', async () => {
|
|
242
|
-
const { adapter, mock } = makeAdapter();
|
|
243
|
-
mock.request.mockResolvedValue(undefined);
|
|
244
|
-
|
|
245
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1' });
|
|
246
|
-
|
|
247
|
-
const [, payload] = mock.request.mock.calls[0] as [string, any];
|
|
248
|
-
expect(payload.attachments).toBeUndefined();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('calls onError and removes callback when request throws', async () => {
|
|
252
|
-
const { adapter, mock } = makeAdapter();
|
|
253
|
-
mock.request.mockRejectedValue(new Error('network fail'));
|
|
254
|
-
const onError = jest.fn();
|
|
255
|
-
|
|
256
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onError });
|
|
257
|
-
|
|
258
|
-
expect(onError).toHaveBeenCalledWith('network fail');
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('calls onDelta when delta stream event arrives', async () => {
|
|
262
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
263
|
-
mock.request.mockResolvedValue(undefined);
|
|
264
|
-
const onDelta = jest.fn();
|
|
265
|
-
|
|
266
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDelta });
|
|
267
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'delta', message: 'partial text' });
|
|
268
|
-
|
|
269
|
-
expect(onDelta).toHaveBeenCalledWith('partial text');
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it('calls onDone with accumulated text on final event', async () => {
|
|
273
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
274
|
-
mock.request.mockResolvedValue(undefined);
|
|
275
|
-
const onDone = jest.fn();
|
|
276
|
-
|
|
277
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDone });
|
|
278
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'delta', message: 'full text' });
|
|
279
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'final' });
|
|
280
|
-
|
|
281
|
-
expect(onDone).toHaveBeenCalledWith('full text');
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('calls onDone on aborted event with last accumulated text', async () => {
|
|
285
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
286
|
-
mock.request.mockResolvedValue(undefined);
|
|
287
|
-
const onDone = jest.fn();
|
|
288
|
-
|
|
289
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDone });
|
|
290
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'delta', message: 'partial' });
|
|
291
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'aborted' });
|
|
292
|
-
|
|
293
|
-
expect(onDone).toHaveBeenCalledWith('partial');
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it('calls onError when error stream event arrives with string error', async () => {
|
|
297
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
298
|
-
mock.request.mockResolvedValue(undefined);
|
|
299
|
-
const onError = jest.fn();
|
|
300
|
-
|
|
301
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onError });
|
|
302
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'error', error: 'stream error' });
|
|
303
|
-
|
|
304
|
-
expect(onError).toHaveBeenCalledWith('stream error');
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('calls onError with error.message when error is an object', async () => {
|
|
308
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
309
|
-
mock.request.mockResolvedValue(undefined);
|
|
310
|
-
const onError = jest.fn();
|
|
311
|
-
|
|
312
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onError });
|
|
313
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'k1', state: 'error', error: { message: 'obj error' } });
|
|
314
|
-
|
|
315
|
-
expect(onError).toHaveBeenCalledWith('obj error');
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('ignores stream events with unknown runId', async () => {
|
|
319
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
320
|
-
mock.request.mockResolvedValue(undefined);
|
|
321
|
-
const onDelta = jest.fn();
|
|
322
|
-
|
|
323
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDelta });
|
|
324
|
-
simulateStreamEvent({ sessionKey: 'sess-1', runId: 'unknown-id', state: 'delta', message: 'x' });
|
|
325
|
-
|
|
326
|
-
expect(onDelta).not.toHaveBeenCalled();
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('ignores events with missing sessionKey in payload', async () => {
|
|
330
|
-
const { adapter, mock, simulateStreamEvent } = makeAdapter();
|
|
331
|
-
mock.request.mockResolvedValue(undefined);
|
|
332
|
-
const onDelta = jest.fn();
|
|
333
|
-
|
|
334
|
-
await adapter.sendMessage({ sessionKey: 'sess-1', text: 'hi', idempotencyKey: 'k1', onDelta });
|
|
335
|
-
simulateStreamEvent({ runId: 'k1', state: 'delta', message: 'x' } as any);
|
|
336
|
-
|
|
337
|
-
expect(onDelta).not.toHaveBeenCalled();
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
describe('OpenclawGatewayAdapter – abort()', () => {
|
|
342
|
-
it('calls chat.abort with sessionKey and runId', async () => {
|
|
343
|
-
const { adapter, mock } = makeAdapter();
|
|
344
|
-
mock.request.mockResolvedValue(undefined);
|
|
345
|
-
|
|
346
|
-
await adapter.abort('sess-1', 'run-1');
|
|
347
|
-
|
|
348
|
-
expect(mock.request).toHaveBeenCalledWith('chat.abort', { sessionKey: 'sess-1', runId: 'run-1' });
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('calls chat.abort without runId when not provided', async () => {
|
|
352
|
-
const { adapter, mock } = makeAdapter();
|
|
353
|
-
mock.request.mockResolvedValue(undefined);
|
|
354
|
-
|
|
355
|
-
await adapter.abort('sess-1');
|
|
356
|
-
|
|
357
|
-
expect(mock.request).toHaveBeenCalledWith('chat.abort', { sessionKey: 'sess-1', runId: undefined });
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it('does not throw when request rejects', async () => {
|
|
361
|
-
const { adapter, mock } = makeAdapter();
|
|
362
|
-
mock.request.mockRejectedValue(new Error('abort failed'));
|
|
363
|
-
|
|
364
|
-
await expect(adapter.abort('sess-1', 'run-1')).resolves.toBeUndefined();
|
|
365
|
-
});
|
|
366
|
-
});
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
// Mock ws before importing anything that uses it.
|
|
2
|
-
// The 'instances' array lives inside the factory closure and is exposed via the
|
|
3
|
-
// static property so tests can grab the last created socket.
|
|
4
|
-
jest.mock('ws', () => {
|
|
5
|
-
const { EventEmitter } = require('node:events');
|
|
6
|
-
const instances: any[] = [];
|
|
7
|
-
|
|
8
|
-
class MockWebSocket extends EventEmitter {
|
|
9
|
-
static OPEN = 1;
|
|
10
|
-
static instances = instances;
|
|
11
|
-
readyState = 1;
|
|
12
|
-
send = jest.fn();
|
|
13
|
-
close = jest.fn(() => (this as any).emit('close'));
|
|
14
|
-
|
|
15
|
-
constructor() {
|
|
16
|
-
super();
|
|
17
|
-
instances.push(this);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return MockWebSocket;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
import WebSocket from 'ws';
|
|
25
|
-
import { GatewayClient } from '../src/openclaw/gateway-client';
|
|
26
|
-
|
|
27
|
-
const WS = WebSocket as any;
|
|
28
|
-
|
|
29
|
-
function lastWs(): any {
|
|
30
|
-
return WS.instances[WS.instances.length - 1];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Complete the connect handshake (challenge → auth → resolve).
|
|
34
|
-
async function doConnect(client: GatewayClient): Promise<any> {
|
|
35
|
-
const connectPromise = client.connect();
|
|
36
|
-
const ws = lastWs();
|
|
37
|
-
|
|
38
|
-
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
39
|
-
await Promise.resolve(); // let the async handler send the auth request
|
|
40
|
-
|
|
41
|
-
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
42
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: true, payload: {} }));
|
|
43
|
-
await connectPromise;
|
|
44
|
-
|
|
45
|
-
ws.send.mockClear();
|
|
46
|
-
return ws;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
beforeEach(() => {
|
|
50
|
-
WS.instances.length = 0;
|
|
51
|
-
jest.clearAllMocks();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe('GatewayClient – initial state', () => {
|
|
55
|
-
it('is not connected before connect()', () => {
|
|
56
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
57
|
-
expect(client.isConnected()).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('setEventHandler does not throw', () => {
|
|
61
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
62
|
-
expect(() => client.setEventHandler(jest.fn())).not.toThrow();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe('GatewayClient – request() before connect', () => {
|
|
67
|
-
it('rejects with "Not connected" when ws is null', async () => {
|
|
68
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
69
|
-
await expect(client.request('test', {})).rejects.toThrow('Not connected');
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('GatewayClient – disconnect()', () => {
|
|
74
|
-
it('is safe to call when not connected', () => {
|
|
75
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
76
|
-
expect(() => client.disconnect()).not.toThrow();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('marks client as not connected after connect → disconnect', async () => {
|
|
80
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
81
|
-
await doConnect(client);
|
|
82
|
-
|
|
83
|
-
expect(client.isConnected()).toBe(true);
|
|
84
|
-
client.disconnect();
|
|
85
|
-
expect(client.isConnected()).toBe(false);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('rejects pending requests on disconnect', async () => {
|
|
89
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
90
|
-
const ws = await doConnect(client);
|
|
91
|
-
|
|
92
|
-
const pending = client.request('slow.method', {});
|
|
93
|
-
client.disconnect();
|
|
94
|
-
|
|
95
|
-
await expect(pending).rejects.toThrow('Connection closed');
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe('GatewayClient – connect()', () => {
|
|
100
|
-
it('creates WebSocket with the given url', async () => {
|
|
101
|
-
const client = new GatewayClient('ws://test-host:8000', 'tok');
|
|
102
|
-
await doConnect(client);
|
|
103
|
-
expect(WS.instances).toHaveLength(1);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('sends auth request containing the token after connect.challenge', async () => {
|
|
107
|
-
const client = new GatewayClient('ws://localhost', 'secret-token');
|
|
108
|
-
const connectPromise = client.connect();
|
|
109
|
-
const ws = lastWs();
|
|
110
|
-
|
|
111
|
-
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
112
|
-
await Promise.resolve();
|
|
113
|
-
|
|
114
|
-
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
115
|
-
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
116
|
-
expect(msg.method).toBe('connect');
|
|
117
|
-
expect(msg.params.auth.token).toBe('secret-token');
|
|
118
|
-
expect(msg.type).toBe('req');
|
|
119
|
-
|
|
120
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: {} }));
|
|
121
|
-
await connectPromise;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('resolves and sets isConnected to true after successful auth', async () => {
|
|
125
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
126
|
-
await doConnect(client);
|
|
127
|
-
expect(client.isConnected()).toBe(true);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('rejects when WebSocket emits error before connecting', async () => {
|
|
131
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
132
|
-
const connectPromise = client.connect();
|
|
133
|
-
lastWs().emit('error', new Error('ECONNREFUSED'));
|
|
134
|
-
await expect(connectPromise).rejects.toThrow('ECONNREFUSED');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('rejects when auth response is an error', async () => {
|
|
138
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
139
|
-
const connectPromise = client.connect();
|
|
140
|
-
const ws = lastWs();
|
|
141
|
-
|
|
142
|
-
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
143
|
-
await Promise.resolve();
|
|
144
|
-
|
|
145
|
-
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
146
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { message: 'Unauthorized' } }));
|
|
147
|
-
|
|
148
|
-
await expect(connectPromise).rejects.toThrow('Unauthorized');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('ignores events other than connect.challenge during handshake', async () => {
|
|
152
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
153
|
-
const connectPromise = client.connect();
|
|
154
|
-
const ws = lastWs();
|
|
155
|
-
|
|
156
|
-
ws.emit('message', JSON.stringify({ type: 'event', event: 'other.event', payload: {} }));
|
|
157
|
-
await Promise.resolve();
|
|
158
|
-
expect(ws.send).not.toHaveBeenCalled();
|
|
159
|
-
|
|
160
|
-
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
161
|
-
await Promise.resolve();
|
|
162
|
-
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
163
|
-
|
|
164
|
-
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
165
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: {} }));
|
|
166
|
-
await connectPromise;
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('restores original event handler after auth completes', async () => {
|
|
170
|
-
const originalHandler = jest.fn();
|
|
171
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
172
|
-
client.setEventHandler(originalHandler);
|
|
173
|
-
const ws = await doConnect(client);
|
|
174
|
-
|
|
175
|
-
// Post-connect event should reach the original handler
|
|
176
|
-
ws.emit('message', JSON.stringify({ type: 'event', event: 'some.event', payload: { x: 1 } }));
|
|
177
|
-
await Promise.resolve();
|
|
178
|
-
|
|
179
|
-
expect(originalHandler).toHaveBeenCalledWith(expect.objectContaining({ event: 'some.event' }));
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
describe('GatewayClient – request() after connect', () => {
|
|
184
|
-
it('sends request with correct type, method, params, and id fields', async () => {
|
|
185
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
186
|
-
const ws = await doConnect(client);
|
|
187
|
-
|
|
188
|
-
const reqPromise = client.request('sessions.list', { limit: 10 });
|
|
189
|
-
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
190
|
-
|
|
191
|
-
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
192
|
-
expect(sent.type).toBe('req');
|
|
193
|
-
expect(sent.method).toBe('sessions.list');
|
|
194
|
-
expect(sent.params).toEqual({ limit: 10 });
|
|
195
|
-
expect(typeof sent.id).toBe('string');
|
|
196
|
-
|
|
197
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: true, payload: { sessions: [] } }));
|
|
198
|
-
const result = await reqPromise;
|
|
199
|
-
expect(result.sessions).toEqual([]);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('rejects when response has ok:false', async () => {
|
|
203
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
204
|
-
const ws = await doConnect(client);
|
|
205
|
-
|
|
206
|
-
const reqPromise = client.request('sessions.list', {});
|
|
207
|
-
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
208
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: false, error: { message: 'Not found' } }));
|
|
209
|
-
|
|
210
|
-
await expect(reqPromise).rejects.toThrow('Not found');
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('rejects with generic message when error has no message field', async () => {
|
|
214
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
215
|
-
const ws = await doConnect(client);
|
|
216
|
-
|
|
217
|
-
const reqPromise = client.request('test', {});
|
|
218
|
-
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
219
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: false }));
|
|
220
|
-
|
|
221
|
-
await expect(reqPromise).rejects.toThrow('Request failed');
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('ignores response for an unknown request id', async () => {
|
|
225
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
226
|
-
const ws = await doConnect(client);
|
|
227
|
-
|
|
228
|
-
const reqPromise = client.request('test', {});
|
|
229
|
-
ws.emit('message', JSON.stringify({ type: 'res', id: 'stale-id', ok: true, payload: {} }));
|
|
230
|
-
await Promise.resolve();
|
|
231
|
-
|
|
232
|
-
let resolved = false;
|
|
233
|
-
reqPromise.then(() => { resolved = true; }).catch(() => {});
|
|
234
|
-
await Promise.resolve();
|
|
235
|
-
expect(resolved).toBe(false);
|
|
236
|
-
|
|
237
|
-
// Clean up to avoid open handles
|
|
238
|
-
client.disconnect();
|
|
239
|
-
await reqPromise.catch(() => {});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('ignores messages with unknown type', async () => {
|
|
243
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
244
|
-
const ws = await doConnect(client);
|
|
245
|
-
|
|
246
|
-
expect(() => {
|
|
247
|
-
ws.emit('message', JSON.stringify({ type: 'unknown_type', id: 'x' }));
|
|
248
|
-
}).not.toThrow();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('ignores malformed JSON messages', async () => {
|
|
252
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
253
|
-
const ws = await doConnect(client);
|
|
254
|
-
|
|
255
|
-
expect(() => {
|
|
256
|
-
ws.emit('message', '{ bad json');
|
|
257
|
-
}).not.toThrow();
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
describe('GatewayClient – connection close', () => {
|
|
262
|
-
it('marks as disconnected and rejects pending requests when connection closes', async () => {
|
|
263
|
-
const client = new GatewayClient('ws://localhost', 'token');
|
|
264
|
-
const ws = await doConnect(client);
|
|
265
|
-
|
|
266
|
-
const pendingReq = client.request('test', {});
|
|
267
|
-
ws.emit('close');
|
|
268
|
-
|
|
269
|
-
await expect(pendingReq).rejects.toThrow('Connection closed');
|
|
270
|
-
expect(client.isConnected()).toBe(false);
|
|
271
|
-
});
|
|
272
|
-
});
|