mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 04:03:16 +08:00
feat: send test messages during bot connection checks
This commit is contained in:
parent
94da24538f
commit
fb61c4235f
78 changed files with 3460 additions and 210 deletions
|
|
@ -4,6 +4,7 @@ import test from 'node:test';
|
|||
import {
|
||||
DINGTALK_ENDPOINTS,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
connectionTestFeedback,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
|
|
@ -83,6 +84,11 @@ test('snapshot derives totals and exposes only browser-safe bot state', () => {
|
|||
schemaVersion: 1,
|
||||
revision: 7,
|
||||
totals: { configured: 99, connected: 99 },
|
||||
testMessage: {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
providerDetail: 'must-not-cross-normalization',
|
||||
},
|
||||
bots: [{
|
||||
botId: 'bot-safe',
|
||||
connected: true,
|
||||
|
|
@ -104,12 +110,31 @@ test('snapshot derives totals and exposes only browser-safe bot state', () => {
|
|||
assert.deepEqual(snapshot.totals, { configured: 1, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal('senders' in snapshot.bots[0], false);
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(snapshot),
|
||||
/raw-client-id|raw-client-secret|credential-ref|device-code|raw-staff-id/,
|
||||
);
|
||||
});
|
||||
|
||||
test('connection-test feedback uses fixed client-owned messages', () => {
|
||||
assert.equal(
|
||||
connectionTestFeedback({ sent: true }),
|
||||
'钉钉连接检查完成,测试消息已发送。',
|
||||
);
|
||||
assert.equal(
|
||||
connectionTestFeedback({ sent: false, code: 'test-target-unavailable' }),
|
||||
'钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。',
|
||||
);
|
||||
assert.equal(
|
||||
connectionTestFeedback({ sent: false, code: 'test-message-failed' }),
|
||||
'钉钉连接检查完成,但测试消息发送失败。',
|
||||
);
|
||||
assert.equal(connectionTestFeedback(null), null);
|
||||
});
|
||||
|
||||
test('presentation helpers redact sensitive messages and format countdowns', () => {
|
||||
assert.deepEqual(
|
||||
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
|
||||
|
|
|
|||
|
|
@ -124,6 +124,192 @@ function findButton(renderer, label) {
|
|||
return button;
|
||||
}
|
||||
|
||||
test('connection check requests a test message and announces its delivery', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
const bot = {
|
||||
botId: 'dt_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••test' },
|
||||
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
|
||||
};
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
return ok(snapshot({ state: 'connected', bots: [bot] }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return ok(snapshot({
|
||||
state: 'connected',
|
||||
bots: [bot],
|
||||
testMessage: { sent: true },
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.reconnectBot)?.payload, {
|
||||
botId: 'dt_test', sendTest: true,
|
||||
});
|
||||
const announcement = [...clock.frames.entries()].at(-1);
|
||||
assert.ok(announcement, 'successful connection test schedules an announcement');
|
||||
await act(async () => {
|
||||
clock.frames.delete(announcement[0]);
|
||||
announcement[1]();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const liveRegion = renderer.root.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
|
||||
);
|
||||
assert.equal(nodeText(liveRegion), '钉钉连接检查完成,测试消息已发送。');
|
||||
const botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_test');
|
||||
const visibleFeedback = botCard.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === undefined,
|
||||
);
|
||||
assert.equal(nodeText(visibleFeedback), '钉钉连接检查完成,测试消息已发送。');
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('connection-check failure stays on the matching card with locale-safe wording', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
const bots = ['dt_one', 'dt_two'].map((botId) => {
|
||||
const connected = botId === 'dt_one';
|
||||
return {
|
||||
botId,
|
||||
connected,
|
||||
state: connected ? 'connected' : 'error',
|
||||
bot: { name: botId, clientIdMasked: `ding••••${botId.slice(-3)}` },
|
||||
health: {
|
||||
status: connected ? 'healthy' : 'offline',
|
||||
summary: connected ? '连接正常' : '现有连接错误',
|
||||
lastCheckedAt: Date.now(),
|
||||
},
|
||||
error: connected ? null : { code: 'STREAM_DOWN', message: '现有连接错误' },
|
||||
};
|
||||
});
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
return ok(snapshot({ state: 'degraded', bots }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
assert.equal(payload.botId, 'dt_two');
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'UPSTREAM_FAILED', message: 'provider-specific failure' },
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const targetCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_two');
|
||||
const targetButton = targetCard.findAllByType('button')
|
||||
.find((candidate) => nodeText(candidate) === '重试连接');
|
||||
assert.ok(targetButton);
|
||||
await act(async () => {
|
||||
targetButton.props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_one');
|
||||
assert.equal(firstCard.findAll((node) => node.props.role === 'status').length, 0);
|
||||
const visibleFeedback = targetCard.find((node) => node.props.role === 'status');
|
||||
assert.equal(nodeText(visibleFeedback), '连接检查失败,请稍后重试。');
|
||||
assert.match(nodeText(targetCard), /现有连接错误/);
|
||||
assert.equal(
|
||||
targetCard.findAll((node) => node.props.className?.includes('dim-cardSummary')).length,
|
||||
2,
|
||||
);
|
||||
assert.doesNotMatch(nodeText(targetCard), /provider-specific failure/);
|
||||
const announcement = [...clock.frames.entries()].at(-1);
|
||||
assert.ok(announcement, 'failed connection check schedules an announcement');
|
||||
await act(async () => {
|
||||
clock.frames.delete(announcement[0]);
|
||||
announcement[1]();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const liveRegion = renderer.root.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
|
||||
);
|
||||
assert.equal(nodeText(liveRegion), '连接检查失败,请稍后重试。');
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('a later disconnect removes stale success feedback and exposes the account error', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
const connectedBot = {
|
||||
botId: 'dt_stale',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: '状态测试机器人', clientIdMasked: 'ding••••ale' },
|
||||
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
|
||||
};
|
||||
const disconnectedBot = {
|
||||
...connectedBot,
|
||||
connected: false,
|
||||
state: 'error',
|
||||
health: { status: 'offline', summary: 'Stream 已断开', lastCheckedAt: Date.now() },
|
||||
error: { code: 'STREAM_DOWN', message: 'Stream 已断开' },
|
||||
};
|
||||
let disconnected = false;
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
const bot = disconnected ? disconnectedBot : connectedBot;
|
||||
return ok(snapshot({ state: bot.connected ? 'connected' : 'degraded', bots: [bot] }));
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return ok(snapshot({
|
||||
state: 'connected',
|
||||
bots: [connectedBot],
|
||||
testMessage: { sent: true },
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
let botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_stale');
|
||||
assert.match(nodeText(botCard), /测试消息已发送/);
|
||||
|
||||
disconnected = true;
|
||||
await act(async () => {
|
||||
await clock.runInterval(15_000);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_stale');
|
||||
assert.match(nodeText(botCard), /Stream 已断开/);
|
||||
assert.doesNotMatch(nodeText(botCard), /测试消息已发送/);
|
||||
assert.equal(botCard.findAll((node) => node.props.role === 'status').length, 0);
|
||||
act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('a late poll response cannot issue status RPCs or schedule work after effect cleanup', async (t) => {
|
||||
const clock = createBrowserClock();
|
||||
t.after(() => clock.restore());
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
createDingtalkBridgeStatus,
|
||||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -81,6 +82,38 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('DingTalk remembers only a private /status session webhook for connection tests', async () => {
|
||||
const privateFixture = stateFixture();
|
||||
const privateSent = [];
|
||||
const privateBridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async (request) => privateSent.push(request) },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: privateFixture.state,
|
||||
});
|
||||
await privateBridge.accept(message('status-private', '/status'));
|
||||
assert.deepEqual(connectionTestTarget(privateFixture.state), {
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
|
||||
});
|
||||
assert.equal(privateSent.at(-1).text, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
||||
|
||||
const groupFixture = stateFixture();
|
||||
const groupBridge = new DingtalkHarnessBridge({
|
||||
api: { sendText: async () => {} },
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: groupFixture.state,
|
||||
});
|
||||
await groupBridge.accept(message('status-group', '/status', {
|
||||
conversationType: '2',
|
||||
conversationId: 'group-status',
|
||||
isInAtList: true,
|
||||
}));
|
||||
assert.equal(connectionTestTarget(groupFixture.state), null);
|
||||
});
|
||||
|
||||
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
|
|
|
|||
|
|
@ -56,8 +56,10 @@ function configFixture(initial = [], events = []) {
|
|||
|
||||
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
return {
|
||||
runtimes,
|
||||
connectionTests,
|
||||
createRuntime: async ({ botId, config, clientSecret }) => {
|
||||
events.push(['create', botId]);
|
||||
let ready = false;
|
||||
|
|
@ -91,6 +93,9 @@ function runtimeFactory({ events = [], pendingByClient = new Map(), failStart =
|
|||
events.push(['stop', botId]);
|
||||
ready = false;
|
||||
},
|
||||
async sendConnectionTest(text) {
|
||||
connectionTests.push({ botId, text });
|
||||
},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
|
|
@ -145,6 +150,10 @@ test('successful QR poll stores secret then config then starts runtime without p
|
|||
/device-code-private|client-secret-private|secretRef|ding-client-private/,
|
||||
);
|
||||
assert.equal(controller.status().totals.connected, 1);
|
||||
await controller.sendConnectionTest(completed.botId);
|
||||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { DingtalkRuntime } from '../../../src/channels/dingtalk/dingtalk-runtime.mjs';
|
||||
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
|
@ -44,6 +45,42 @@ function stateFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
test('runtime sends a DingTalk connection test only through the remembered private status webhook', async () => {
|
||||
const state = stateFixture();
|
||||
const sends = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
socket: { readyState: 1 },
|
||||
registerCallbackListener() {},
|
||||
async connect() {},
|
||||
socketCallBackResponse() {},
|
||||
disconnect() {},
|
||||
};
|
||||
const runtime = new DingtalkRuntime({
|
||||
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
api: { sendText: async (request) => sends.push(request) },
|
||||
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await assert.rejects(() => runtime.sendConnectionTest('连接测试'), {
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
rememberConnectionTestTarget(state, {
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
|
||||
});
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(sends[0].clientId, 'ding-client');
|
||||
assert.equal(sends[0].clientSecret, 'host-secret');
|
||||
assert.equal(sends[0].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=status-private');
|
||||
assert.equal(sends[0].text, '连接测试');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('runtime owns one DWClient, waits for socket OPEN, acknowledges first, and disconnects on stop', async () => {
|
||||
const order = [];
|
||||
const state = stateFixture();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ function controller(overrides = {}) {
|
|||
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => ({ bots: [] }),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
approveSender: async () => ({ bots: [] }),
|
||||
revokeSender: async () => ({ bots: [] }),
|
||||
|
|
@ -113,3 +114,55 @@ test('RPC is registered for loopback clients only', () => {
|
|||
assert.equal(registrations[0][0], '/dingtalk');
|
||||
assert.deepEqual(registrations[0][2], { authority: 'loopback' });
|
||||
});
|
||||
|
||||
test('reconnect sends a DingTalk test message only for a connected bot and isolates send failures', async () => {
|
||||
const sent = [];
|
||||
const connectedSnapshot = {
|
||||
bots: [{ botId: 'dt_abc', connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
};
|
||||
const success = await createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => connectedSnapshot,
|
||||
sendConnectionTest: async (botId) => { sent.push(botId); },
|
||||
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, ['dt_abc']);
|
||||
|
||||
const failedSend = await createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => connectedSnapshot,
|
||||
sendConnectionTest: async () => { throw new Error('expired webhook'); },
|
||||
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false, code: 'test-message-failed',
|
||||
});
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = await createDingtalkRpcHandler(controller({
|
||||
reconnectBot: async () => ({
|
||||
bots: [{ botId: 'dt_abc', connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
sendConnectionTest: async () => { offlineSendCalled = true; },
|
||||
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
|
||||
assert.equal(offline.ok, true);
|
||||
assert.deepEqual(offline.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const withoutMethod = controller({ reconnectBot: async () => connectedSnapshot });
|
||||
delete withoutMethod.sendConnectionTest;
|
||||
const unavailableWithoutMethod = await createDingtalkRpcHandler(withoutMethod)(
|
||||
DINGTALK_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'dt_abc', sendTest: true },
|
||||
);
|
||||
assert.equal(unavailableWithoutMethod.ok, true);
|
||||
assert.deepEqual(unavailableWithoutMethod.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await createDingtalkRpcHandler(controller())(
|
||||
DINGTALK_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'dt_abc', sendTest: false },
|
||||
)).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
}, '1234567890123456789');
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: '222222222222222222' });
|
||||
|
||||
const group = normalizeDiscordMessage({
|
||||
id: '111111111111111112',
|
||||
|
|
|
|||
124
test/channels/feishu/connection-test-client.test.mjs
Normal file
124
test/channels/feishu/connection-test-client.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
|
||||
import { FEISHU_ENDPOINTS } from '../../../plugin-src/client/channels/feishu/api.js';
|
||||
import {
|
||||
BotCard,
|
||||
FeishuSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/feishu/index.js';
|
||||
import {
|
||||
en,
|
||||
setImTranslator,
|
||||
} from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (!node) return '';
|
||||
const children = node.children ?? node.props?.children ?? [];
|
||||
return (Array.isArray(children) ? children : [children]).map(textOf).join('');
|
||||
}
|
||||
|
||||
test('Feishu connection check requests and displays test-message feedback', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/feishu/index.js',
|
||||
import.meta.url,
|
||||
), 'utf8');
|
||||
assert.match(source, /FEISHU_ENDPOINTS\.reconnectBot, \{ botId, sendTest: true \}/);
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(BotCard, {
|
||||
connection: {
|
||||
botId: 'bot-feishu-test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
bot: { name: '飞书测试机器人', appIdMasked: 'cli_test••••1234' },
|
||||
health: { summary: '长连接运行正常', lastCheckedAt: Date.now() },
|
||||
},
|
||||
testNotice: '测试消息已发送,请到飞书会话中确认。',
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('Feishu reconnect failures render fixed English-safe feedback', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
let nextFrame = 0;
|
||||
const frames = new Map();
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
requestAnimationFrame(callback) {
|
||||
const id = ++nextFrame;
|
||||
frames.set(id, callback);
|
||||
queueMicrotask(() => {
|
||||
const pending = frames.get(id);
|
||||
if (!pending) return;
|
||||
frames.delete(id);
|
||||
pending();
|
||||
});
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame(id) { frames.delete(id); },
|
||||
};
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => {
|
||||
setImTranslator(null);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
bots: [{
|
||||
botId: 'bot_feishu_test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name: '今天是牢梁', appIdMasked: 'cli_test••••1234' },
|
||||
health: { status: 'healthy', summary: 'Long connection is healthy' },
|
||||
}],
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
|
||||
if (endpoint === FEISHU_ENDPOINTS.reconnectBot) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'FEISHU_UPSTREAM_FAILED', message: '飞书上游操作失败' },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'bot_feishu_test' });
|
||||
await act(async () => {
|
||||
card.findAllByType('button')
|
||||
.find((button) => textOf(button) === 'Check connection').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const announcement = renderer.root.find(
|
||||
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
|
||||
);
|
||||
assert.equal(textOf(announcement), 'Connection check failed. Try again later.');
|
||||
assert.doesNotMatch(textOf(announcement), /[\p{Script=Han}]/u);
|
||||
assert.match(textOf(card), /Connection check failed\. Try again later\./);
|
||||
assert.doesNotMatch(textOf(card), /飞书上游操作失败/);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
|
@ -4,9 +4,20 @@ import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
|
|||
|
||||
class FakeClient {
|
||||
static instances = [];
|
||||
static sent = [];
|
||||
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.im = {
|
||||
v1: {
|
||||
message: {
|
||||
create: async (payload) => {
|
||||
FakeClient.sent.push(payload);
|
||||
return { code: 0, data: { message_id: `message-${FakeClient.sent.length}` } };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
FakeClient.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +59,7 @@ class FakeWSClient {
|
|||
function fakeLark() {
|
||||
FakeWSClient.instances.length = 0;
|
||||
FakeClient.instances.length = 0;
|
||||
FakeClient.sent.length = 0;
|
||||
return {
|
||||
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
|
||||
LoggerLevel: { info: 'info' },
|
||||
|
|
@ -104,6 +116,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
url: 'https://open.feishu.cn/test',
|
||||
})).timeout, 15_000);
|
||||
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(FakeClient.sent, [{
|
||||
params: { receive_id_type: 'open_id' },
|
||||
data: {
|
||||
receive_id: 'ou_owner',
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text: '连接测试' }),
|
||||
},
|
||||
}]);
|
||||
|
||||
const stopped = await runtime.stop();
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ function fixture({
|
|||
appSecret,
|
||||
starts: 0,
|
||||
stops: 0,
|
||||
sentTests: [],
|
||||
get status() { return structuredClone(status); },
|
||||
async start() {
|
||||
runtime.starts += 1;
|
||||
|
|
@ -113,6 +114,10 @@ function fixture({
|
|||
status.ready = false;
|
||||
status.feishuLongConnectionState = 'idle';
|
||||
},
|
||||
async sendConnectionTest(text) {
|
||||
runtime.sentTests.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
const history = runtimes.get(botId) ?? [];
|
||||
history.push(runtime);
|
||||
|
|
@ -197,6 +202,22 @@ test('repeated initialization never restarts an already healthy bot', async () =
|
|||
assert.equal(fx.runtimes.get(healthy.id)[0].stops, 0);
|
||||
});
|
||||
|
||||
test('connection test uses the selected bot runtime and shared message copy', async () => {
|
||||
const healthy = bot('bot_healthy', 'healthy');
|
||||
healthy.appId = 'cli_healthy_1234567890';
|
||||
const fx = fixture({
|
||||
bots: [healthy],
|
||||
secrets: { [healthy.secretRef]: 'healthy-secret' },
|
||||
});
|
||||
|
||||
await fx.controller.initialize();
|
||||
assert.deepEqual(await fx.controller.sendConnectionTest(healthy.id), { sent: true });
|
||||
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
|
||||
]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('multiple scans create independent bots, credential refs and runtimes', async () => {
|
||||
const fx = fixture({ createBotIds: ['bot_alpha', 'bot_beta'] });
|
||||
const alpha = completeScan(fx, {
|
||||
|
|
|
|||
|
|
@ -398,22 +398,27 @@ test('multi-bot RPC operations require a botId and never expose host-only fields
|
|||
cancelRegistration: async () => multi,
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async (botId) => { calls.push(['reconnect', botId]); return multi; },
|
||||
sendConnectionTest: async (botId) => { calls.push(['test-message', botId]); },
|
||||
disconnectBot: async (botId) => { calls.push(['disconnect', botId]); return multi; },
|
||||
deleteBot: async (botId) => { calls.push(['delete', botId]); return status({ schemaVersion: 2, bots: [] }); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
for (const [endpoint, payload] of [
|
||||
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe' }],
|
||||
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe', sendTest: true }],
|
||||
[FEISHU_MULTI_ENDPOINTS.disconnectBot, { botId: 'bot_safe' }],
|
||||
[FEISHU_MULTI_ENDPOINTS.deleteBot, { botId: 'bot_safe', confirm: true }],
|
||||
]) {
|
||||
const result = await fx.registration.handler(endpoint, payload, signal());
|
||||
assert.equal(result.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(result), /DSH_FEISHU_APP_SECRET|ou_private|cli_raw_private|private-token/);
|
||||
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
|
||||
assert.deepEqual(result.value.testMessage, { sent: true });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['reconnect', 'bot_safe'],
|
||||
['test-message', 'bot_safe'],
|
||||
['disconnect', 'bot_safe'],
|
||||
['delete', 'bot_safe'],
|
||||
]);
|
||||
|
|
@ -428,6 +433,105 @@ test('multi-bot RPC operations require a botId and never expose host-only fields
|
|||
assert.doesNotMatch(JSON.stringify(invalid), /\.\.\/private/);
|
||||
});
|
||||
|
||||
test('Feishu reconnect test delivery is best-effort and requires the target bot to be connected', async () => {
|
||||
let connected = true;
|
||||
let sendCalls = 0;
|
||||
const snapshot = () => status({
|
||||
schemaVersion: 2,
|
||||
revision: 1,
|
||||
configured: true,
|
||||
bots: [{
|
||||
botId: 'bot_safe',
|
||||
phase: connected ? 'connected' : 'disconnected',
|
||||
connected,
|
||||
configured: true,
|
||||
bot: { name: '安全机器人', appIdMasked: 'cli_safe••••1234', domain: 'feishu' },
|
||||
connection: {
|
||||
ready: connected,
|
||||
feishuLongConnectionState: connected ? 'connected' : 'idle',
|
||||
harnessReachable: connected,
|
||||
},
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => snapshot(),
|
||||
startRegistration: async () => snapshot(),
|
||||
cancelRegistration: async () => snapshot(),
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => {
|
||||
sendCalls += 1;
|
||||
throw new Error('private Feishu provider failure');
|
||||
},
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const failedSend = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'bot_safe', sendTest: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-message-failed',
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(failedSend), /private Feishu provider failure/);
|
||||
assert.equal(sendCalls, 1);
|
||||
|
||||
connected = false;
|
||||
const unavailable = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'bot_safe', sendTest: true },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(sendCalls, 1);
|
||||
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_MULTI_ENDPOINTS.reconnectBot,
|
||||
{ botId: 'bot_safe', sendTest: 'yes' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('Feishu RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const controller = {
|
||||
status: async () => status(),
|
||||
startRegistration: async () => status(),
|
||||
cancelRegistration: async () => status(),
|
||||
disconnect: async () => status(),
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
const abort = new AbortController();
|
||||
const result = fx.registration.handler(FEISHU_MULTI_ENDPOINTS.reconnectBot, {
|
||||
botId: 'bot_safe',
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect(status({
|
||||
schemaVersion: 2,
|
||||
bots: [{ botId: 'bot_safe', connected: true }],
|
||||
}));
|
||||
|
||||
assert.equal((await result).error.code, 'cancelled');
|
||||
assert.equal(sendCalls, 0);
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('dependency failures and AbortSignal use valid RpcResult error branches', async () => {
|
||||
const secret = 'should-never-be-reflected';
|
||||
const controller = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -258,6 +259,45 @@ test('QQ executes /compact for the bound Session without prompting the model', a
|
|||
assert.equal(fixture.seen.has('compact-qq'), true);
|
||||
});
|
||||
|
||||
test('QQ remembers a connection-test target only for an authorized private /status', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (target, text) => sent.push({ target, text }) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'status-rejected',
|
||||
senderId: 'other-openid',
|
||||
content: '/status',
|
||||
replyTarget: { scope: 'c2c', targetId: 'other-openid', msgId: 'status-rejected' },
|
||||
}));
|
||||
await bridge.accept(message({
|
||||
kind: 'group',
|
||||
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
|
||||
groupOpenid: 'group-1',
|
||||
messageId: 'status-group',
|
||||
content: '/status',
|
||||
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'status-group' },
|
||||
}));
|
||||
assert.equal(connectionTestTarget(fixture.state), null);
|
||||
|
||||
const privateTarget = {
|
||||
scope: 'c2c', targetId: 'owner-openid', msgId: 'status-private',
|
||||
};
|
||||
await bridge.accept(message({
|
||||
messageId: 'status-private',
|
||||
content: '/status',
|
||||
replyTarget: privateTarget,
|
||||
}));
|
||||
|
||||
assert.deepEqual(connectionTestTarget(fixture.state), privateTarget);
|
||||
assert.equal(sent.filter(({ text }) => text.includes('连接正常')).length, 2);
|
||||
});
|
||||
|
||||
test('QQ private messages stream Harness snapshots and finalize once', async () => {
|
||||
const frames = [];
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js';
|
||||
import {
|
||||
connectionTestFeedback,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
safeQrSource,
|
||||
} from '../../../plugin-src/client/channels/qq/api.js';
|
||||
|
||||
test('QQ client keeps only redacted bot and host-rendered QR state', () => {
|
||||
const qr = 'data:image/png;base64,YWJjZA==';
|
||||
|
|
@ -20,3 +25,26 @@ test('QQ client keeps only redacted bot and host-rendered QR state', () => {
|
|||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456');
|
||||
});
|
||||
|
||||
test('QQ client normalizes and presents connection-test outcomes', () => {
|
||||
const sent = normalizeSnapshot({ bots: [], testMessage: { sent: true, code: 'ignored' } });
|
||||
assert.deepEqual(sent.testMessage, { sent: true });
|
||||
assert.equal(
|
||||
connectionTestFeedback(sent.testMessage),
|
||||
'测试消息已发送,请到对应机器人会话中确认。',
|
||||
);
|
||||
|
||||
const unavailable = normalizeSnapshot({
|
||||
bots: [], testMessage: { sent: false, code: 'test-target-unavailable' },
|
||||
});
|
||||
assert.deepEqual(unavailable.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.match(connectionTestFeedback(unavailable.testMessage), /\/status/);
|
||||
|
||||
const invalid = normalizeSnapshot({
|
||||
bots: [], testMessage: { sent: false, code: 'private-provider-error' },
|
||||
});
|
||||
assert.deepEqual(invalid.testMessage, { sent: false, code: 'test-message-failed' });
|
||||
assert.equal(connectionTestFeedback(undefined), null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AccountCard, QqSettingsTab } from '../../../plugin-src/client/channels/qq/index.js';
|
||||
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/qq/index.js', import.meta.url);
|
||||
|
||||
test('QQ settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, {
|
||||
|
|
@ -50,3 +54,56 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
|
|||
}));
|
||||
assert.match(offlineMarkup, /class="ddt-summary dim-cardSummary">连接失败,请检查凭据</);
|
||||
});
|
||||
|
||||
test('QQ connection checks request a test message and show concise card feedback', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /\{ botId: account\.botId, sendTest: true \}/);
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: true, state: 'connected',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: 'QQ WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
feedback: '测试消息已发送,请到对应机器人会话中确认。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /role="status"/);
|
||||
assert.match(markup, /测试消息已发送/);
|
||||
|
||||
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: false, state: 'error',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: 'QQ 连接尚未就绪', lastCheckedAt: Date.now() },
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '测试消息已发送,请到对应机器人会话中确认。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(offlineMarkup, />连接凭据已失效</);
|
||||
assert.match(offlineMarkup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('fixed reconnect failure copy renders fully in English', () => {
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
try {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: true, state: 'connected',
|
||||
bot: { name: 'QQ Bot', appIdMasked: '123••••456' },
|
||||
health: { summary: 'healthy', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
feedback: '连接检查失败,请稍后重试。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /Connection check failed\. Try again later\./);
|
||||
assert.doesNotMatch(markup, /[\p{Script=Han}]/u);
|
||||
} finally {
|
||||
setImTranslator(null);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { maskQqAppId } from '../../../src/channels/qq/config-store.mjs';
|
||||
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
|
||||
import { connectionTestMessage } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
|
||||
|
||||
function deferred() {
|
||||
|
|
@ -90,6 +92,41 @@ test('QQ AppID and AppSecret binding stores the secret and accepts the platform
|
|||
await controller.close();
|
||||
});
|
||||
|
||||
test('QQ controller delegates the shared connection-test message to the selected runtime', async () => {
|
||||
const config = {
|
||||
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
|
||||
};
|
||||
const sent = [];
|
||||
const controller = new QqController({
|
||||
qrAuth: { start() { return () => {}; } },
|
||||
credentials: {
|
||||
resolve: async () => ({ value: 'secret' }),
|
||||
set: async () => {},
|
||||
unset: async () => {},
|
||||
},
|
||||
configStore: {
|
||||
list: () => [config],
|
||||
get: (botId) => botId === config.botId ? config : null,
|
||||
getByAppId: () => config,
|
||||
save: async () => {},
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async () => ({
|
||||
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
sendConnectionTest: async (text) => sent.push(text),
|
||||
}),
|
||||
});
|
||||
|
||||
await controller.initialize();
|
||||
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
|
||||
assert.deepEqual(sent, [
|
||||
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
|
||||
]);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
|
|
@ -100,13 +137,22 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
|
|||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
reconnectBot: async (botId) => ({
|
||||
bots: [{ botId, connected: true }], totals: { configured: 1, connected: 1 },
|
||||
}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(QQ_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), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
|
||||
const legacyReconnect = await handler(QQ_ENDPOINTS.reconnectBot, {
|
||||
botId: 'qq_bot', sendTest: true,
|
||||
});
|
||||
assert.equal(legacyReconnect.ok, true);
|
||||
assert.deepEqual(legacyReconnect.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
|
||||
|
|
@ -128,3 +174,71 @@ test('QQ credential RPC validates the pair and never returns AppSecret', async (
|
|||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
|
||||
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
|
||||
});
|
||||
|
||||
test('QQ reconnect RPC reports test-message outcomes without discarding the snapshot', async () => {
|
||||
const calls = [];
|
||||
let sendError = null;
|
||||
let connected = true;
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}),
|
||||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({}),
|
||||
reconnectBot: async (botId) => {
|
||||
calls.push(['reconnect', botId]);
|
||||
return {
|
||||
revision: 7,
|
||||
bots: [{ botId, connected, bot: { name: 'QQ机器人' } }],
|
||||
totals: { configured: 1, connected: connected ? 1 : 0 },
|
||||
};
|
||||
},
|
||||
sendConnectionTest: async (botId) => {
|
||||
calls.push(['test', botId]);
|
||||
if (sendError) throw sendError;
|
||||
return { sent: true };
|
||||
},
|
||||
deleteBot: async () => ({}),
|
||||
});
|
||||
|
||||
const success = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
|
||||
assert.equal(success.ok, true);
|
||||
assert.equal(success.value.revision, 7);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(calls, [['reconnect', 'qq_bot'], ['test', 'qq_bot']]);
|
||||
|
||||
sendError = Object.assign(new Error('no recent target'), { code: 'test-target-unavailable' });
|
||||
const unavailable = await handler(QQ_ENDPOINTS.reconnectBot, {
|
||||
botId: 'qq_bot', sendTest: true,
|
||||
});
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.equal(unavailable.value.bots[0].connected, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
|
||||
sendError = new Error('QQ API rejected the message');
|
||||
const failed = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
|
||||
assert.equal(failed.ok, true);
|
||||
assert.deepEqual(failed.value.testMessage, { sent: false, code: 'test-message-failed' });
|
||||
|
||||
calls.length = 0;
|
||||
connected = false;
|
||||
sendError = null;
|
||||
const offline = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
|
||||
assert.equal(offline.ok, true);
|
||||
assert.deepEqual(offline.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
|
||||
|
||||
calls.length = 0;
|
||||
connected = true;
|
||||
const reconnectOnly = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot' });
|
||||
assert.equal(reconnectOnly.ok, true);
|
||||
assert.equal(reconnectOnly.value.testMessage, undefined);
|
||||
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
|
||||
assert.equal((await handler(QQ_ENDPOINTS.reconnectBot, {
|
||||
botId: 'qq_bot', sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||
import test from 'node:test';
|
||||
|
||||
import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs';
|
||||
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -12,6 +13,7 @@ function deferred() {
|
|||
|
||||
class FakeBot extends EventEmitter {
|
||||
middlewares = [];
|
||||
sent = [];
|
||||
stopped = false;
|
||||
use(value) { this.middlewares.push(value); }
|
||||
async start(signal) {
|
||||
|
|
@ -19,7 +21,7 @@ class FakeBot extends EventEmitter {
|
|||
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
|
||||
}
|
||||
stop() { this.stopped = true; }
|
||||
async sendText() {}
|
||||
async sendText(target, text) { this.sent.push({ target, text }); }
|
||||
}
|
||||
|
||||
test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => {
|
||||
|
|
@ -77,6 +79,56 @@ test('QQ runtime never reports ready when the gateway does not emit ready', asyn
|
|||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
||||
test('QQ runtime sends a proactive connection test to the explicit owner fallback', async () => {
|
||||
const bot = new FakeBot();
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner-openid' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('connection-test'), { sent: true });
|
||||
assert.deepEqual(bot.sent, [{
|
||||
target: { scope: 'c2c', targetId: 'owner-openid' },
|
||||
text: 'connection-test',
|
||||
}]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('QQ runtime requires /status for wildcard owners and strips the remembered reply id', async () => {
|
||||
const bot = new FakeBot();
|
||||
const state = {};
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: '*' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await assert.rejects(
|
||||
() => runtime.sendConnectionTest('unavailable'),
|
||||
(error) => error?.code === 'test-target-unavailable',
|
||||
);
|
||||
rememberConnectionTestTarget(state, {
|
||||
scope: 'c2c', targetId: 'recent-user', msgId: 'old-status-message',
|
||||
});
|
||||
await runtime.sendConnectionTest('remembered-target');
|
||||
assert.deepEqual(bot.sent, [{
|
||||
target: { scope: 'c2c', targetId: 'recent-user' },
|
||||
text: 'remembered-target',
|
||||
}]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('QQ runtime aborts an in-flight Harness interaction when stopped', async () => {
|
||||
const bot = new FakeBot();
|
||||
const askStarted = deferred();
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new SlackConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const connectionTests = [];
|
||||
const controller = new SlackController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -299,6 +300,7 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendConnectionTest(text) { connectionTests.push(text); },
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
|
||||
|
|
@ -311,12 +313,16 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
const stored = await readFile(configPath, 'utf8');
|
||||
assert.doesNotMatch(stored, new RegExp(BOT_TOKEN));
|
||||
assert.doesNotMatch(stored, new RegExp(APP_TOKEN));
|
||||
await controller.sendConnectionTest(identity.botId);
|
||||
assert.match(connectionTests[0], /DeepSeek Harness/);
|
||||
assert.match(connectionTests[0], /T1234••• · U1234•••/);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
|
||||
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
|
||||
});
|
||||
|
||||
test('Slack RPC requires exactly two tokens and strips all credential internals', async () => {
|
||||
const connectionTests = [];
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async () => ({
|
||||
|
|
@ -331,7 +337,11 @@ test('Slack RPC requires exactly two tokens and strips all credential internals'
|
|||
}],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async (botId) => ({
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
}),
|
||||
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createSlackRpcHandler(controller);
|
||||
|
|
@ -352,6 +362,42 @@ test('Slack RPC requires exactly two tokens and strips all credential internals'
|
|||
});
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
|
||||
const tested = await handler(SLACK_ENDPOINTS.reconnectBot, {
|
||||
botId: 'slack_1234567890abcdef12345678',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(tested.ok, true);
|
||||
assert.deepEqual(tested.value.testMessage, { sent: true });
|
||||
assert.deepEqual(connectionTests, ['slack_1234567890abcdef12345678']);
|
||||
});
|
||||
|
||||
test('Slack RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const botId = 'slack_1234567890abcdef12345678';
|
||||
const controller = {
|
||||
status: async () => ({ bots: [] }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createSlackRpcHandler(controller)(SLACK_ENDPOINTS.reconnectBot, {
|
||||
botId,
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect({ bots: [{ botId, connected: true }] });
|
||||
|
||||
assert.deepEqual(await result, {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
||||
});
|
||||
assert.equal(sendCalls, 0);
|
||||
});
|
||||
|
||||
test('Slack normalizes direct messages and addressed channel events', () => {
|
||||
|
|
@ -372,6 +418,7 @@ test('Slack normalizes direct messages and addressed channel events', () => {
|
|||
assert.equal(direct.content, 'hello & welcome');
|
||||
assert.equal(direct.conversationId, 'D12345678');
|
||||
assert.equal(direct.replyTarget.threadTs, '1700000000.001');
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: 'D12345678' });
|
||||
|
||||
const group = normalizeSlackEvent({
|
||||
event_id: 'Ev002',
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
const configStore = await new TelegramConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const controller = new TelegramController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -174,6 +175,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendConnectionTest(text) { connectionTests.push(text); },
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
|
|
@ -192,6 +194,9 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
|
||||
await controller.reconnectBot(identity.botId);
|
||||
assert.equal(runtimes.length, 2);
|
||||
await controller.sendConnectionTest(identity.botId);
|
||||
assert.match(connectionTests[0], /Harness Telegram/);
|
||||
assert.match(connectionTests[0], /123•••/);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
assert.equal(controller.status().totals.configured, 0);
|
||||
|
|
@ -199,6 +204,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
|
||||
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
|
||||
const calls = [];
|
||||
const connectionTests = [];
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async (payload) => {
|
||||
|
|
@ -213,7 +219,11 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
totals: { configured: 1, connected: 0 },
|
||||
};
|
||||
},
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async (botId) => ({
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
}),
|
||||
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createTelegramRpcHandler(controller);
|
||||
|
|
@ -225,6 +235,63 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
const rejected = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN, extra: true });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
|
||||
const legacyReconnect = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
});
|
||||
assert.equal(legacyReconnect.ok, true);
|
||||
assert.equal('testMessage' in legacyReconnect.value, false);
|
||||
assert.deepEqual(connectionTests, []);
|
||||
|
||||
const tested = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(tested.ok, true);
|
||||
assert.deepEqual(tested.value.testMessage, { sent: true });
|
||||
assert.deepEqual(connectionTests, ['telegram_123']);
|
||||
|
||||
controller.sendConnectionTest = async () => {
|
||||
const error = new Error('No explicit recipient');
|
||||
error.code = 'test-target-unavailable';
|
||||
throw error;
|
||||
};
|
||||
const missingTarget = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(missingTarget.ok, true);
|
||||
assert.deepEqual(missingTarget.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('shared token RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const controller = {
|
||||
status: async () => ({ bots: [] }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'telegram_123',
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect({ bots: [{ botId: 'telegram_123', connected: true }] });
|
||||
|
||||
assert.deepEqual(await result, {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
||||
});
|
||||
assert.equal(sendCalls, 0);
|
||||
});
|
||||
|
||||
test('Telegram normalizes private messages and requires an explicit group address', () => {
|
||||
|
|
@ -239,6 +306,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(privateMessage.kind, 'direct');
|
||||
assert.equal(privateMessage.addressed, true);
|
||||
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
|
||||
|
||||
const groupMessage = normalizeTelegramUpdate({
|
||||
update_id: 11,
|
||||
|
|
@ -352,9 +420,13 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
|||
|
||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||
const sent = [];
|
||||
const sentTargets = [];
|
||||
const updates = [];
|
||||
const bot = {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
sendText: async (target, text) => {
|
||||
sentTargets.push(target);
|
||||
sent.push(text);
|
||||
},
|
||||
sendTyping: async () => {},
|
||||
openStream: async () => ({
|
||||
update: async (text) => updates.push(text),
|
||||
|
|
@ -373,7 +445,8 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
|
|||
return '完成';
|
||||
},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({ bot, harness, state: memoryState() });
|
||||
const state = memoryState();
|
||||
const bridge = new TelegramHarnessBridge({ bot, harness, state });
|
||||
await bridge.accept({
|
||||
messageId: '1', senderId: 'u1', kind: 'group', conversationId: 'g1', content: 'ignored',
|
||||
addressed: false, replyTarget: {},
|
||||
|
|
@ -386,6 +459,26 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
|
|||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(updates, ['正在使用搜索…', '处理中']);
|
||||
assert.deepEqual(sent, ['完成']);
|
||||
await assert.rejects(
|
||||
() => bridge.sendConnectionTest('card test'),
|
||||
(error) => error?.code === 'test-target-unavailable',
|
||||
);
|
||||
|
||||
const statusTarget = { chatId: 88, replyToMessageId: 7 };
|
||||
await bridge.accept({
|
||||
messageId: '3', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: '/status',
|
||||
addressed: true, replyTarget: statusTarget, connectionTestTarget: { chatId: 88 },
|
||||
});
|
||||
await bridge.sendConnectionTest('card test');
|
||||
assert.deepEqual(sent.slice(-2), [
|
||||
'Telegram机器人与 DeepSeek Harness 连接正常。',
|
||||
'card test',
|
||||
]);
|
||||
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
|
||||
const reconnectedBridge = new TelegramHarnessBridge({ bot, harness, state });
|
||||
await reconnectedBridge.sendConnectionTest('after reconnect');
|
||||
assert.equal(sent.at(-1), 'after reconnect');
|
||||
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
|
||||
});
|
||||
|
||||
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
wecomInboundMessage,
|
||||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -100,6 +101,33 @@ function questionInteraction({
|
|||
};
|
||||
}
|
||||
|
||||
test('Enterprise WeChat remembers only a private /status chat as a connection-test target', async () => {
|
||||
const privateState = state();
|
||||
const privateTransport = testClient();
|
||||
const privateBridge = new WecomHarnessBridge({
|
||||
client: privateTransport.client,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: privateState,
|
||||
});
|
||||
await privateBridge.accept(frame({ msgid: 'status-private', text: { content: '/status' } }));
|
||||
assert.deepEqual(connectionTestTarget(privateState), { chatId: 'member-1' });
|
||||
|
||||
const groupState = state();
|
||||
const groupTransport = testClient();
|
||||
const groupBridge = new WecomHarnessBridge({
|
||||
client: groupTransport.client,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: groupState,
|
||||
});
|
||||
await groupBridge.accept(frame({
|
||||
msgid: 'status-group',
|
||||
chattype: 'group',
|
||||
chatid: 'group-1',
|
||||
text: { content: '@机器人 /status' },
|
||||
}));
|
||||
assert.equal(connectionTestTarget(groupState), null);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat executes /compact for the bound Session without prompting the model', async () => {
|
||||
const store = state();
|
||||
const transport = testClient();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR stat
|
|||
});
|
||||
assert.equal(provision.qrCodeDataUrl, qr);
|
||||
const snapshot = normalizeSnapshot({
|
||||
testMessage: { sent: false, code: 'test-target-unavailable', ignored: 'secret' },
|
||||
bots: [{
|
||||
botId: 'wecom_abc', connected: true, state: 'connected',
|
||||
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
|
||||
|
|
@ -23,4 +24,7 @@ test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR stat
|
|||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001');
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,44 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
AccountCard,
|
||||
WecomSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/wecom/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/wecom/index.js', import.meta.url);
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function buttonNamed(root, name) {
|
||||
return root.findAllByType('button').find((button) => textOf(button) === name);
|
||||
}
|
||||
|
||||
function account(botId, name) {
|
||||
return {
|
||||
botId,
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
workspace: '/workspace/current',
|
||||
bot: { name, appIdMasked: `${botId}•••` },
|
||||
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('Enterprise WeChat settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -42,3 +72,67 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
|
|||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat card feedback stays visible without hiding connection errors', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
...account('wecom_bot', '企业微信机器人'),
|
||||
connected: false,
|
||||
state: 'error',
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '企业微信连接检查完成,测试消息已发送。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
|
||||
assert.match(markup, />连接凭据已失效</);
|
||||
assert.match(markup, /role="status"[^>]*>企业微信连接检查完成,测试消息已发送。</);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat connection feedback is scoped to the checked bot', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; }, clearInterval() {},
|
||||
setTimeout() { return 1; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const bots = [account('wecom_first', 'First Bot'), account('wecom_second', 'Second Bot')];
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: { bots } };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
calls.push(payload);
|
||||
return { ok: true, value: { bots, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WecomSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const first = renderer.root.findByProps({ 'data-bot-id': 'wecom_first' });
|
||||
await act(async () => {
|
||||
buttonNamed(first, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wecom_first' });
|
||||
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wecom_second' });
|
||||
assert.match(textOf(firstAfter), /测试消息已发送/);
|
||||
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
|
||||
assert.deepEqual(calls, [{ botId: 'wecom_first', sendTest: true }]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reconnect failure uses fixed translatable copy', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
let connectionTestText;
|
||||
const controller = new WecomController({
|
||||
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
|
||||
credentials: {
|
||||
|
|
@ -75,6 +76,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
return {
|
||||
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
sendConnectionTest: async (text) => { connectionTestText = text; },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -83,6 +85,9 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
|
||||
assert.equal(runtimeArgs.secret, 'manual-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-secret|remote-manual|secretRef/);
|
||||
await controller.sendConnectionTest(status.bots[0].botId);
|
||||
assert.match(connectionTestText, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(connectionTestText, /企业微信机器人(remote••••nual)/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
@ -102,6 +107,7 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
|
|||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
|
||||
|
|
@ -115,7 +121,8 @@ test('Enterprise WeChat credential RPC accepts official Bot ID fields and redact
|
|||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}), registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}),
|
||||
sendConnectionTest: async () => ({ sent: true }), deleteBot: async () => ({}),
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], totals: { configured: 0, connected: 0 }, secret: payload.secret };
|
||||
|
|
@ -129,3 +136,63 @@ test('Enterprise WeChat credential RPC accepts official Bot ID fields and redact
|
|||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|"secret"/);
|
||||
assert.equal((await handler(WECOM_ENDPOINTS.bindCredentials, { botId: 'remote-manual' })).ok, false);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat reconnect optionally sends a test message without changing connection success', async () => {
|
||||
const sent = [];
|
||||
const base = {
|
||||
status: () => ({ bots: [] }),
|
||||
startProvisioning: async () => ({}), registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}), bindCredentials: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
};
|
||||
const connected = {
|
||||
bots: [{ botId: 'wecom_bot', connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
};
|
||||
const handler = createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => connected,
|
||||
sendConnectionTest: async (botId) => { sent.push(botId); },
|
||||
});
|
||||
|
||||
const success = await handler(WECOM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'wecom_bot', sendTest: true,
|
||||
});
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, ['wecom_bot']);
|
||||
|
||||
const failedSend = await createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => connected,
|
||||
sendConnectionTest: async () => { throw new Error('provider rejected'); },
|
||||
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, { sent: false, code: 'test-message-failed' });
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = await createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => ({
|
||||
bots: [{ botId: 'wecom_bot', connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
sendConnectionTest: async () => { offlineSendCalled = true; },
|
||||
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
|
||||
assert.equal(offline.ok, true);
|
||||
assert.deepEqual(offline.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const missingMethod = await createWecomRpcHandler({
|
||||
...base,
|
||||
reconnectBot: async () => connected,
|
||||
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
|
||||
assert.equal(missingMethod.ok, true);
|
||||
assert.deepEqual(missingMethod.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await handler(WECOM_ENDPOINTS.reconnectBot, {
|
||||
botId: 'wecom_bot', sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||
import test from 'node:test';
|
||||
|
||||
import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs';
|
||||
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -12,13 +13,38 @@ function deferred() {
|
|||
|
||||
class FakeClient extends EventEmitter {
|
||||
disconnected = false;
|
||||
sent = [];
|
||||
connect() { queueMicrotask(() => this.emit('authenticated')); }
|
||||
disconnect() { this.disconnected = true; }
|
||||
async replyStream() {}
|
||||
async replyStreamNonBlocking() {}
|
||||
async sendMessage() {}
|
||||
async sendMessage(chatId, body) { this.sent.push({ chatId, body }); }
|
||||
}
|
||||
|
||||
test('Enterprise WeChat runtime sends a connection test only to the remembered private status target', async () => {
|
||||
const client = new FakeClient();
|
||||
const state = {};
|
||||
const runtime = new WecomRuntime({
|
||||
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
|
||||
secret: 'private-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
createClient: () => client,
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
await runtime.start();
|
||||
await assert.rejects(() => runtime.sendConnectionTest('测试'), {
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
rememberConnectionTestTarget(state, { chatId: 'member-private' });
|
||||
assert.deepEqual(await runtime.sendConnectionTest('测试'), { sent: true });
|
||||
assert.deepEqual(client.sent, [{
|
||||
chatId: 'member-private',
|
||||
body: { msgtype: 'markdown', markdown: { content: '测试' } },
|
||||
}]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => {
|
||||
const client = new FakeClient();
|
||||
let options;
|
||||
|
|
|
|||
131
test/channels/weixin/client-api.test.mjs
Normal file
131
test/channels/weixin/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { normalizeSnapshot } from '../../../plugin-src/client/channels/weixin/api.js';
|
||||
import {
|
||||
AccountCard,
|
||||
WeixinSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/weixin/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/weixin/index.js', import.meta.url);
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function buttonNamed(root, name) {
|
||||
return root.findAllByType('button').find((button) => textOf(button) === name);
|
||||
}
|
||||
|
||||
function account(botId, name) {
|
||||
return {
|
||||
botId,
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name, accountIdMasked: `${botId}•••` },
|
||||
health: { summary: '微信消息长轮询运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('Weixin client keeps only the public connection-test result', () => {
|
||||
const snapshot = normalizeSnapshot({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
testMessage: {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
providerDetail: 'must-not-cross-client-normalization',
|
||||
},
|
||||
bots: [{
|
||||
botId: 'wx_0123456789abcdef01234567',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
bot: { name: '微信机器人', accountIdMasked: 'account••••1234' },
|
||||
}],
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('Weixin card feedback stays visible without hiding connection errors', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
...account('wx_first', '微信机器人'),
|
||||
connected: false,
|
||||
state: 'error',
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '微信连接检查完成,测试消息已发送。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
|
||||
assert.match(markup, />连接凭据已失效</);
|
||||
assert.match(markup, /role="status"[^>]*>微信连接检查完成,测试消息已发送。</);
|
||||
});
|
||||
|
||||
test('Weixin connection feedback is scoped to the checked bot', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; }, clearInterval() {},
|
||||
setTimeout() { return 1; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const bots = [account('wx_first', 'First Bot'), account('wx_second', 'Second Bot')];
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: { revision: 1, bots } };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
calls.push(payload);
|
||||
return { ok: true, value: { revision: 2, bots, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WeixinSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const first = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
|
||||
await act(async () => {
|
||||
buttonNamed(first, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
|
||||
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_second' });
|
||||
assert.match(textOf(firstAfter), /测试消息已发送/);
|
||||
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
|
||||
assert.deepEqual(calls, [{ botId: 'wx_first', sendTest: true }]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Weixin reconnect failure uses fixed translatable copy', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ function controllerFixture() {
|
|||
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
|
||||
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
|
||||
reconnectBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => snapshot(),
|
||||
};
|
||||
return controller;
|
||||
|
|
@ -105,3 +106,60 @@ test('RPC requires explicit confirmation before removing a Weixin account', asyn
|
|||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('RPC sends a Weixin connection test only after reconnect reports the account connected', async () => {
|
||||
const botId = 'wx_0123456789abcdef01234567';
|
||||
const sent = [];
|
||||
const connected = controllerFixture();
|
||||
connected.reconnectBot = async () => snapshot({
|
||||
state: 'connected',
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
});
|
||||
connected.sendConnectionTest = async (id) => { sent.push(id); };
|
||||
const success = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, [botId]);
|
||||
|
||||
connected.sendConnectionTest = async () => { throw new Error('send rejected'); };
|
||||
const failedSend = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false, code: 'test-message-failed',
|
||||
});
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = controllerFixture();
|
||||
offline.reconnectBot = async () => snapshot({
|
||||
state: 'offline',
|
||||
bots: [{ botId, connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
});
|
||||
offline.sendConnectionTest = async () => { offlineSendCalled = true; };
|
||||
const unavailable = await createWeixinRpcHandler(offline)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const missingMethod = controllerFixture();
|
||||
missingMethod.reconnectBot = connected.reconnectBot;
|
||||
delete missingMethod.sendConnectionTest;
|
||||
const missing = await createWeixinRpcHandler(missingMethod)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(missing.ok, true);
|
||||
assert.deepEqual(missing.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -68,6 +69,35 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('Weixin remembers only the authorized owner /status as a connection-test target', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('status-owner', '/status'));
|
||||
assert.deepEqual(connectionTestTarget(fixture.state), { toUserId: 'owner-user' });
|
||||
assert.equal(sent.at(-1).text, '微信与 DeepSeek Harness 连接正常。');
|
||||
|
||||
const rejectedFixture = stateFixture();
|
||||
const rejectedBridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async () => assert.fail('unauthorized status must not be answered') },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: rejectedFixture.state,
|
||||
});
|
||||
await rejectedBridge.accept(message('status-other', '/status', { from_user_id: 'other-user' }));
|
||||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||||
});
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function configFixture() {
|
|||
|
||||
function runtimeFactory({ failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const createRuntime = async ({ config, token }) => {
|
||||
let ready = false;
|
||||
const runtime = {
|
||||
|
|
@ -67,11 +68,12 @@ function runtimeFactory({ failStart = false } = {}) {
|
|||
ready = true;
|
||||
},
|
||||
async stop() { ready = false; },
|
||||
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
};
|
||||
return { runtimes, createRuntime };
|
||||
return { runtimes, connectionTests, createRuntime };
|
||||
}
|
||||
|
||||
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
|
||||
|
|
@ -114,6 +116,11 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
|
|||
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
|
||||
assert.equal(controller.status().totals.connected, 1);
|
||||
|
||||
await controller.sendConnectionTest(completed.botId);
|
||||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
|
||||
|
||||
await controller.deleteBot(completed.botId);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,37 @@ function abortable(promise, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
test('runtime sends a connection test to the bound Weixin owner without reply context', async () => {
|
||||
const sends = [];
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => {},
|
||||
notifyStop: async () => {},
|
||||
sendText: async (request) => sends.push(request),
|
||||
getUpdates: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
},
|
||||
config: {
|
||||
botId: 'wx_owner',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
ownerUserId: 'owner-user',
|
||||
},
|
||||
token: 'bot-token',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: { getUpdatesBuf: () => '' },
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(sends[0].toUserId, 'owner-user');
|
||||
assert.equal(sends[0].text, '连接测试');
|
||||
assert.equal(sends[0].contextToken, undefined);
|
||||
assert.equal(sends[0].runId, undefined);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
|
||||
const calls = [];
|
||||
let pollCount = 0;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,30 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
EmptyView,
|
||||
ProvisionView,
|
||||
QrPanel,
|
||||
WhatsappAccountCard,
|
||||
WhatsappSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/whatsapp/index.js';
|
||||
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
|
||||
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
|
||||
|
|
@ -48,10 +63,78 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
|
|||
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
testNotice: '测试消息已发送,请到 WhatsApp 自聊会话中确认。',
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="whatsapp"/);
|
||||
assert.match(markup, /WhatsApp Web/);
|
||||
assert.match(markup, /检查连接/);
|
||||
assert.match(markup, /移除接入/);
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/whatsapp/index.js',
|
||||
import.meta.url,
|
||||
), 'utf8');
|
||||
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
|
||||
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
||||
test('WhatsApp reconnect failures render a fixed English-safe notice', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
setTimeout() { return 1; },
|
||||
clearTimeout() {},
|
||||
};
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => {
|
||||
setImTranslator(null);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const snapshot = {
|
||||
schemaVersion: 1,
|
||||
bots: [{
|
||||
botId: 'whatsapp_test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
|
||||
health: { summary: 'WhatsApp Web is healthy', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
}],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WhatsappSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'whatsapp_test' });
|
||||
await act(async () => {
|
||||
card.findAllByType('button')
|
||||
.find((button) => textOf(button) === 'Check connection').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
|
||||
assert.equal(notice, 'Connection check failed. Try again later.');
|
||||
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -426,6 +426,193 @@ test('WhatsApp runtime answers self-chat without processing its own reply echo',
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp runtime sends a connection test to self and suppresses its outbound echo', async () => {
|
||||
let callbacks;
|
||||
let askCount = 0;
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content) => {
|
||||
sent.push([jid, content]);
|
||||
return { key: { id: 'connection-test-1' } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-connection-test',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { askCount += 1; return 'unexpected'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-connection-test',
|
||||
sessionExists: async () => true,
|
||||
},
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
|
||||
message: { conversation: '连接测试' },
|
||||
});
|
||||
assert.equal(askCount, 0);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp controller delegates connection test copy to the current runtime', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-test-message-'));
|
||||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
const config = await configStore.save(linkedConfig());
|
||||
const sent = [];
|
||||
const controller = new WhatsappController({
|
||||
configStore,
|
||||
authPath: (name) => join(root, 'auth', name),
|
||||
createSession: async () => { throw new Error('not used'); },
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
sendConnectionTest: async (text) => {
|
||||
sent.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
});
|
||||
t.after(() => controller.close());
|
||||
|
||||
await controller.initialize();
|
||||
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
|
||||
assert.deepEqual(sent, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {
|
||||
const botId = deriveWhatsappBotId(ACCOUNT_JID);
|
||||
let connected = true;
|
||||
let sendFailure = false;
|
||||
let sendCalls = 0;
|
||||
const snapshot = () => ({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bots: [{
|
||||
botId,
|
||||
state: connected ? 'connected' : 'offline',
|
||||
connected,
|
||||
configured: true,
|
||||
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
|
||||
health: { summary: 'status', lastCheckedAt: Date.now() },
|
||||
}],
|
||||
totals: { configured: 1, connected: connected ? 1 : 0 },
|
||||
});
|
||||
const controller = {
|
||||
status: async () => snapshot(),
|
||||
startProvisioning: async () => null,
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => null,
|
||||
reconnectBot: async () => snapshot(),
|
||||
deleteBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => {
|
||||
sendCalls += 1;
|
||||
if (sendFailure) throw new Error('private provider failure');
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
const handler = createWhatsappRpcHandler(controller);
|
||||
|
||||
const legacy = await handler(WHATSAPP_ENDPOINTS.reconnectBot, { botId });
|
||||
assert.equal(legacy.ok, true);
|
||||
assert.equal('testMessage' in legacy.value, false);
|
||||
assert.equal(sendCalls, 0);
|
||||
|
||||
const success = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: true },
|
||||
);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.equal(sendCalls, 1);
|
||||
|
||||
sendFailure = true;
|
||||
const failedSend = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: true },
|
||||
);
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-message-failed',
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(failedSend), /private provider failure/);
|
||||
|
||||
connected = false;
|
||||
const unavailable = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: true },
|
||||
);
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(sendCalls, 2);
|
||||
|
||||
const invalid = await handler(
|
||||
WHATSAPP_ENDPOINTS.reconnectBot,
|
||||
{ botId, sendTest: 'yes' },
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('WhatsApp RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
let resolveReconnect;
|
||||
let sendCalls = 0;
|
||||
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
|
||||
const botId = deriveWhatsappBotId(ACCOUNT_JID);
|
||||
const controller = {
|
||||
status: async () => ({ bots: [] }),
|
||||
startProvisioning: async () => null,
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => null,
|
||||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
|
||||
botId,
|
||||
sendTest: true,
|
||||
}, abort.signal);
|
||||
|
||||
abort.abort();
|
||||
resolveReconnect({ bots: [{ botId, connected: true }] });
|
||||
|
||||
assert.deepEqual(await result, {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The request was cancelled.' },
|
||||
});
|
||||
assert.equal(sendCalls, 0);
|
||||
});
|
||||
|
||||
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
|
||||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
|
|
|
|||
|
|
@ -492,6 +492,84 @@ test('an older reconnect snapshot from another bot cannot restore a saved worksp
|
|||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('connection check requests a test message and shows its delivery result', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const calls = [];
|
||||
const snapshot = discordSnapshot('/workspace/current');
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
return { ok: true, value: { ...snapshot, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||||
await act(async () => {
|
||||
buttonNamed(card, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.reconnect')?.payload, {
|
||||
botId: 'discord_test',
|
||||
sendTest: true,
|
||||
});
|
||||
assert.equal(
|
||||
textOf(renderer.root.findByProps({ role: 'status' })),
|
||||
'测试消息已发送,请到对应机器人会话中确认。',
|
||||
);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('shared token connection failures render a fixed English-safe notice', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => {
|
||||
setImTranslator(null);
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
const snapshot = discordSnapshot('/workspace/current');
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'discord-operation-failed', message: 'Discord 操作失败,请稍后重试。' },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
|
||||
await act(async () => {
|
||||
buttonNamed(card, 'Check connection').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
|
||||
assert.equal(notice, 'Connection check failed. Try again later.');
|
||||
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('an older reconnect snapshot cannot resurrect a bot deleted by a newer mutation', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
validateWorkspacePath,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import {
|
||||
connectionTestTarget,
|
||||
rememberConnectionTestTarget,
|
||||
} from '../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
runWorkspaceCommand,
|
||||
splitWorkspaceCommandMessage,
|
||||
|
|
@ -70,6 +74,23 @@ test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspac
|
|||
assert.equal(await store.ensure('bot_cwd'), process.cwd());
|
||||
});
|
||||
|
||||
test('connection test targets survive a new workspace scope for the same bot', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_reconnect');
|
||||
const state = {};
|
||||
const harness = {};
|
||||
const beforeReconnect = createBotWorkspaceScope(harness, {
|
||||
botId: 'bot_reconnect', workspaces, state,
|
||||
});
|
||||
const afterReconnect = createBotWorkspaceScope(harness, {
|
||||
botId: 'bot_reconnect', workspaces, state,
|
||||
});
|
||||
|
||||
assert.equal(rememberConnectionTestTarget(beforeReconnect.state, { channelId: 'D123' }), true);
|
||||
assert.deepEqual(connectionTestTarget(afterReconnect.state), { channelId: 'D123' });
|
||||
});
|
||||
|
||||
test('workspace writes roll back updates while committed removals stay retired in memory', async (t) => {
|
||||
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const storeDirectory = join(root, 'workspace-store');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue