agent-relay 3.1.1 → 3.1.2
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/package.json +8 -8
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-control.test.js +250 -0
- package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js +617 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/spawn-manager.test.js +29 -0
- package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/ws-client.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js +324 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -0
- package/packages/openclaw/dist/cli.js +1 -1
- package/packages/openclaw/dist/cli.js.map +1 -1
- package/packages/openclaw/dist/gateway.d.ts +33 -7
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +101 -50
- package/packages/openclaw/dist/gateway.js.map +1 -1
- package/packages/openclaw/dist/types.d.ts +5 -1
- package/packages/openclaw/dist/types.d.ts.map +1 -1
- package/packages/openclaw/package.json +2 -2
- package/packages/openclaw/skill/SKILL.md +35 -13
- package/packages/openclaw/src/__tests__/SPEC-ws-client-testing.md +192 -0
- package/packages/openclaw/src/__tests__/gateway-control.test.ts +288 -0
- package/packages/openclaw/src/__tests__/gateway-threads.test.ts +746 -0
- package/packages/openclaw/src/__tests__/spawn-manager.test.ts +37 -0
- package/packages/openclaw/src/__tests__/ws-client.test.ts +395 -0
- package/packages/openclaw/src/cli.ts +1 -1
- package/packages/openclaw/src/gateway.ts +129 -56
- package/packages/openclaw/src/types.ts +5 -1
- package/packages/policy/package.json +2 -2
- package/packages/sdk/package.json +2 -2
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Spec: OpenClawGatewayClient WebSocket Testing
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
The `OpenClawGatewayClient` class in `gateway.ts` handles WebSocket connection, Ed25519 challenge-response auth, RPC message delivery, and automatic reconnection. It currently has 0% test coverage because it opens real WebSocket connections that are hard to mock.
|
|
6
|
+
|
|
7
|
+
## Approach: In-process Mock WebSocket Server
|
|
8
|
+
|
|
9
|
+
Use the `ws` package (already a dependency) to spin up a lightweight `WebSocketServer` on a random port within the test process. The mock server implements just enough of the OpenClaw gateway protocol to exercise all client code paths.
|
|
10
|
+
|
|
11
|
+
## Test Infrastructure
|
|
12
|
+
|
|
13
|
+
### MockOpenClawServer
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
17
|
+
import { AddressInfo } from 'node:net';
|
|
18
|
+
|
|
19
|
+
class MockOpenClawServer {
|
|
20
|
+
private wss: WebSocketServer;
|
|
21
|
+
port: number;
|
|
22
|
+
connections: WebSocket[] = [];
|
|
23
|
+
receivedMessages: Record<string, unknown>[] = [];
|
|
24
|
+
|
|
25
|
+
/** Control flags — tests toggle these to simulate server behavior. */
|
|
26
|
+
rejectAuth = false;
|
|
27
|
+
skipChallenge = false;
|
|
28
|
+
rpcDelay = 0; // ms delay before responding to RPCs
|
|
29
|
+
rpcError = false; // respond to chat.send with an error
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
this.wss = new WebSocketServer({ port: 0 }); // random port
|
|
33
|
+
this.port = (this.wss.address() as AddressInfo).port;
|
|
34
|
+
this.wss.on('connection', (ws) => this.handleConnection(ws));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private handleConnection(ws: WebSocket): void {
|
|
38
|
+
this.connections.push(ws);
|
|
39
|
+
|
|
40
|
+
// Step 1: Send connect.challenge
|
|
41
|
+
if (!this.skipChallenge) {
|
|
42
|
+
ws.send(JSON.stringify({
|
|
43
|
+
type: 'event',
|
|
44
|
+
event: 'connect.challenge',
|
|
45
|
+
payload: { nonce: 'test-nonce-123', ts: Date.now() },
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ws.on('message', (data) => {
|
|
50
|
+
const msg = JSON.parse(data.toString());
|
|
51
|
+
this.receivedMessages.push(msg);
|
|
52
|
+
|
|
53
|
+
// Step 2: Handle connect request
|
|
54
|
+
if (msg.method === 'connect') {
|
|
55
|
+
ws.send(JSON.stringify({
|
|
56
|
+
type: 'res',
|
|
57
|
+
id: msg.id,
|
|
58
|
+
ok: !this.rejectAuth,
|
|
59
|
+
...(this.rejectAuth ? { error: 'auth rejected' } : {}),
|
|
60
|
+
}));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Step 3: Handle chat.send RPC
|
|
65
|
+
if (msg.method === 'chat.send') {
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
ws.send(JSON.stringify({
|
|
68
|
+
type: 'res',
|
|
69
|
+
id: msg.id,
|
|
70
|
+
ok: !this.rpcError,
|
|
71
|
+
...(this.rpcError
|
|
72
|
+
? { error: 'delivery failed' }
|
|
73
|
+
: { payload: { runId: 'run_1', status: 'ok' } }),
|
|
74
|
+
}));
|
|
75
|
+
}, this.rpcDelay);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Forcibly close all connections (simulates server crash). */
|
|
82
|
+
disconnectAll(): void {
|
|
83
|
+
for (const ws of this.connections) ws.close(1006);
|
|
84
|
+
this.connections = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async close(): Promise<void> {
|
|
88
|
+
this.disconnectAll();
|
|
89
|
+
return new Promise((resolve) => this.wss.close(() => resolve()));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Test Cases
|
|
95
|
+
|
|
96
|
+
### 1. Connection & Authentication
|
|
97
|
+
|
|
98
|
+
| Test | What it validates |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `should connect and authenticate via challenge-response` | Full happy path: challenge → sign → connect response |
|
|
101
|
+
| `should reject when server denies auth` | `connect()` rejects when server returns `ok: false` |
|
|
102
|
+
| `should timeout if no challenge arrives` | `connect()` rejects after `CONNECT_TIMEOUT_MS` when `skipChallenge=true` |
|
|
103
|
+
| `should resolve immediately if already connected` | Second `connect()` call is a no-op |
|
|
104
|
+
|
|
105
|
+
### 2. Message Delivery (chat.send RPC)
|
|
106
|
+
|
|
107
|
+
| Test | What it validates |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `should send chat.send and resolve true on success` | `sendChatMessage()` returns `true` |
|
|
110
|
+
| `should send idempotencyKey when provided` | Verify `params.idempotencyKey` in received message |
|
|
111
|
+
| `should resolve false when RPC returns error` | `rpcError=true` → returns `false` |
|
|
112
|
+
| `should resolve false on RPC timeout` | `rpcDelay=20000` → hits 15s timeout, returns `false` |
|
|
113
|
+
| `should reconnect and retry if not connected` | Disconnect, call `sendChatMessage`, verify reconnection |
|
|
114
|
+
|
|
115
|
+
### 3. Reconnection
|
|
116
|
+
|
|
117
|
+
| Test | What it validates |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `should reconnect after server disconnects` | `disconnectAll()` → client reconnects within ~3s |
|
|
120
|
+
| `should not reconnect after stop()` | `disconnect()` then `disconnectAll()` → no reconnection |
|
|
121
|
+
| `should reject pending RPCs on disconnect` | In-flight `sendChatMessage` resolves `false` on disconnect |
|
|
122
|
+
|
|
123
|
+
### 4. Ed25519 Signature Verification
|
|
124
|
+
|
|
125
|
+
| Test | What it validates |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `should produce valid Ed25519 signature` | Mock server verifies the signature using the client's public key from the connect payload |
|
|
128
|
+
| `should include correct v3 payload fields` | Verify clientId, clientMode, platform, role, scopes, nonce |
|
|
129
|
+
|
|
130
|
+
## Implementation Notes
|
|
131
|
+
|
|
132
|
+
- Each test creates its own `MockOpenClawServer` and `OpenClawGatewayClient` for full isolation.
|
|
133
|
+
- The `OpenClawGatewayClient` class is currently not exported. Either:
|
|
134
|
+
- (a) Export it (simplest), or
|
|
135
|
+
- (b) Test indirectly through `InboundGateway` with a real mock WS server (heavier but no API changes).
|
|
136
|
+
- Recommended: export the class with a `@internal` JSDoc tag.
|
|
137
|
+
- Tests should use `afterEach` to close both the mock server and client to prevent port leaks.
|
|
138
|
+
|
|
139
|
+
## E2E Integration Tests
|
|
140
|
+
|
|
141
|
+
Separate from the WS unit tests, create integration tests following the broker harness pattern in `tests/integration/broker/`:
|
|
142
|
+
|
|
143
|
+
### Test: Full gateway message flow with real Relaycast
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
1. Create ephemeral Relaycast workspace (RelayCast.createWorkspace)
|
|
147
|
+
2. Register two agents: "sender" and "viewer-test-claw"
|
|
148
|
+
3. Start InboundGateway with the workspace key
|
|
149
|
+
4. Post a message to #general via sender agent
|
|
150
|
+
5. Assert the gateway's relaySender.sendMessage was called with correct format
|
|
151
|
+
6. Post a DM from sender to viewer-test-claw
|
|
152
|
+
7. Assert DM delivery with [relaycast:dm] format
|
|
153
|
+
8. Add a reaction via sender
|
|
154
|
+
9. Assert reaction soft notification delivery
|
|
155
|
+
10. Cleanup: stop gateway, workspace is ephemeral
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Test: Gateway reconnection resilience
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
1. Start gateway with real Relaycast connection
|
|
162
|
+
2. Force-disconnect the SDK WebSocket (call relayAgentClient.disconnect())
|
|
163
|
+
3. Wait for reconnection
|
|
164
|
+
4. Post a message
|
|
165
|
+
5. Assert message is still delivered
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Prerequisites
|
|
169
|
+
|
|
170
|
+
These tests require network access to `api.relaycast.dev` and should:
|
|
171
|
+
- Use `checkPrerequisites()` pattern from broker harness
|
|
172
|
+
- Be skippable via `skipIfMissing()`
|
|
173
|
+
- Have generous timeouts (120s)
|
|
174
|
+
- Use unique channel/agent names with timestamp suffixes
|
|
175
|
+
|
|
176
|
+
## File Locations
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
packages/openclaw/src/__tests__/
|
|
180
|
+
gateway-threads.test.ts # Existing unit tests (vitest)
|
|
181
|
+
ws-client.test.ts # NEW: WebSocket client unit tests (vitest)
|
|
182
|
+
|
|
183
|
+
tests/integration/openclaw/
|
|
184
|
+
gateway-e2e.test.ts # NEW: Full integration tests (node:test)
|
|
185
|
+
utils/gateway-harness.ts # NEW: Gateway test harness
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Estimated Effort
|
|
189
|
+
|
|
190
|
+
- WS client unit tests: ~2-3 hours
|
|
191
|
+
- E2E integration tests: ~3-4 hours
|
|
192
|
+
- Total: ~1 day
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mocks
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const eventHandlers: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
9
|
+
|
|
10
|
+
function registerHandler(event: string) {
|
|
11
|
+
return (handler: (...args: unknown[]) => void) => {
|
|
12
|
+
if (!eventHandlers[event]) eventHandlers[event] = [];
|
|
13
|
+
eventHandlers[event].push(handler);
|
|
14
|
+
return () => {
|
|
15
|
+
eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler);
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const mockAgentClient = {
|
|
21
|
+
connect: vi.fn(),
|
|
22
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
subscribe: vi.fn(),
|
|
24
|
+
channels: {
|
|
25
|
+
join: vi.fn().mockResolvedValue({ ok: true }),
|
|
26
|
+
create: vi.fn().mockResolvedValue({ name: 'general' }),
|
|
27
|
+
},
|
|
28
|
+
on: {
|
|
29
|
+
connected: registerHandler('connected'),
|
|
30
|
+
messageCreated: registerHandler('messageCreated'),
|
|
31
|
+
threadReply: registerHandler('threadReply'),
|
|
32
|
+
dmReceived: registerHandler('dmReceived'),
|
|
33
|
+
groupDmReceived: registerHandler('groupDmReceived'),
|
|
34
|
+
commandInvoked: registerHandler('commandInvoked'),
|
|
35
|
+
reactionAdded: registerHandler('reactionAdded'),
|
|
36
|
+
reactionRemoved: registerHandler('reactionRemoved'),
|
|
37
|
+
reconnecting: registerHandler('reconnecting'),
|
|
38
|
+
disconnected: registerHandler('disconnected'),
|
|
39
|
+
error: registerHandler('error'),
|
|
40
|
+
any: registerHandler('any'),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
vi.mock('@relaycast/sdk', () => ({
|
|
45
|
+
RelayCast: vi.fn().mockImplementation(() => ({
|
|
46
|
+
agents: {
|
|
47
|
+
registerOrGet: vi.fn().mockResolvedValue({ name: 'viewer-test-claw', token: 'tok_test' }),
|
|
48
|
+
},
|
|
49
|
+
channels: { join: vi.fn().mockResolvedValue({ ok: true }) },
|
|
50
|
+
messages: { list: vi.fn().mockResolvedValue([]) },
|
|
51
|
+
as: vi.fn().mockReturnValue(mockAgentClient),
|
|
52
|
+
})),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const mockSpawnManager = {
|
|
56
|
+
size: 0,
|
|
57
|
+
spawn: vi.fn(),
|
|
58
|
+
release: vi.fn(),
|
|
59
|
+
releaseByName: vi.fn(),
|
|
60
|
+
releaseAll: vi.fn().mockResolvedValue(undefined),
|
|
61
|
+
list: vi.fn().mockReturnValue([]),
|
|
62
|
+
get: vi.fn(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
vi.mock('../spawn/manager.js', () => ({
|
|
66
|
+
SpawnManager: vi.fn().mockImplementation(() => mockSpawnManager),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.mock('node:fs/promises', () => ({
|
|
70
|
+
readFile: vi.fn().mockResolvedValue('{"spawns":[]}'),
|
|
71
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
72
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
vi.mock('node:fs', () => ({
|
|
76
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
// We do NOT mock node:http — we want a real HTTP server for these tests.
|
|
80
|
+
// But we need to intercept createServer in InboundGateway so we can control the port.
|
|
81
|
+
// Strategy: let gateway start its own server, then hit it via fetch().
|
|
82
|
+
|
|
83
|
+
// We need to override RELAYCAST_CONTROL_PORT to use port 0 (random)
|
|
84
|
+
// Actually, we can't use port 0 because the gateway hardcodes the listen call.
|
|
85
|
+
// Instead, let's mock node:http to capture the request handler, then run a real server.
|
|
86
|
+
|
|
87
|
+
let capturedHandler: ((req: IncomingMessage, res: ServerResponse) => void) | null = null;
|
|
88
|
+
let realServer: HttpServer | null = null;
|
|
89
|
+
let controlPort = 0;
|
|
90
|
+
|
|
91
|
+
vi.mock('node:http', async (importOriginal) => {
|
|
92
|
+
const actual = await importOriginal<typeof import('node:http')>();
|
|
93
|
+
return {
|
|
94
|
+
...actual,
|
|
95
|
+
createServer: vi.fn((handler: (req: IncomingMessage, res: ServerResponse) => void) => {
|
|
96
|
+
capturedHandler = handler;
|
|
97
|
+
// Create a real HTTP server with the captured handler
|
|
98
|
+
realServer = actual.createServer(handler);
|
|
99
|
+
return {
|
|
100
|
+
listen: vi.fn((_port: number, _host: string, cb: () => void) => {
|
|
101
|
+
// Bind to random port
|
|
102
|
+
realServer!.listen(0, '127.0.0.1', () => {
|
|
103
|
+
const addr = realServer!.address() as { port: number };
|
|
104
|
+
controlPort = addr.port;
|
|
105
|
+
cb();
|
|
106
|
+
});
|
|
107
|
+
}),
|
|
108
|
+
close: vi.fn((cb?: () => void) => {
|
|
109
|
+
realServer?.close(() => cb?.());
|
|
110
|
+
}),
|
|
111
|
+
address: vi.fn(() => realServer?.address()),
|
|
112
|
+
on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => {}),
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
import { InboundGateway } from '../gateway.js';
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Helpers
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async function fetchControl(method: string, path: string, body?: unknown): Promise<Response> {
|
|
125
|
+
const url = `http://127.0.0.1:${controlPort}${path}`;
|
|
126
|
+
const opts: RequestInit = { method };
|
|
127
|
+
if (body !== undefined) {
|
|
128
|
+
opts.body = JSON.stringify(body);
|
|
129
|
+
opts.headers = { 'Content-Type': 'application/json' };
|
|
130
|
+
}
|
|
131
|
+
return fetch(url, opts);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Tests
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe('Gateway control HTTP server', () => {
|
|
139
|
+
let gateway: InboundGateway;
|
|
140
|
+
|
|
141
|
+
beforeEach(async () => {
|
|
142
|
+
vi.clearAllMocks();
|
|
143
|
+
for (const key of Object.keys(eventHandlers)) {
|
|
144
|
+
eventHandlers[key] = [];
|
|
145
|
+
}
|
|
146
|
+
// Reset mock spawn manager state
|
|
147
|
+
mockSpawnManager.size = 0;
|
|
148
|
+
mockSpawnManager.spawn.mockReset();
|
|
149
|
+
mockSpawnManager.release.mockReset();
|
|
150
|
+
mockSpawnManager.releaseByName.mockReset();
|
|
151
|
+
mockSpawnManager.list.mockReturnValue([]);
|
|
152
|
+
|
|
153
|
+
gateway = new InboundGateway({
|
|
154
|
+
config: {
|
|
155
|
+
apiKey: 'rk_live_test',
|
|
156
|
+
clawName: 'test-claw',
|
|
157
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
158
|
+
channels: ['general'],
|
|
159
|
+
},
|
|
160
|
+
relaySender: { sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt_1' }) },
|
|
161
|
+
});
|
|
162
|
+
await gateway.start();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(async () => {
|
|
166
|
+
await gateway.stop();
|
|
167
|
+
if (realServer) {
|
|
168
|
+
realServer.close();
|
|
169
|
+
realServer = null;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('GET /health returns 200', async () => {
|
|
174
|
+
const res = await fetchControl('GET', '/health');
|
|
175
|
+
expect(res.status).toBe(200);
|
|
176
|
+
const data = await res.json() as Record<string, unknown>;
|
|
177
|
+
expect(data.ok).toBe(true);
|
|
178
|
+
expect(data.status).toBe('running');
|
|
179
|
+
expect(typeof data.uptime).toBe('number');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('POST /spawn with name returns 200', async () => {
|
|
183
|
+
mockSpawnManager.spawn.mockResolvedValue({
|
|
184
|
+
id: 'spawn-1',
|
|
185
|
+
displayName: 'worker-1',
|
|
186
|
+
agentName: 'claw-ws-worker-1',
|
|
187
|
+
gatewayPort: 18800,
|
|
188
|
+
});
|
|
189
|
+
mockSpawnManager.size = 1;
|
|
190
|
+
|
|
191
|
+
const res = await fetchControl('POST', '/spawn', {
|
|
192
|
+
name: 'worker-1',
|
|
193
|
+
role: 'researcher',
|
|
194
|
+
});
|
|
195
|
+
expect(res.status).toBe(200);
|
|
196
|
+
const data = await res.json() as Record<string, unknown>;
|
|
197
|
+
expect(data.ok).toBe(true);
|
|
198
|
+
expect(data.name).toBe('worker-1');
|
|
199
|
+
expect(data.agentName).toBe('claw-ws-worker-1');
|
|
200
|
+
expect(data.id).toBe('spawn-1');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('POST /spawn without name returns 400', async () => {
|
|
204
|
+
const res = await fetchControl('POST', '/spawn', { role: 'worker' });
|
|
205
|
+
expect(res.status).toBe(400);
|
|
206
|
+
const data = await res.json() as Record<string, unknown>;
|
|
207
|
+
expect(data.ok).toBe(false);
|
|
208
|
+
expect(data.error).toMatch(/name/i);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('POST /spawn error returns 500', async () => {
|
|
212
|
+
mockSpawnManager.spawn.mockRejectedValue(new Error('Docker unavailable'));
|
|
213
|
+
|
|
214
|
+
const res = await fetchControl('POST', '/spawn', { name: 'worker-1' });
|
|
215
|
+
expect(res.status).toBe(500);
|
|
216
|
+
const data = await res.json() as Record<string, unknown>;
|
|
217
|
+
expect(data.ok).toBe(false);
|
|
218
|
+
expect(data.error).toContain('Docker unavailable');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('GET /list returns 200 with empty list', async () => {
|
|
222
|
+
mockSpawnManager.list.mockReturnValue([]);
|
|
223
|
+
|
|
224
|
+
const res = await fetchControl('GET', '/list');
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
const data = await res.json() as Record<string, unknown>;
|
|
227
|
+
expect(data.ok).toBe(true);
|
|
228
|
+
expect(data.active).toBe(0);
|
|
229
|
+
expect(data.claws).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('GET /list returns handles', async () => {
|
|
233
|
+
mockSpawnManager.list.mockReturnValue([
|
|
234
|
+
{ id: 's1', displayName: 'alpha', agentName: 'claw-alpha', gatewayPort: 18801 },
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const res = await fetchControl('GET', '/list');
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
const data = await res.json() as { claws: Array<{ name: string }> };
|
|
240
|
+
expect(data.claws).toHaveLength(1);
|
|
241
|
+
expect(data.claws[0].name).toBe('alpha');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('POST /release by name returns 200', async () => {
|
|
245
|
+
mockSpawnManager.releaseByName.mockResolvedValue(true);
|
|
246
|
+
mockSpawnManager.size = 0;
|
|
247
|
+
|
|
248
|
+
const res = await fetchControl('POST', '/release', { name: 'worker-1' });
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
const data = await res.json() as Record<string, unknown>;
|
|
251
|
+
expect(data.ok).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('POST /release by id returns 200', async () => {
|
|
255
|
+
mockSpawnManager.release.mockResolvedValue(true);
|
|
256
|
+
mockSpawnManager.size = 0;
|
|
257
|
+
|
|
258
|
+
const res = await fetchControl('POST', '/release', { id: 'spawn-1' });
|
|
259
|
+
expect(res.status).toBe(200);
|
|
260
|
+
const data = await res.json() as Record<string, unknown>;
|
|
261
|
+
expect(data.ok).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('POST /release without name or id returns 400', async () => {
|
|
265
|
+
const res = await fetchControl('POST', '/release', {});
|
|
266
|
+
expect(res.status).toBe(400);
|
|
267
|
+
const data = await res.json() as Record<string, unknown>;
|
|
268
|
+
expect(data.ok).toBe(false);
|
|
269
|
+
expect(data.error).toMatch(/name.*id|id.*name/i);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('POST /release error returns 500', async () => {
|
|
273
|
+
mockSpawnManager.release.mockRejectedValue(new Error('Process kill failed'));
|
|
274
|
+
|
|
275
|
+
const res = await fetchControl('POST', '/release', { id: 'spawn-1' });
|
|
276
|
+
expect(res.status).toBe(500);
|
|
277
|
+
const data = await res.json() as Record<string, unknown>;
|
|
278
|
+
expect(data.ok).toBe(false);
|
|
279
|
+
expect(data.error).toContain('Process kill failed');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('GET /unknown returns 404', async () => {
|
|
283
|
+
const res = await fetchControl('GET', '/nonexistent');
|
|
284
|
+
expect(res.status).toBe(404);
|
|
285
|
+
const data = await res.json() as Record<string, unknown>;
|
|
286
|
+
expect(data.error).toBe('Not found');
|
|
287
|
+
});
|
|
288
|
+
});
|