@xuanyue202/shared 2026.3.21

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.
@@ -0,0 +1,459 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const cancelMock = vi.fn();
4
+ const confirmMock = vi.fn();
5
+ const introMock = vi.fn();
6
+ const noteMock = vi.fn();
7
+ const outroMock = vi.fn();
8
+ const selectMock = vi.fn();
9
+ const textMock = vi.fn();
10
+
11
+ vi.mock("@clack/prompts", () => ({
12
+ cancel: (...args: unknown[]) => cancelMock(...args),
13
+ confirm: (...args: unknown[]) => confirmMock(...args),
14
+ intro: (...args: unknown[]) => introMock(...args),
15
+ isCancel: () => false,
16
+ note: (...args: unknown[]) => noteMock(...args),
17
+ outro: (...args: unknown[]) => outroMock(...args),
18
+ select: (...args: unknown[]) => selectMock(...args),
19
+ text: (...args: unknown[]) => textMock(...args),
20
+ }));
21
+
22
+ import { registerChinaSetupCli } from "./china-setup.js";
23
+ import type { ChannelId } from "./china-setup.js";
24
+
25
+ type ActionHandler = () => void | Promise<void>;
26
+
27
+ type LoggerLike = {
28
+ info?: (message: string) => void;
29
+ warn?: (message: string) => void;
30
+ error?: (message: string) => void;
31
+ };
32
+
33
+ type CommandNode = {
34
+ children: Map<string, CommandNode>;
35
+ actionHandler?: ActionHandler;
36
+ command: (name: string) => CommandNode;
37
+ description: (text: string) => CommandNode;
38
+ action: (handler: ActionHandler) => CommandNode;
39
+ };
40
+
41
+ type ConfigRoot = {
42
+ channels?: Record<string, Record<string, unknown>>;
43
+ };
44
+
45
+ const CLI_STATE_KEY = Symbol.for("@xuanyue202/china-cli-state");
46
+
47
+ function setupTTYMocks(): () => void {
48
+ const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
49
+ const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
50
+
51
+ vi.clearAllMocks();
52
+ delete (globalThis as Record<PropertyKey, unknown>)[CLI_STATE_KEY];
53
+ Object.defineProperty(process.stdin, "isTTY", {
54
+ configurable: true,
55
+ value: true,
56
+ });
57
+ Object.defineProperty(process.stdout, "isTTY", {
58
+ configurable: true,
59
+ value: true,
60
+ });
61
+
62
+ return () => {
63
+ if (stdinDescriptor) {
64
+ Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
65
+ }
66
+ if (stdoutDescriptor) {
67
+ Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
68
+ }
69
+ };
70
+ }
71
+
72
+ function createCommandNode(): CommandNode {
73
+ const node: CommandNode = {
74
+ children: new Map<string, CommandNode>(),
75
+ command(name: string): CommandNode {
76
+ const child = createCommandNode();
77
+ node.children.set(name, child);
78
+ return child;
79
+ },
80
+ description(): CommandNode {
81
+ return node;
82
+ },
83
+ action(handler: ActionHandler): CommandNode {
84
+ node.actionHandler = handler;
85
+ return node;
86
+ },
87
+ };
88
+ return node;
89
+ }
90
+
91
+ async function runSetup(
92
+ initialConfig: ConfigRoot,
93
+ channels: readonly ChannelId[] = ["wecom"]
94
+ ): Promise<{
95
+ writeConfigFile: ReturnType<typeof vi.fn>;
96
+ }> {
97
+ let registrar:
98
+ | ((ctx: { program: unknown; config?: unknown; logger?: LoggerLike }) => void | Promise<void>)
99
+ | undefined;
100
+ const writeConfigFile = vi.fn(async (_cfg: ConfigRoot) => {});
101
+
102
+ registerChinaSetupCli(
103
+ {
104
+ runtime: {
105
+ config: {
106
+ writeConfigFile,
107
+ },
108
+ },
109
+ registerCli: (nextRegistrar) => {
110
+ registrar = nextRegistrar;
111
+ },
112
+ },
113
+ { channels }
114
+ );
115
+
116
+ const program = createCommandNode();
117
+ await registrar?.({
118
+ program,
119
+ config: initialConfig,
120
+ logger: {},
121
+ });
122
+
123
+ const setupCommand = program.children.get("china")?.children.get("setup");
124
+ expect(setupCommand?.actionHandler).toBeTypeOf("function");
125
+ await setupCommand?.actionHandler?.();
126
+
127
+ return { writeConfigFile };
128
+ }
129
+
130
+ describe("china setup wecom", () => {
131
+ let restoreTTY: (() => void) | undefined;
132
+
133
+ beforeEach(() => {
134
+ restoreTTY = setupTTYMocks();
135
+ });
136
+
137
+ afterEach(() => {
138
+ restoreTTY?.();
139
+ });
140
+
141
+ it("stores ws-only credentials for wecom setup", async () => {
142
+ selectMock.mockResolvedValueOnce("wecom");
143
+ textMock.mockResolvedValueOnce("bot-123").mockResolvedValueOnce("secret-456");
144
+ confirmMock.mockResolvedValueOnce(false);
145
+
146
+ const { writeConfigFile } = await runSetup({
147
+ channels: {
148
+ wecom: {
149
+ webhookPath: "/legacy-wecom",
150
+ token: "legacy-token",
151
+ encodingAESKey: "legacy-aes-key",
152
+ },
153
+ },
154
+ });
155
+
156
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
157
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
158
+ const wecomConfig = savedConfig.channels?.wecom;
159
+
160
+ expect(wecomConfig?.enabled).toBe(true);
161
+ expect(wecomConfig?.mode).toBe("ws");
162
+ expect(wecomConfig?.botId).toBe("bot-123");
163
+ expect(wecomConfig?.secret).toBe("secret-456");
164
+ expect(wecomConfig?.webhookPath).toBeUndefined();
165
+ expect(wecomConfig?.token).toBeUndefined();
166
+ expect(wecomConfig?.encodingAESKey).toBeUndefined();
167
+
168
+ const promptMessages = textMock.mock.calls.map((call) => {
169
+ const firstArg = call[0] as { message?: string } | undefined;
170
+ return firstArg?.message ?? "";
171
+ });
172
+ expect(promptMessages).toEqual(["WeCom botId(ws 长连接)", "WeCom secret(ws 长连接)"]);
173
+ });
174
+
175
+ it("marks wecom as configured when botId and secret already exist", async () => {
176
+ let selectOptions: Array<{ label?: string; value?: string }> = [];
177
+ selectMock.mockImplementationOnce(async (params: { options?: Array<{ label?: string; value?: string }> }) => {
178
+ selectOptions = params.options ?? [];
179
+ return "cancel";
180
+ });
181
+
182
+ const { writeConfigFile } = await runSetup({
183
+ channels: {
184
+ wecom: {
185
+ botId: "existing-bot",
186
+ secret: "existing-secret",
187
+ },
188
+ },
189
+ });
190
+
191
+ expect(writeConfigFile).not.toHaveBeenCalled();
192
+ expect(selectOptions.some((option) => option.label === "WeCom(企业微信-智能机器人)(已配置)")).toBe(true);
193
+ });
194
+ });
195
+
196
+ describe("china setup wechat-mp", () => {
197
+ let restoreTTY: (() => void) | undefined;
198
+
199
+ beforeEach(() => {
200
+ restoreTTY = setupTTYMocks();
201
+ });
202
+
203
+ afterEach(() => {
204
+ restoreTTY?.();
205
+ });
206
+
207
+ it("stores wechat-mp callback and account config", async () => {
208
+ selectMock
209
+ .mockResolvedValueOnce("wechat-mp")
210
+ .mockResolvedValueOnce("safe")
211
+ .mockResolvedValueOnce("passive");
212
+ confirmMock.mockResolvedValueOnce(true); // renderMarkdown enabled (default)
213
+ textMock
214
+ .mockResolvedValueOnce("/wechat-mp")
215
+ .mockResolvedValueOnce("wx-test-appid")
216
+ .mockResolvedValueOnce("wx-test-secret")
217
+ .mockResolvedValueOnce("callback-token")
218
+ .mockResolvedValueOnce("encoding-aes-key")
219
+ .mockResolvedValueOnce("欢迎关注");
220
+
221
+ const { writeConfigFile } = await runSetup({}, ["wechat-mp"]);
222
+
223
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
224
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
225
+ const wechatMpConfig = savedConfig.channels?.["wechat-mp"];
226
+
227
+ expect(wechatMpConfig?.enabled).toBe(true);
228
+ expect(wechatMpConfig?.webhookPath).toBe("/wechat-mp");
229
+ expect(wechatMpConfig?.appId).toBe("wx-test-appid");
230
+ expect(wechatMpConfig?.appSecret).toBe("wx-test-secret");
231
+ expect(wechatMpConfig?.token).toBe("callback-token");
232
+ expect(wechatMpConfig?.encodingAESKey).toBe("encoding-aes-key");
233
+ expect(wechatMpConfig?.messageMode).toBe("safe");
234
+ expect(wechatMpConfig?.replyMode).toBe("passive");
235
+ expect(wechatMpConfig?.welcomeText).toBe("欢迎关注");
236
+ expect(wechatMpConfig?.renderMarkdown).toBe(true);
237
+ });
238
+
239
+ it("stores activeDeliveryMode when replyMode is active", async () => {
240
+ selectMock
241
+ .mockResolvedValueOnce("wechat-mp")
242
+ .mockResolvedValueOnce("safe")
243
+ .mockResolvedValueOnce("active")
244
+ .mockResolvedValueOnce("split");
245
+ textMock
246
+ .mockResolvedValueOnce("/wechat-mp-active")
247
+ .mockResolvedValueOnce("wx-active-appid")
248
+ .mockResolvedValueOnce("wx-active-secret")
249
+ .mockResolvedValueOnce("active-token")
250
+ .mockResolvedValueOnce("active-aes-key")
251
+ .mockResolvedValueOnce("welcome");
252
+
253
+ const { writeConfigFile } = await runSetup({}, ["wechat-mp"]);
254
+
255
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
256
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
257
+ const wechatMpConfig = savedConfig.channels?.["wechat-mp"];
258
+
259
+ expect(wechatMpConfig?.enabled).toBe(true);
260
+ expect(wechatMpConfig?.replyMode).toBe("active");
261
+ expect(wechatMpConfig?.activeDeliveryMode).toBe("split");
262
+ });
263
+
264
+ it("stores renderMarkdown when explicitly disabled", async () => {
265
+ selectMock
266
+ .mockResolvedValueOnce("wechat-mp")
267
+ .mockResolvedValueOnce("safe")
268
+ .mockResolvedValueOnce("active")
269
+ .mockResolvedValueOnce("merged");
270
+ confirmMock.mockResolvedValueOnce(false); // Disable renderMarkdown
271
+ textMock
272
+ .mockResolvedValueOnce("/wechat-mp-no-md")
273
+ .mockResolvedValueOnce("wx-no-md-appid")
274
+ .mockResolvedValueOnce("wx-no-md-secret")
275
+ .mockResolvedValueOnce("no-md-token")
276
+ .mockResolvedValueOnce("no-md-aes-key")
277
+ .mockResolvedValueOnce("welcome");
278
+
279
+ const { writeConfigFile } = await runSetup({}, ["wechat-mp"]);
280
+
281
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
282
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
283
+ const wechatMpConfig = savedConfig.channels?.["wechat-mp"];
284
+
285
+ expect(wechatMpConfig?.enabled).toBe(true);
286
+ expect(wechatMpConfig?.activeDeliveryMode).toBe("merged");
287
+ expect(wechatMpConfig?.renderMarkdown).toBe(false);
288
+ });
289
+
290
+ it("defaults renderMarkdown to true when not explicitly disabled", async () => {
291
+ selectMock
292
+ .mockResolvedValueOnce("wechat-mp")
293
+ .mockResolvedValueOnce("safe")
294
+ .mockResolvedValueOnce("passive");
295
+ confirmMock.mockResolvedValueOnce(true); // Keep renderMarkdown enabled (default)
296
+ textMock
297
+ .mockResolvedValueOnce("/wechat-mp-default-md")
298
+ .mockResolvedValueOnce("wx-default-appid")
299
+ .mockResolvedValueOnce("wx-default-secret")
300
+ .mockResolvedValueOnce("default-token")
301
+ .mockResolvedValueOnce("default-aes-key")
302
+ .mockResolvedValueOnce("welcome");
303
+
304
+ const { writeConfigFile } = await runSetup({}, ["wechat-mp"]);
305
+
306
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
307
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
308
+ const wechatMpConfig = savedConfig.channels?.["wechat-mp"];
309
+
310
+ expect(wechatMpConfig?.enabled).toBe(true);
311
+ // setup writes the value explicitly, even when it's the default true
312
+ expect(wechatMpConfig?.renderMarkdown).toBe(true);
313
+ });
314
+ });
315
+
316
+ describe("china setup wecom-kf", () => {
317
+ let restoreTTY: (() => void) | undefined;
318
+
319
+ beforeEach(() => {
320
+ restoreTTY = setupTTYMocks();
321
+ });
322
+
323
+ afterEach(() => {
324
+ restoreTTY?.();
325
+ });
326
+
327
+ it("stores only the initial wecom-kf callback setup fields", async () => {
328
+ selectMock.mockResolvedValueOnce("wecom-kf");
329
+ textMock
330
+ .mockResolvedValueOnce("/kf-hook")
331
+ .mockResolvedValueOnce("callback-token")
332
+ .mockResolvedValueOnce("encoding-aes-key")
333
+ .mockResolvedValueOnce("ww-test-corp")
334
+ .mockResolvedValueOnce("wk-test")
335
+ .mockResolvedValueOnce("");
336
+
337
+ const { writeConfigFile } = await runSetup({}, ["wecom-kf"]);
338
+
339
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
340
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
341
+ const wecomKfConfig = savedConfig.channels?.["wecom-kf"];
342
+
343
+ expect(wecomKfConfig?.enabled).toBe(true);
344
+ expect(wecomKfConfig?.webhookPath).toBe("/kf-hook");
345
+ expect(wecomKfConfig?.token).toBe("callback-token");
346
+ expect(wecomKfConfig?.encodingAESKey).toBe("encoding-aes-key");
347
+ expect(wecomKfConfig?.corpId).toBe("ww-test-corp");
348
+ expect(wecomKfConfig?.openKfId).toBe("wk-test");
349
+ expect(wecomKfConfig?.corpSecret).toBeUndefined();
350
+ expect(wecomKfConfig?.apiBaseUrl).toBeUndefined();
351
+ expect(wecomKfConfig?.welcomeText).toBeUndefined();
352
+ expect(wecomKfConfig?.dmPolicy).toBeUndefined();
353
+ expect(wecomKfConfig?.allowFrom).toBeUndefined();
354
+
355
+ const promptMessages = textMock.mock.calls.map((call) => {
356
+ const firstArg = call[0] as { message?: string } | undefined;
357
+ return firstArg?.message ?? "";
358
+ });
359
+ expect(promptMessages).toEqual([
360
+ "Webhook 路径(默认 /wecom-kf)",
361
+ "微信客服回调 Token",
362
+ "微信客服回调 EncodingAESKey",
363
+ "corpId",
364
+ "open_kfid",
365
+ "微信客服 Secret(最后填写;首次接入可先留空)",
366
+ ]);
367
+
368
+ const noteMessages = noteMock.mock.calls.map((call) => {
369
+ const firstArg = call[0] as string | undefined;
370
+ return firstArg ?? "";
371
+ });
372
+ expect(
373
+ noteMessages.some((message) =>
374
+ message.includes(
375
+ "配置文档:https://github.com/BytePioneer-AI/openclaw-china/tree/main/doc/guides/wecom-kf/configuration.md"
376
+ )
377
+ )
378
+ ).toBe(true);
379
+ expect(
380
+ noteMessages.some((message) =>
381
+ message.includes("corpSecret 会作为最后一个参数询问;首次接入可先留空,待回调 URL 校验通过并点击“开始使用”后再补")
382
+ )
383
+ ).toBe(true);
384
+ });
385
+ });
386
+
387
+ describe("china setup dingtalk", () => {
388
+ let restoreTTY: (() => void) | undefined;
389
+
390
+ beforeEach(() => {
391
+ restoreTTY = setupTTYMocks();
392
+ });
393
+
394
+ afterEach(() => {
395
+ restoreTTY?.();
396
+ });
397
+
398
+ it("stores gateway token when dingtalk AI Card streaming is enabled", async () => {
399
+ let registrar:
400
+ | ((ctx: { program: unknown; config?: unknown; logger?: LoggerLike }) => void | Promise<void>)
401
+ | undefined;
402
+ const writeConfigFile = vi.fn(async (_cfg: ConfigRoot) => {});
403
+
404
+ registerChinaSetupCli(
405
+ {
406
+ runtime: {
407
+ config: {
408
+ writeConfigFile,
409
+ },
410
+ },
411
+ registerCli: (nextRegistrar) => {
412
+ registrar = nextRegistrar;
413
+ },
414
+ },
415
+ { channels: ["dingtalk"] }
416
+ );
417
+
418
+ selectMock.mockResolvedValueOnce("dingtalk");
419
+ textMock.mockResolvedValueOnce("ding-app-key");
420
+ textMock.mockResolvedValueOnce("ding-app-secret");
421
+ confirmMock.mockResolvedValueOnce(true);
422
+ textMock.mockResolvedValueOnce("gateway-token-123");
423
+
424
+ const program = createCommandNode();
425
+ await registrar?.({
426
+ program,
427
+ config: {
428
+ gateway: {
429
+ auth: {
430
+ token: "global-token",
431
+ },
432
+ },
433
+ },
434
+ logger: {},
435
+ });
436
+
437
+ const setupCommand = program.children.get("china")?.children.get("setup");
438
+ expect(setupCommand?.actionHandler).toBeTypeOf("function");
439
+ await setupCommand?.actionHandler?.();
440
+
441
+ expect(writeConfigFile).toHaveBeenCalledTimes(1);
442
+ const savedConfig = writeConfigFile.mock.calls[0]?.[0] as ConfigRoot;
443
+ const dingtalkConfig = savedConfig.channels?.dingtalk;
444
+
445
+ expect(dingtalkConfig?.enabled).toBe(true);
446
+ expect(dingtalkConfig?.clientId).toBe("ding-app-key");
447
+ expect(dingtalkConfig?.clientSecret).toBe("ding-app-secret");
448
+ expect(dingtalkConfig?.enableAICard).toBe(true);
449
+ expect(dingtalkConfig?.gatewayToken).toBe("gateway-token-123");
450
+
451
+ const promptMessages = textMock.mock.calls.map((call) => {
452
+ const firstArg = call[0] as { message?: string } | undefined;
453
+ return firstArg?.message ?? "";
454
+ });
455
+ expect(promptMessages).toContain(
456
+ "OpenClaw Gateway Token(流式输出必需;留空则使用全局 gateway.auth.token)"
457
+ );
458
+ });
459
+ });