@workclaw/openclaw-workclaw 1.0.16 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/index.ts +210 -210
- package/openclaw.plugin.json +1 -0
- package/package.json +12 -5
- package/setup-entry.ts +6 -0
- package/skills/openclaw-workclaw-cron/SKILL.md +45 -28
- package/src/accounts.ts +62 -37
- package/src/api/accounts-api.ts +88 -89
- package/src/api/prompts-api.ts +70 -77
- package/src/api/session-api.ts +99 -108
- package/src/api/skills-api.ts +35 -37
- package/src/api/workspace.ts +27 -29
- package/src/channel.ts +200 -202
- package/src/config-schema.ts +9 -9
- package/src/connection/workclaw-client.ts +554 -567
- package/src/gateway/agent-handlers.ts +392 -426
- package/src/gateway/config-writer.ts +228 -243
- package/src/gateway/message-context.ts +534 -362
- package/src/gateway/message-dispatcher.ts +529 -489
- package/src/gateway/reconnect.ts +217 -113
- package/src/gateway/skills-handler.ts +408 -472
- package/src/gateway/skills-list-handler.ts +9 -9
- package/src/gateway/tools-list-handler.ts +70 -72
- package/src/gateway/workclaw-gateway.ts +328 -486
- package/src/media/upload.ts +83 -94
- package/src/outbound/index.ts +57 -55
- package/src/outbound/workclaw-sender.ts +134 -133
- package/src/runtime.ts +291 -194
- package/src/send.ts +1 -1
- package/src/tools/openclaw-workclaw-cron/api/index.ts +6 -6
- package/src/tools/openclaw-workclaw-cron/src/add/params.ts +20 -19
- package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +2 -2
- package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +1 -1
- package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +3 -3
- package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +1 -1
- package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +3 -3
- package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +2 -2
- package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +1 -1
- package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +3 -3
- package/src/tools/openclaw-workclaw-cron/src/update/params.ts +195 -197
- package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +4 -4
- package/src/tools/openclaw-workclaw-system/src/get/index.ts +2 -2
- package/src/tools/openclaw-workclaw-system/src/token/index.ts +4 -4
- package/src/types.ts +38 -40
- package/src/utils/content.ts +16 -21
- package/tests/accounts.test.ts +285 -0
- package/tests/message-context.test.ts +313 -0
- package/tests/reconnect.test.ts +257 -0
- package/tests/workclaw-client.test.ts +112 -0
- package/tsconfig.json +8 -5
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for message parsing: parseInboundMessage, parseWorkClawMessage, buildInboundContext.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
parseInboundMessage,
|
|
8
|
+
parseWorkClawMessage,
|
|
9
|
+
buildInboundContext,
|
|
10
|
+
OPENCLAW_WORKCLAW_CHANNEL,
|
|
11
|
+
type InboundMessage,
|
|
12
|
+
} from '../src/gateway/message-context.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// parseInboundMessage
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
describe('parseInboundMessage', () => {
|
|
19
|
+
it('parses text from "text" field', () => {
|
|
20
|
+
const result = parseInboundMessage(JSON.stringify({ text: 'hello', from: 'user-1' }));
|
|
21
|
+
expect(result.text).toBe('hello');
|
|
22
|
+
expect(result.from).toBe('user-1');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('falls back to "message" field when "text" is absent', () => {
|
|
26
|
+
const result = parseInboundMessage(JSON.stringify({ message: 'hi there' }));
|
|
27
|
+
expect(result.text).toBe('hi there');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back to "body" field when "text" and "message" are absent', () => {
|
|
31
|
+
const result = parseInboundMessage(JSON.stringify({ body: 'body text' }));
|
|
32
|
+
expect(result.text).toBe('body text');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to raw data when no known field exists', () => {
|
|
36
|
+
const raw = 'plain text message';
|
|
37
|
+
const result = parseInboundMessage(raw);
|
|
38
|
+
expect(result.text).toBe(raw);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('extracts chatId into "to"', () => {
|
|
42
|
+
const result = parseInboundMessage(JSON.stringify({ chatId: 'chat-abc' }));
|
|
43
|
+
expect(result.to).toBe('chat-abc');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('sets chatType to "group" for group messages', () => {
|
|
47
|
+
const result = parseInboundMessage(JSON.stringify({ chatType: 'group' }));
|
|
48
|
+
expect(result.chatType).toBe('group');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sets chatType to "dm" for non-group messages', () => {
|
|
52
|
+
const result = parseInboundMessage(JSON.stringify({ chatType: 'dm' }));
|
|
53
|
+
expect(result.chatType).toBe('dm');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('defaults chatType to "dm" when absent', () => {
|
|
57
|
+
const result = parseInboundMessage(JSON.stringify({}));
|
|
58
|
+
expect(result.chatType).toBe('dm');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles non-JSON input gracefully', () => {
|
|
62
|
+
const result = parseInboundMessage('not json at all');
|
|
63
|
+
expect(result.text).toBe('not json at all');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('stores raw JSON payload when allowRawJsonPayload=true', () => {
|
|
67
|
+
const input = { text: 'hi', extra: 'data' };
|
|
68
|
+
const result = parseInboundMessage(JSON.stringify(input), true);
|
|
69
|
+
expect(result.rawPayload).toBe(JSON.stringify(input));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// parseWorkClawMessage
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
describe('parseWorkClawMessage', () => {
|
|
78
|
+
it('returns null for invalid JSON', () => {
|
|
79
|
+
const result = parseWorkClawMessage('not json');
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('parses SYSTEM/ping message', () => {
|
|
84
|
+
const msg = JSON.stringify({
|
|
85
|
+
type: 'SYSTEM',
|
|
86
|
+
metadata: { topic: 'ping' },
|
|
87
|
+
});
|
|
88
|
+
const result = parseWorkClawMessage(msg);
|
|
89
|
+
expect(result?.type).toBe('ping');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('parses SYSTEM/disconnect message', () => {
|
|
93
|
+
const msg = JSON.stringify({
|
|
94
|
+
type: 'SYSTEM',
|
|
95
|
+
metadata: { topic: 'disconnect' },
|
|
96
|
+
});
|
|
97
|
+
const result = parseWorkClawMessage(msg);
|
|
98
|
+
expect(result?.type).toBe('disconnect');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('ignores unknown SYSTEM topics', () => {
|
|
102
|
+
const msg = JSON.stringify({
|
|
103
|
+
type: 'SYSTEM',
|
|
104
|
+
metadata: { topic: 'unknown-topic' },
|
|
105
|
+
});
|
|
106
|
+
const result = parseWorkClawMessage(msg);
|
|
107
|
+
expect(result?.type).toBe('ignored');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('parses EVENT/AGENT_CREATED', () => {
|
|
111
|
+
const msg = JSON.stringify({
|
|
112
|
+
type: 'EVENT',
|
|
113
|
+
metadata: { event: 'AGENT_CREATED' },
|
|
114
|
+
data: { agentId: '123' },
|
|
115
|
+
});
|
|
116
|
+
const result = parseWorkClawMessage(msg);
|
|
117
|
+
expect(result?.type).toBe('agent_created');
|
|
118
|
+
expect(result?.eventData).toEqual({ agentId: '123' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('parses EVENT/AGENT_UPDATED', () => {
|
|
122
|
+
const msg = JSON.stringify({
|
|
123
|
+
type: 'EVENT',
|
|
124
|
+
metadata: { event: 'AGENT_UPDATED' },
|
|
125
|
+
data: { agentId: '456' },
|
|
126
|
+
});
|
|
127
|
+
expect(parseWorkClawMessage(msg)?.type).toBe('agent_updated');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('parses EVENT/AGENT_DELETED', () => {
|
|
131
|
+
const msg = JSON.stringify({
|
|
132
|
+
type: 'EVENT',
|
|
133
|
+
metadata: { event: 'AGENT_DELETED' },
|
|
134
|
+
data: { agentId: '789' },
|
|
135
|
+
});
|
|
136
|
+
expect(parseWorkClawMessage(msg)?.type).toBe('agent_deleted');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('parses EVENT/TOOLS_LIST', () => {
|
|
140
|
+
const msg = JSON.stringify({
|
|
141
|
+
type: 'EVENT',
|
|
142
|
+
metadata: { event: 'TOOLS_LIST' },
|
|
143
|
+
data: { tools: [] },
|
|
144
|
+
});
|
|
145
|
+
expect(parseWorkClawMessage(msg)?.type).toBe('tools_list');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('parses EVENT/SKILLS_LIST', () => {
|
|
149
|
+
const msg = JSON.stringify({
|
|
150
|
+
type: 'EVENT',
|
|
151
|
+
metadata: { event: 'SKILLS_LIST' },
|
|
152
|
+
data: {},
|
|
153
|
+
});
|
|
154
|
+
expect(parseWorkClawMessage(msg)?.type).toBe('skills_list');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('parses EVENT/INIT_AGENT and unwraps JSON-string data', () => {
|
|
158
|
+
const innerData = { agentId: 12345 };
|
|
159
|
+
const msg = JSON.stringify({
|
|
160
|
+
type: 'EVENT',
|
|
161
|
+
metadata: { event: 'INIT_AGENT' },
|
|
162
|
+
data: JSON.stringify(innerData),
|
|
163
|
+
});
|
|
164
|
+
const result = parseWorkClawMessage(msg);
|
|
165
|
+
expect(result?.type).toBe('init_agent');
|
|
166
|
+
expect(result?.eventData).toEqual(innerData);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('ignores unknown EVENT types', () => {
|
|
170
|
+
const msg = JSON.stringify({
|
|
171
|
+
type: 'EVENT',
|
|
172
|
+
metadata: { event: 'UNKNOWN_EVENT' },
|
|
173
|
+
});
|
|
174
|
+
expect(parseWorkClawMessage(msg)?.type).toBe('ignored');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('parses CALLBACK/AGENT_MESSAGE with text in content field', () => {
|
|
178
|
+
const msg = JSON.stringify({
|
|
179
|
+
type: 'CALLBACK',
|
|
180
|
+
metadata: { topic: 'AGENT_MESSAGE' },
|
|
181
|
+
data: {
|
|
182
|
+
userId: 'user-abc',
|
|
183
|
+
conversationId: 'conv-123',
|
|
184
|
+
msgId: 'msg-456',
|
|
185
|
+
agentId: 42,
|
|
186
|
+
message: { content: 'hello from user' },
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
const result = parseWorkClawMessage(msg);
|
|
190
|
+
expect(result?.type).toBe('agent_message');
|
|
191
|
+
expect(result?.message?.text).toBe('hello from user');
|
|
192
|
+
expect(result?.message?.userId).toBe('user-abc');
|
|
193
|
+
expect(result?.message?.openConversationId).toBe('conv-123');
|
|
194
|
+
expect(result?.message?.messageId).toBe('msg-456');
|
|
195
|
+
expect(result?.message?.agentId).toBe(42);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('parses CALLBACK/AGENT_MESSAGE with string message field', () => {
|
|
199
|
+
const msg = JSON.stringify({
|
|
200
|
+
type: 'CALLBACK',
|
|
201
|
+
metadata: { topic: 'AGENT_MESSAGE' },
|
|
202
|
+
data: {
|
|
203
|
+
userId: 'u1',
|
|
204
|
+
conversationId: 'c1',
|
|
205
|
+
msgId: 'm1',
|
|
206
|
+
message: 'plain text',
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const result = parseWorkClawMessage(msg);
|
|
210
|
+
expect(result?.message?.text).toBe('plain text');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('ignores non-AGENT_MESSAGE CALLBACK topics', () => {
|
|
214
|
+
const msg = JSON.stringify({
|
|
215
|
+
type: 'CALLBACK',
|
|
216
|
+
metadata: { topic: 'OTHER_TOPIC' },
|
|
217
|
+
});
|
|
218
|
+
expect(parseWorkClawMessage(msg)?.type).toBe('ignored');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('unwraps JSON-string data field in CALLBACK', () => {
|
|
222
|
+
const innerData = {
|
|
223
|
+
userId: 'u2',
|
|
224
|
+
conversationId: 'c2',
|
|
225
|
+
msgId: 'm2',
|
|
226
|
+
message: { content: 'nested' },
|
|
227
|
+
};
|
|
228
|
+
const msg = JSON.stringify({
|
|
229
|
+
type: 'CALLBACK',
|
|
230
|
+
metadata: { topic: 'AGENT_MESSAGE' },
|
|
231
|
+
data: JSON.stringify(innerData),
|
|
232
|
+
});
|
|
233
|
+
const result = parseWorkClawMessage(msg);
|
|
234
|
+
expect(result?.message?.text).toBe('nested');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('parses SKILL_DO_REMIND event', () => {
|
|
238
|
+
const msg = JSON.stringify({
|
|
239
|
+
type: 'EVENT',
|
|
240
|
+
metadata: { event: 'SKILL_DO_REMIND' },
|
|
241
|
+
data: { dotype: 'invoke', skillId: 's1' },
|
|
242
|
+
});
|
|
243
|
+
const result = parseWorkClawMessage(msg);
|
|
244
|
+
expect(result?.type).toBe('skills_event');
|
|
245
|
+
expect(result?.eventData?.topic).toBe('skills/invoke');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// buildInboundContext (without pluginRuntime)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
describe('buildInboundContext – without pluginRuntime', () => {
|
|
254
|
+
const baseMessage: InboundMessage = {
|
|
255
|
+
text: 'test message',
|
|
256
|
+
to: 'agent-001',
|
|
257
|
+
from: 'user-xyz',
|
|
258
|
+
chatType: 'dm',
|
|
259
|
+
messageId: 'msg-001',
|
|
260
|
+
rawPayload: '{"text":"test message"}',
|
|
261
|
+
timestamp: 1700000000000,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
it('returns correct basic context fields', async () => {
|
|
265
|
+
const ctx = await buildInboundContext(baseMessage, 'default', {});
|
|
266
|
+
expect(ctx.Body).toBe('test message');
|
|
267
|
+
expect(ctx.From).toBe('user-xyz');
|
|
268
|
+
expect(ctx.To).toBe('agent-001');
|
|
269
|
+
expect(ctx.ChatType).toBe('dm');
|
|
270
|
+
expect(ctx.AccountId).toBe('default');
|
|
271
|
+
expect(ctx.Provider).toBe(OPENCLAW_WORKCLAW_CHANNEL);
|
|
272
|
+
expect(ctx.MessageSid).toBe('msg-001');
|
|
273
|
+
expect(ctx.Timestamp).toBe(1700000000000);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('sets SessionKey when agentId is provided', async () => {
|
|
277
|
+
const ctx = await buildInboundContext(baseMessage, 'default', {}, 'agent-99');
|
|
278
|
+
expect(ctx.SessionKey).toContain('agent-99');
|
|
279
|
+
expect(ctx.SessionKey).toContain('default');
|
|
280
|
+
expect(ctx.SessionKey).toContain('user-xyz');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('does not set SessionKey when agentId is "main"', async () => {
|
|
284
|
+
const ctx = await buildInboundContext(baseMessage, 'default', {}, 'main');
|
|
285
|
+
expect(ctx.SessionKey).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('uses sessionKey from rawPayload when present', async () => {
|
|
289
|
+
const msgWithSession: InboundMessage = {
|
|
290
|
+
...baseMessage,
|
|
291
|
+
rawPayload: JSON.stringify({ sessionKey: 'custom-session-key-123' }),
|
|
292
|
+
};
|
|
293
|
+
const ctx = await buildInboundContext(msgWithSession, 'default', {}, 'agent-1');
|
|
294
|
+
expect(ctx.SessionKey).toBe('custom-session-key-123');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('OriginatingChannel and OriginatingTo are set correctly', async () => {
|
|
298
|
+
const ctx = await buildInboundContext(baseMessage, 'default', {});
|
|
299
|
+
expect(ctx.OriginatingChannel).toBe(OPENCLAW_WORKCLAW_CHANNEL);
|
|
300
|
+
expect(ctx.OriginatingTo).toBe('agent-001');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('BodyForAgent returns text content', async () => {
|
|
304
|
+
const ctx = await buildInboundContext(baseMessage, 'default', {});
|
|
305
|
+
expect(ctx.BodyForAgent).toBe('test message');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('handles group chatType', async () => {
|
|
309
|
+
const groupMsg: InboundMessage = { ...baseMessage, chatType: 'group' };
|
|
310
|
+
const ctx = await buildInboundContext(groupMsg, 'default', {});
|
|
311
|
+
expect(ctx.ChatType).toBe('group');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for reconnect scheduler logic.
|
|
3
|
+
* Network/WebSocket calls are not made — we test scheduling, dedup, stop, and error handling logic.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { createReconnectScheduler } from '../src/gateway/reconnect.js';
|
|
8
|
+
|
|
9
|
+
// Mock the external dependencies so tests don't touch the network
|
|
10
|
+
vi.mock('../src/connection/workclaw-client.js', () => ({
|
|
11
|
+
clearWorkclawTokenCache: vi.fn(),
|
|
12
|
+
openWorkclawConnection: vi.fn().mockRejectedValue(new Error('network error')),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/runtime.js', () => ({
|
|
16
|
+
setWorkclawWsConnection: vi.fn(),
|
|
17
|
+
clearWorkclawWsConnection: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('undici', () => ({
|
|
21
|
+
Agent: vi.fn(),
|
|
22
|
+
WebSocket: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const baseConfig = {
|
|
26
|
+
appKey: 'test-key',
|
|
27
|
+
appSecret: 'test-secret',
|
|
28
|
+
baseUrl: 'http://localhost:9999',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Basic scheduling
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('createReconnectScheduler – scheduling', () => {
|
|
45
|
+
it('returns scheduleReconnect and stopReconnect functions', () => {
|
|
46
|
+
const scheduler = createReconnectScheduler({
|
|
47
|
+
key: 'test',
|
|
48
|
+
config: baseConfig,
|
|
49
|
+
onMessage: vi.fn(),
|
|
50
|
+
});
|
|
51
|
+
expect(typeof scheduler.scheduleReconnect).toBe('function');
|
|
52
|
+
expect(typeof scheduler.stopReconnect).toBe('function');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does not reconnect immediately — waits for timer', () => {
|
|
56
|
+
const logs: string[] = [];
|
|
57
|
+
const scheduler = createReconnectScheduler({
|
|
58
|
+
key: 'test',
|
|
59
|
+
config: baseConfig,
|
|
60
|
+
onMessage: vi.fn(),
|
|
61
|
+
log: { info: (m) => logs.push(m) },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
scheduler.scheduleReconnect();
|
|
65
|
+
|
|
66
|
+
// Nothing should have run yet
|
|
67
|
+
expect(logs.some(l => l.includes('Attempting'))).toBe(false);
|
|
68
|
+
expect(logs.some(l => l.includes('Scheduling'))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('deduplicates concurrent scheduleReconnect calls', () => {
|
|
72
|
+
const logs: string[] = [];
|
|
73
|
+
const scheduler = createReconnectScheduler({
|
|
74
|
+
key: 'test',
|
|
75
|
+
config: baseConfig,
|
|
76
|
+
onMessage: vi.fn(),
|
|
77
|
+
log: { info: (m) => logs.push(m) },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
scheduler.scheduleReconnect();
|
|
81
|
+
scheduler.scheduleReconnect();
|
|
82
|
+
scheduler.scheduleReconnect();
|
|
83
|
+
|
|
84
|
+
const scheduledLogs = logs.filter(l => l.includes('Scheduling'));
|
|
85
|
+
const skippedLogs = logs.filter(l => l.includes('Already scheduled'));
|
|
86
|
+
expect(scheduledLogs).toHaveLength(1);
|
|
87
|
+
expect(skippedLogs).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// stopReconnect
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
describe('createReconnectScheduler – stopReconnect', () => {
|
|
96
|
+
it('prevents reconnect after stop is called', () => {
|
|
97
|
+
const logs: string[] = [];
|
|
98
|
+
const scheduler = createReconnectScheduler({
|
|
99
|
+
key: 'test',
|
|
100
|
+
config: baseConfig,
|
|
101
|
+
onMessage: vi.fn(),
|
|
102
|
+
log: { info: (m) => logs.push(m) },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
scheduler.stopReconnect();
|
|
106
|
+
scheduler.scheduleReconnect();
|
|
107
|
+
|
|
108
|
+
const stoppedLog = logs.find(l => l.includes('Stopped'));
|
|
109
|
+
expect(stoppedLog).toBeTruthy();
|
|
110
|
+
expect(logs.some(l => l.includes('Scheduling'))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('cancels a pending timer when stop is called', () => {
|
|
114
|
+
const logs: string[] = [];
|
|
115
|
+
const scheduler = createReconnectScheduler({
|
|
116
|
+
key: 'test',
|
|
117
|
+
config: baseConfig,
|
|
118
|
+
onMessage: vi.fn(),
|
|
119
|
+
log: { info: (m) => logs.push(m) },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
scheduler.scheduleReconnect();
|
|
123
|
+
expect(logs.some(l => l.includes('Scheduling'))).toBe(true);
|
|
124
|
+
|
|
125
|
+
scheduler.stopReconnect();
|
|
126
|
+
|
|
127
|
+
// Advance time past the reconnect delay — nothing should fire
|
|
128
|
+
vi.advanceTimersByTime(10000);
|
|
129
|
+
|
|
130
|
+
// scheduleReconnect after stop should be rejected
|
|
131
|
+
scheduler.scheduleReconnect();
|
|
132
|
+
expect(logs.filter(l => l.includes('Stopped'))).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Error handling: auth error stops reconnect
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe('createReconnectScheduler – auth error handling', () => {
|
|
141
|
+
it('stops reconnecting after auth error', async () => {
|
|
142
|
+
const { openWorkclawConnection } = await import('../src/connection/workclaw-client.js');
|
|
143
|
+
vi.mocked(openWorkclawConnection).mockRejectedValueOnce(
|
|
144
|
+
new Error('{"errCode":2001,"message":"鉴权失败"}'),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const logs: string[] = [];
|
|
148
|
+
const errorLogs: string[] = [];
|
|
149
|
+
const scheduler = createReconnectScheduler({
|
|
150
|
+
key: 'auth-test',
|
|
151
|
+
config: baseConfig,
|
|
152
|
+
onMessage: vi.fn(),
|
|
153
|
+
log: {
|
|
154
|
+
info: (m) => logs.push(m),
|
|
155
|
+
error: (m) => errorLogs.push(m),
|
|
156
|
+
warn: vi.fn(),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
scheduler.scheduleReconnect();
|
|
161
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
162
|
+
|
|
163
|
+
expect(errorLogs.some(l => l.includes('Authentication failed'))).toBe(true);
|
|
164
|
+
|
|
165
|
+
// Subsequent scheduleReconnect should be stopped
|
|
166
|
+
scheduler.scheduleReconnect();
|
|
167
|
+
expect(logs.some(l => l.includes('Stopped'))).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('stops reconnecting on "invalid credentials" error', async () => {
|
|
171
|
+
const { openWorkclawConnection } = await import('../src/connection/workclaw-client.js');
|
|
172
|
+
vi.mocked(openWorkclawConnection).mockRejectedValueOnce(
|
|
173
|
+
new Error('invalid credentials'),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const errorLogs: string[] = [];
|
|
177
|
+
const scheduler = createReconnectScheduler({
|
|
178
|
+
key: 'cred-test',
|
|
179
|
+
config: baseConfig,
|
|
180
|
+
onMessage: vi.fn(),
|
|
181
|
+
log: { error: (m) => errorLogs.push(m), info: vi.fn(), warn: vi.fn() },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
scheduler.scheduleReconnect();
|
|
185
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
186
|
+
|
|
187
|
+
expect(errorLogs.some(l => l.includes('Authentication failed'))).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Error handling: rate limit resets backoff
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe('createReconnectScheduler – rate limit handling', () => {
|
|
196
|
+
it('retries with minimal backoff on rate limit error', async () => {
|
|
197
|
+
const { openWorkclawConnection } = await import('../src/connection/workclaw-client.js');
|
|
198
|
+
vi.mocked(openWorkclawConnection).mockRejectedValue(
|
|
199
|
+
new Error('访问过于频繁,请稍后再试'),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const logs: string[] = [];
|
|
203
|
+
const warnLogs: string[] = [];
|
|
204
|
+
const scheduler = createReconnectScheduler({
|
|
205
|
+
key: 'rate-test',
|
|
206
|
+
config: baseConfig,
|
|
207
|
+
onMessage: vi.fn(),
|
|
208
|
+
log: {
|
|
209
|
+
info: (m) => logs.push(m),
|
|
210
|
+
warn: (m) => warnLogs.push(m),
|
|
211
|
+
error: vi.fn(),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Stop after first reschedule so timer doesn't loop infinitely
|
|
216
|
+
const orig = scheduler.scheduleReconnect;
|
|
217
|
+
let rescheduleCount = 0;
|
|
218
|
+
scheduler.scheduleReconnect();
|
|
219
|
+
|
|
220
|
+
// Advance time to trigger first attempt
|
|
221
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
222
|
+
|
|
223
|
+
expect(warnLogs.some(l => l.includes('Rate limited'))).toBe(true);
|
|
224
|
+
// Should have scheduled again with low backoff
|
|
225
|
+
expect(logs.some(l => l.includes('Scheduling') && l.includes('base=1000'))).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Generic error: continues reconnecting
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('createReconnectScheduler – generic error', () => {
|
|
234
|
+
it('schedules attempt after generic network error', async () => {
|
|
235
|
+
const { openWorkclawConnection } = await import('../src/connection/workclaw-client.js');
|
|
236
|
+
vi.mocked(openWorkclawConnection).mockRejectedValueOnce(
|
|
237
|
+
new Error('network error'),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const logs: string[] = [];
|
|
241
|
+
const scheduler = createReconnectScheduler({
|
|
242
|
+
key: 'net-test',
|
|
243
|
+
config: baseConfig,
|
|
244
|
+
onMessage: vi.fn(),
|
|
245
|
+
log: { info: (m) => logs.push(m), error: vi.fn(), warn: vi.fn() },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
scheduler.scheduleReconnect();
|
|
249
|
+
|
|
250
|
+
// Advance enough to trigger the attempt
|
|
251
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
252
|
+
|
|
253
|
+
// Should have scheduled initial reconnect
|
|
254
|
+
const schedulingLogs = logs.filter(l => l.includes('Scheduling'));
|
|
255
|
+
expect(schedulingLogs.length).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for workclaw-client: token cache, normalizeBaseUrl, createWsEndpoint logic.
|
|
3
|
+
* Network calls (doFetchJson) are not tested here — those require a real server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
normalizeBaseUrl,
|
|
9
|
+
clearWorkclawTokenCache,
|
|
10
|
+
getWorkclawAccessToken,
|
|
11
|
+
} from '../src/connection/workclaw-client.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// normalizeBaseUrl
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe('normalizeBaseUrl', () => {
|
|
18
|
+
it('returns default URL when value is undefined', () => {
|
|
19
|
+
expect(normalizeBaseUrl(undefined)).toBe('https://open.workbrain.cn/open-apis');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns default URL when value is empty string', () => {
|
|
23
|
+
expect(normalizeBaseUrl('')).toBe('https://open.workbrain.cn/open-apis');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('strips trailing slash', () => {
|
|
27
|
+
expect(normalizeBaseUrl('http://localhost:8080/')).toBe('http://localhost:8080');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('leaves URL without trailing slash unchanged', () => {
|
|
31
|
+
expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('trims whitespace before processing', () => {
|
|
35
|
+
// trim + strip trailing slash — both happen, result has no trailing slash
|
|
36
|
+
expect(normalizeBaseUrl(' http://example.com/ ')).toBe('http://example.com');
|
|
37
|
+
expect(normalizeBaseUrl(' http://example.com/ '.trim())).toBe('http://example.com');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('handles multiple trailing slashes — strips only last one', () => {
|
|
41
|
+
// The implementation strips one trailing slash
|
|
42
|
+
expect(normalizeBaseUrl('http://example.com//')).toBe('http://example.com/');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// clearWorkclawTokenCache
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe('clearWorkclawTokenCache', () => {
|
|
51
|
+
it('does not throw when clearing a key that was never set', () => {
|
|
52
|
+
expect(() => clearWorkclawTokenCache('non-existent-key')).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('can be called multiple times for the same key without error', () => {
|
|
56
|
+
clearWorkclawTokenCache('key-abc');
|
|
57
|
+
clearWorkclawTokenCache('key-abc');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// getWorkclawAccessToken — error handling
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('getWorkclawAccessToken – validation', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
clearWorkclawTokenCache('test-cache-key');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws when appKey is missing', async () => {
|
|
71
|
+
await expect(
|
|
72
|
+
getWorkclawAccessToken('test-cache-key', {
|
|
73
|
+
baseUrl: 'http://localhost:9999',
|
|
74
|
+
appKey: '',
|
|
75
|
+
appSecret: 'secret',
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow('Missing appKey/appSecret');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws when appSecret is missing', async () => {
|
|
81
|
+
await expect(
|
|
82
|
+
getWorkclawAccessToken('test-cache-key', {
|
|
83
|
+
baseUrl: 'http://localhost:9999',
|
|
84
|
+
appKey: 'key',
|
|
85
|
+
appSecret: '',
|
|
86
|
+
}),
|
|
87
|
+
).rejects.toThrow('Missing appKey/appSecret');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws when both appKey and appSecret are missing', async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
getWorkclawAccessToken('test-cache-key', {
|
|
93
|
+
baseUrl: 'http://localhost:9999',
|
|
94
|
+
}),
|
|
95
|
+
).rejects.toThrow('Missing appKey/appSecret');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('uses cached token on second call without hitting network', async () => {
|
|
99
|
+
// Manually prime the cache by calling with valid config — but since we
|
|
100
|
+
// have no server, we just verify that clearing and re-requesting fails fast.
|
|
101
|
+
// This test documents the cache behavior by ensuring clear() invalidates it.
|
|
102
|
+
clearWorkclawTokenCache('primed-key');
|
|
103
|
+
await expect(
|
|
104
|
+
getWorkclawAccessToken('primed-key', {
|
|
105
|
+
baseUrl: 'http://127.0.0.1:1', // unreachable
|
|
106
|
+
appKey: 'key',
|
|
107
|
+
appSecret: 'secret',
|
|
108
|
+
requestTimeout: 500,
|
|
109
|
+
}),
|
|
110
|
+
).rejects.toThrow(); // network error or timeout, not validation error
|
|
111
|
+
});
|
|
112
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "../../tsconfig.json",
|
|
3
2
|
"compilerOptions": {
|
|
4
|
-
"
|
|
3
|
+
"module": "NodeNext",
|
|
4
|
+
"moduleResolution": "NodeNext",
|
|
5
|
+
"target": "ES2023",
|
|
6
|
+
"rootDir": ".",
|
|
7
|
+
"outDir": "dist",
|
|
5
8
|
"declaration": true,
|
|
6
|
-
"
|
|
7
|
-
"outDir": "./dist"
|
|
9
|
+
"skipLibCheck": true
|
|
8
10
|
},
|
|
9
|
-
"include": ["
|
|
11
|
+
"include": ["./**/*.ts", "./index.ts"],
|
|
10
12
|
"exclude": ["node_modules", "dist"]
|
|
11
13
|
}
|
|
14
|
+
|