mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 10:30:44 +08:00
Add WeCom QR bot channel
This commit is contained in:
parent
601271a7f8
commit
4874d3e354
33 changed files with 5200 additions and 440 deletions
111
test/channels/wecom/bridge.test.mjs
Normal file
111
test/channels/wecom/bridge.test.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
|
||||
function frame(overrides = {}) {
|
||||
return {
|
||||
headers: { req_id: 'req-1' },
|
||||
body: {
|
||||
msgid: 'msg-1',
|
||||
chattype: 'single',
|
||||
from: { userid: 'member-1' },
|
||||
msgtype: 'text',
|
||||
text: { content: '请回答' },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function state() {
|
||||
const seen = new Set();
|
||||
return {
|
||||
seen,
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
sessionExists: async () => true,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {
|
||||
const replies = [];
|
||||
const active = [];
|
||||
const store = state();
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
|
||||
replyStreamNonBlocking: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
|
||||
sendMessage: async (chatId, body) => active.push({ chatId, body }),
|
||||
},
|
||||
generateStreamId: () => 'stream-1',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-new',
|
||||
ensureRunning: async () => true,
|
||||
ask: async (_session, _text, { onUpdate }) => {
|
||||
await onUpdate({ type: 'tool', name: '网页搜索' });
|
||||
await onUpdate({ type: 'text', text: '回答中' });
|
||||
return '最终回答';
|
||||
},
|
||||
},
|
||||
state: store,
|
||||
});
|
||||
|
||||
await bridge.accept(frame());
|
||||
assert.deepEqual(replies, [
|
||||
{ streamId: 'stream-1', content: '正在思考中…', finish: false },
|
||||
{ streamId: 'stream-1', content: '正在使用网页搜索…', finish: false },
|
||||
{ streamId: 'stream-1', content: '回答中', finish: false },
|
||||
{ streamId: 'stream-1', content: '最终回答', finish: true },
|
||||
]);
|
||||
assert.deepEqual(active, []);
|
||||
assert.equal(store.seen.has('msg-1'), true);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat visibility scope accepts direct and group conversations without local approval', async () => {
|
||||
let asks = 0;
|
||||
const client = {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
};
|
||||
const harness = {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { asks += 1; return 'ok'; },
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({ client, harness, state: state(), generateStreamId: () => 'stream' });
|
||||
await bridge.accept(frame({ msgid: 'direct', from: { userid: 'member-a' } }));
|
||||
await bridge.accept(frame({ msgid: 'group', chattype: 'group', chatid: 'group-1', from: { userid: 'member-b' } }));
|
||||
assert.equal(asks, 2);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat finalizes an existing progress stream when Harness fails', async () => {
|
||||
const replies = [];
|
||||
const store = state();
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
},
|
||||
generateStreamId: () => 'stream-failure',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ensureRunning: async () => true,
|
||||
ask: async () => { throw new Error('Harness unavailable'); },
|
||||
},
|
||||
state: store,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(frame());
|
||||
assert.deepEqual(replies, [
|
||||
{ streamId: 'stream-failure', content: '正在思考中…', finish: false },
|
||||
{ streamId: 'stream-failure', content: '消息处理失败,请稍后重试。', finish: true },
|
||||
]);
|
||||
assert.equal(store.seen.has('msg-1'), true);
|
||||
});
|
||||
26
test/channels/wecom/client-api.test.mjs
Normal file
26
test/channels/wecom/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
safeQrSource,
|
||||
} from '../../../plugin-src/client/channels/wecom/api.js';
|
||||
|
||||
test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR state', () => {
|
||||
const qr = 'data:image/png;base64,YWJjZA==';
|
||||
assert.equal(safeQrSource(qr), qr);
|
||||
const provision = normalizeProvisioning({
|
||||
attemptId: 'attempt_1', status: 'pending', expiresAt: Date.now() + 1_000, qrCodeDataUrl: qr,
|
||||
});
|
||||
assert.equal(provision.qrCodeDataUrl, qr);
|
||||
const snapshot = normalizeSnapshot({
|
||||
bots: [{
|
||||
botId: 'wecom_abc', connected: true, state: 'connected',
|
||||
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
|
||||
health: { summary: '运行正常' },
|
||||
}],
|
||||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001');
|
||||
});
|
||||
43
test/channels/wecom/client-ui.test.mjs
Normal file
43
test/channels/wecom/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
AccountCard,
|
||||
WecomSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/wecom/index.js';
|
||||
|
||||
test('Enterprise WeChat settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定企业微信机器人</);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat bot cards match the shared two-metric card treatment', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'wecom_bot',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
|
||||
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /class="ddt-card dim-botCard"/);
|
||||
assert.match(markup, /data-im-channel-logo="wecom"/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
});
|
||||
68
test/channels/wecom/config-and-qr.test.mjs
Normal file
68
test/channels/wecom/config-and-qr.test.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
deriveWecomBotIdentity,
|
||||
WecomConfigStore,
|
||||
} from '../../../src/channels/wecom/config-store.mjs';
|
||||
import { WecomQrAuth } from '../../../src/channels/wecom/qr-auth.mjs';
|
||||
|
||||
test('Enterprise WeChat config stores only non-secret bot identity with mode 0600', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-wecom-config-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const path = join(root, 'config.json');
|
||||
const identity = deriveWecomBotIdentity('bot-enterprise-1');
|
||||
const store = await new WecomConfigStore(path).load();
|
||||
await store.save({
|
||||
...identity,
|
||||
remoteBotId: 'bot-enterprise-1',
|
||||
createdAt: '2026-08-15T00:00:00.000Z',
|
||||
});
|
||||
const document = await readFile(path, 'utf8');
|
||||
assert.match(document, /bot-enterprise-1/);
|
||||
assert.doesNotMatch(document, /private-secret|"secret"/);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.equal(store.get(identity.botId).remoteBotId, 'bot-enterprise-1');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat QR auth uses fixed official endpoints and returns credentials only after success', async () => {
|
||||
const requests = [];
|
||||
const replies = [
|
||||
{ data: { scode: 'opaque-scode', auth_url: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque' } },
|
||||
{ data: { status: 'init' } },
|
||||
{ data: { status: 'success', bot_info: { botid: 'bot-enterprise-1', secret: 'private-secret' } } },
|
||||
];
|
||||
const auth = new WecomQrAuth({
|
||||
clock: () => 1_000,
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, status: 200, json: async () => replies.shift() };
|
||||
},
|
||||
});
|
||||
const started = await auth.start();
|
||||
assert.equal(started.verificationUrl, 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque');
|
||||
assert.equal(started.expiresAt, 301_000);
|
||||
assert.equal(await auth.poll({ scode: started.scode }).then((value) => value.status), 'waiting');
|
||||
const completed = await auth.poll({ scode: started.scode });
|
||||
assert.deepEqual(completed, {
|
||||
status: 'success', remoteBotId: 'bot-enterprise-1', secret: 'private-secret',
|
||||
});
|
||||
assert.match(requests[0].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/generate\?/);
|
||||
assert.match(requests[0].url, /source=deepseek-harness/);
|
||||
assert.match(requests[1].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/query_result\?/);
|
||||
assert.equal(requests.every(({ options }) => options.redirect === 'error'), true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat QR auth rejects an authorization URL outside the official host', async () => {
|
||||
const auth = new WecomQrAuth({
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { scode: 'opaque', auth_url: 'https://example.com/steal' } }),
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => auth.start(), /invalid data/);
|
||||
});
|
||||
76
test/channels/wecom/controller-and-rpc.test.mjs
Normal file
76
test/channels/wecom/controller-and-rpc.test.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { WecomController } from '../../../src/channels/wecom/wecom-controller.mjs';
|
||||
import {
|
||||
createWecomRpcHandler,
|
||||
WECOM_ENDPOINTS,
|
||||
} from '../../../plugin-src/host/channels/wecom/rpc.mjs';
|
||||
|
||||
test('Enterprise WeChat QR success stores Secret off-config and starts its runtime', async () => {
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new WecomController({
|
||||
qrAuth: {
|
||||
start: async () => ({
|
||||
scode: 'host-only-code',
|
||||
verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque',
|
||||
expiresAt: Date.now() + 10_000,
|
||||
pollIntervalMs: 500,
|
||||
}),
|
||||
poll: async () => ({ status: 'success', remoteBotId: 'remote-bot', secret: 'private-secret' }),
|
||||
},
|
||||
credentials: {
|
||||
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
|
||||
set: async (ref, value) => values.set(ref, value),
|
||||
unset: async (ref) => values.delete(ref),
|
||||
},
|
||||
configStore: {
|
||||
list: () => [...configs],
|
||||
get: (id) => configs.find((value) => value.botId === id) ?? null,
|
||||
getByRemoteBotId: (id) => configs.find((value) => value.remoteBotId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return {
|
||||
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const started = await controller.startProvisioning();
|
||||
const completed = await controller.registrationStatus(started.attemptId);
|
||||
assert.equal(completed.status, 'connected');
|
||||
const status = controller.status();
|
||||
assert.equal(status.bots[0].connected, true);
|
||||
assert.equal(values.get(configs[0].secretRef), 'private-secret');
|
||||
assert.equal(runtimeArgs.secret, 'private-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /private-secret|secretRef|remote-bot|host-only-code/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat RPC encodes the QR on Host and strips every authorization field', async () => {
|
||||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({
|
||||
attemptId: 'attempt_1',
|
||||
status: 'pending',
|
||||
verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque',
|
||||
scode: 'never-public',
|
||||
secret: 'never-public',
|
||||
remoteBotId: 'never-public',
|
||||
expiresAt: Date.now() + 1_000,
|
||||
}),
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/);
|
||||
});
|
||||
80
test/channels/wecom/runtime.test.mjs
Normal file
80
test/channels/wecom/runtime.test.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs';
|
||||
|
||||
class FakeClient extends EventEmitter {
|
||||
disconnected = false;
|
||||
connect() { queueMicrotask(() => this.emit('authenticated')); }
|
||||
disconnect() { this.disconnected = true; }
|
||||
async replyStream() {}
|
||||
async replyStreamNonBlocking() {}
|
||||
async sendMessage() {}
|
||||
}
|
||||
|
||||
test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => {
|
||||
const client = new FakeClient();
|
||||
let options;
|
||||
const logs = [];
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createClient: (value) => { options = value; return client; },
|
||||
logger: { debug: (...args) => logs.push(args), warn() {} },
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
const status = await runtime.start();
|
||||
assert.equal(status.ready, true);
|
||||
assert.equal(status.wecomConnectionState, 'connected');
|
||||
assert.equal(options.botId, 'remote-bot');
|
||||
assert.equal(options.secret, 'private-secret');
|
||||
options.logger.debug('raw message payload');
|
||||
options.logger.warn('raw unknown frame');
|
||||
assert.deepEqual(logs, []);
|
||||
client.emit('disconnected', 'network');
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.wecomConnectionState, 'connecting');
|
||||
client.emit('authenticated');
|
||||
assert.equal(runtime.status.ready, true);
|
||||
await runtime.stop();
|
||||
assert.equal(client.disconnected, true);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat runtime never reports ready without SDK authentication', async () => {
|
||||
const client = new FakeClient();
|
||||
client.connect = () => {};
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createClient: () => client,
|
||||
connectTimeoutMs: 5,
|
||||
});
|
||||
await assert.rejects(() => runtime.start(), /authentication timed out/);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(client.disconnected, true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat runtime stop cancels an in-flight authentication wait', async () => {
|
||||
const client = new FakeClient();
|
||||
client.connect = () => {};
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createClient: () => client,
|
||||
connectTimeoutMs: 60_000,
|
||||
});
|
||||
const starting = runtime.start();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await runtime.stop();
|
||||
await assert.rejects(starting, { name: 'AbortError' });
|
||||
assert.equal(client.disconnected, true);
|
||||
assert.equal(runtime.status.wecomConnectionState, 'idle');
|
||||
});
|
||||
|
|
@ -19,6 +19,10 @@ import {
|
|||
AccountCard as WeixinAccountCard,
|
||||
WeixinSettingsTab,
|
||||
} from '../plugin-src/client/channels/weixin/index.js';
|
||||
import {
|
||||
AccountCard as WecomAccountCard,
|
||||
WecomSettingsTab,
|
||||
} from '../plugin-src/client/channels/wecom/index.js';
|
||||
|
||||
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
|
||||
const FEISHU_STYLES_URL = new URL(
|
||||
|
|
@ -33,6 +37,10 @@ const DINGTALK_STYLES_URL = new URL(
|
|||
'../plugin-src/client/channels/dingtalk/styles.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const WECOM_STYLES_URL = new URL(
|
||||
'../plugin-src/client/channels/wecom/styles.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const FEISHU_SOURCE_URL = new URL(
|
||||
'../plugin-src/client/channels/feishu/index.js',
|
||||
import.meta.url,
|
||||
|
|
@ -46,12 +54,17 @@ const DINGTALK_CLIENT_SOURCE_URL = new URL(
|
|||
'../plugin-src/client/channels/dingtalk/index.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const WECOM_SOURCE_URL = new URL(
|
||||
'../plugin-src/client/channels/wecom/index.js',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders four compact logo channel tabs without enable switches', () => {
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
wecomRpcCall: async () => ({ ok: true, value: {} }),
|
||||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
|
|
@ -61,12 +74,14 @@ test('IM settings renders four compact logo channel tabs without enable switches
|
|||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, />钉钉</);
|
||||
assert.match(markup, />企业微信</);
|
||||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.match(markup, /dim-logoWecom/);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 4);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 5);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
@ -138,20 +153,23 @@ test('Feishu keeps its heading controls on one row without a plus icon', async (
|
|||
});
|
||||
|
||||
test('scan actions align left while online totals align right in every channel', async () => {
|
||||
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, feishuSource, weixinSource, dingtalkSource] = await Promise.all([
|
||||
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, wecomStyles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
readFile(FEISHU_STYLES_URL, 'utf8'),
|
||||
readFile(WEIXIN_STYLES_URL, 'utf8'),
|
||||
readFile(DINGTALK_STYLES_URL, 'utf8'),
|
||||
readFile(WECOM_STYLES_URL, 'utf8'),
|
||||
readFile(FEISHU_SOURCE_URL, 'utf8'),
|
||||
readFile(WEIXIN_SOURCE_URL, 'utf8'),
|
||||
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
|
||||
readFile(WECOM_SOURCE_URL, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(imStyles, /\.dim-panel \.bxf-headingTools, \.dim-panel \.dxw-tools, \.dim-panel \.ddt-tools \{[^}]*justify-content: space-between;/);
|
||||
assert.match(feishuStyles, /\.bxf-headingTools \{[^}]*justify-content: space-between;/);
|
||||
assert.match(weixinStyles, /\.dxw-tools \{[^}]*justify-content: space-between;/);
|
||||
assert.match(dingtalkStyles, /\.ddt-tools \{[^}]*justify-content: space-between;/);
|
||||
assert.match(wecomStyles, /\.dwecom-page/);
|
||||
|
||||
const headingSource = (source) => source.slice(
|
||||
source.indexOf('function Heading'),
|
||||
|
|
@ -160,11 +178,13 @@ test('scan actions align left while online totals align right in every channel',
|
|||
const feishuHeading = headingSource(feishuSource);
|
||||
const weixinHeading = headingSource(weixinSource);
|
||||
const dingtalkHeading = headingSource(dingtalkSource);
|
||||
const wecomHeading = headingSource(wecomSource);
|
||||
assert.ok(feishuHeading.indexOf('扫码绑定机器人') < feishuHeading.indexOf('bxf-totalBadge'));
|
||||
assert.ok(weixinHeading.indexOf('扫码绑定微信') < weixinHeading.indexOf('dxw-badge'));
|
||||
assert.ok(dingtalkHeading.indexOf('扫码接入钉钉') < dingtalkHeading.indexOf('ddt-badge'));
|
||||
assert.ok(wecomHeading.indexOf('扫码绑定企业微信机器人') < wecomHeading.indexOf('ddt-badge'));
|
||||
|
||||
for (const heading of [feishuHeading, weixinHeading, dingtalkHeading]) {
|
||||
for (const heading of [feishuHeading, weixinHeading, dingtalkHeading, wecomHeading]) {
|
||||
assert.match(heading, /dim-scanButton/);
|
||||
assert.match(heading, /dim-onlineBadge/);
|
||||
}
|
||||
|
|
@ -175,7 +195,7 @@ test('scan actions align left while online totals align right in every channel',
|
|||
});
|
||||
|
||||
test('channel headings omit the redundant local credential badge', () => {
|
||||
const components = [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab];
|
||||
const components = [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab, WecomSettingsTab];
|
||||
|
||||
for (const Component of components) {
|
||||
const markup = renderToStaticMarkup(React.createElement(Component, {
|
||||
|
|
@ -186,14 +206,15 @@ test('channel headings omit the redundant local credential badge', () => {
|
|||
});
|
||||
|
||||
test('all channel settings states use the DingTalk page treatment', async () => {
|
||||
const [styles, feishuSource, weixinSource, dingtalkSource] = await Promise.all([
|
||||
const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
readFile(FEISHU_SOURCE_URL, 'utf8'),
|
||||
readFile(WEIXIN_SOURCE_URL, 'utf8'),
|
||||
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
|
||||
readFile(WECOM_SOURCE_URL, 'utf8'),
|
||||
]);
|
||||
|
||||
for (const Component of [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab]) {
|
||||
for (const Component of [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab, WecomSettingsTab]) {
|
||||
const markup = renderToStaticMarkup(React.createElement(Component, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
|
@ -202,7 +223,7 @@ test('all channel settings states use the DingTalk page treatment', async () =>
|
|||
assert.match(markup, /dim-spinner/);
|
||||
}
|
||||
|
||||
for (const source of [feishuSource, weixinSource, dingtalkSource]) {
|
||||
for (const source of [feishuSource, weixinSource, dingtalkSource, wecomSource]) {
|
||||
for (const className of [
|
||||
'dim-channelPage',
|
||||
'dim-surfaceCard',
|
||||
|
|
@ -232,6 +253,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
|
|||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
wecomRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
const accountMarkup = renderToStaticMarkup(React.createElement(WeixinAccountCard, {
|
||||
account: {
|
||||
|
|
@ -250,6 +272,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
|
|||
|
||||
assert.match(railMarkup, /data-im-channel-logo="weixin"/);
|
||||
assert.match(railMarkup, /data-im-channel-logo="feishu"/);
|
||||
assert.match(railMarkup, /data-im-channel-logo="wecom"/);
|
||||
assert.match(accountMarkup, /class="dxw-card dim-botCard"/);
|
||||
assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/);
|
||||
assert.match(accountMarkup, /class="dxw-health dim-botHealth"/);
|
||||
|
|
@ -259,6 +282,20 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
|
|||
assert.doesNotMatch(accountMarkup, /收到 \/ 回复/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat cards reuse the rail logo and compact action treatment', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WecomAccountCard, {
|
||||
account: {
|
||||
botId: 'wecom-card', state: 'connected', connected: true,
|
||||
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
|
||||
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
},
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="wecom"/);
|
||||
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('DingTalk bot cards omit the redundant received and replied metric', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(DingtalkAccountCard, {
|
||||
account: {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', async () => {
|
||||
test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
|
||||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]),
|
||||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
|
|
@ -16,6 +17,7 @@ test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context',
|
|||
feishu: { domain: 'feishu' },
|
||||
weixin: { timeout: 30 },
|
||||
dingtalk: { replyTimeoutMs: 60_000 },
|
||||
wecom: { replyTimeoutMs: 60_000 },
|
||||
qq: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context',
|
|||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
['dingtalk', ctx, config.dingtalk],
|
||||
['wecom', ctx, config.wecom],
|
||||
['qq', ctx, config.qq],
|
||||
]);
|
||||
});
|
||||
|
|
@ -37,6 +40,7 @@ test('Host does not start Weixin when Feishu activation fails', async () => {
|
|||
applyFeishu: async () => { throw new Error('feishu unavailable'); },
|
||||
applyWeixin: async () => { weixinStarted = true; },
|
||||
applyDingtalk: async () => { throw new Error('DingTalk must not start'); },
|
||||
applyWecom: async () => { throw new Error('Enterprise WeChat must not start'); },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
|
|
@ -50,6 +54,7 @@ test('Host does not start DingTalk when Weixin activation fails', async () => {
|
|||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => { throw new Error('weixin unavailable'); },
|
||||
applyDingtalk: async () => { dingtalkStarted = true; },
|
||||
applyWecom: async () => { throw new Error('Enterprise WeChat must not start'); },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
|
|
@ -57,15 +62,30 @@ test('Host does not start DingTalk when Weixin activation fails', async () => {
|
|||
assert.equal(dingtalkStarted, false);
|
||||
});
|
||||
|
||||
test('Host does not start QQ when DingTalk activation fails', async () => {
|
||||
let qqStarted = false;
|
||||
test('Host does not start Enterprise WeChat when DingTalk activation fails', async () => {
|
||||
let wecomStarted = false;
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => {},
|
||||
applyDingtalk: async () => { throw new Error('dingtalk unavailable'); },
|
||||
applyQq: async () => { qqStarted = true; },
|
||||
applyWecom: async () => { wecomStarted = true; },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /dingtalk unavailable/);
|
||||
assert.equal(wecomStarted, false);
|
||||
});
|
||||
|
||||
test('Host does not start QQ when Enterprise WeChat activation fails', async () => {
|
||||
let qqStarted = false;
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => {},
|
||||
applyDingtalk: async () => {},
|
||||
applyWecom: async () => { throw new Error('wecom unavailable'); },
|
||||
applyQq: async () => { qqStarted = true; },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /wecom unavailable/);
|
||||
assert.equal(qqStarted, false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue