import assert from 'node:assert/strict'; import test from 'node:test'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import TestRenderer from 'react-test-renderer'; import { ACCESS_CHANNEL_DEFINITIONS, ACCESS_POLICY_ENDPOINT, } from '../plugin-src/client/access-policy-settings.js'; import { BOT_SETTINGS_TABS, DELIVERY_CHANNEL_DEFINITIONS, DELIVERY_ENDPOINTS, DELIVERY_RPC_CHANNEL, DeliveryTargetSettingsPage, } from '../plugin-src/client/delivery-settings.js'; import { en, setImTranslator } from '../plugin-src/client/i18n.js'; import { BotCard as FeishuBotCard } from '../plugin-src/client/channels/feishu/index.js'; import { AccountCard as WeixinAccountCard } from '../plugin-src/client/channels/weixin/index.js'; import { AccountCard as DingtalkAccountCard } from '../plugin-src/client/channels/dingtalk/index.js'; import { AccountCard as WecomAccountCard } from '../plugin-src/client/channels/wecom/index.js'; import { AccountCard as QqAccountCard } from '../plugin-src/client/channels/qq/index.js'; import { SlackAccountCard } from '../plugin-src/client/channels/slack/index.js'; import { TelegramAccountCard } from '../plugin-src/client/channels/telegram/index.js'; import { DiscordAccountCard } from '../plugin-src/client/channels/discord/index.js'; import { WhatsappAccountCard } from '../plugin-src/client/channels/whatsapp/index.js'; import { IMSettingsTab } from '../plugin-src/client/index.js'; const { act, create } = TestRenderer; function flush() { return new Promise((resolve) => setImmediate(resolve)); } function textOf(node) { if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(textOf).join(''); return textOf(node?.props?.children ?? ''); } function button(root, label) { return root.findAllByType('button').find((entry) => textOf(entry) === label); } function accessHelpButtons(root) { return root.findAll((entry) => entry.type === 'button' && String(entry.props['aria-label'] ?? '').endsWith('查看访问权限说明')); } function deferred() { let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } async function mount(t, props) { let renderer; await act(async () => { renderer = create(React.createElement(DeliveryTargetSettingsPage, props)); await flush(); }); t.after(async () => { await act(async () => { renderer.unmount(); await flush(); }); }); return renderer; } const connectedAccount = Object.freeze({ botId: 'bot_feishu_01', botName: '通知机器人', connected: true, accessPolicy: Object.freeze({ direct: Object.freeze({ mode: 'open', open: Object.freeze({ defaultCanExecuteCommands: true, commandPermissionOverrides: Object.freeze([]), }), allowlist: Object.freeze({ users: Object.freeze([]) }), }), group: Object.freeze({ mode: 'open', open: Object.freeze({ defaultCanExecuteCommands: false, commandPermissionOverrides: Object.freeze([]), }), allowlist: Object.freeze({ users: Object.freeze([]) }), }), }), }); test('delivery settings define only the nine supported IM channel routes', () => { assert.deepEqual(BOT_SETTINGS_TABS, [ { id: 'delivery', label: '投递设置' }, { id: 'access', label: '访问设置' }, ]); assert.equal(DELIVERY_RPC_CHANNEL, '/dsh-im-delivery'); assert.deepEqual(Object.keys(DELIVERY_CHANNEL_DEFINITIONS), [ 'weixin', 'feishu', 'dingtalk', 'wecom', 'qq', 'slack', 'telegram', 'discord', 'whatsapp', ]); assert.deepEqual( DELIVERY_CHANNEL_DEFINITIONS.feishu.fields.user.map((field) => field.key), ['openId'], ); assert.deepEqual( DELIVERY_CHANNEL_DEFINITIONS.dingtalk.fields.group.map((field) => field.key), ['openConversationId'], ); assert.deepEqual( DELIVERY_CHANNEL_DEFINITIONS.telegram.fields.topic.map((field) => field.key), ['chatId', 'messageThreadId'], ); assert.equal('office' in DELIVERY_CHANNEL_DEFINITIONS, false); assert.deepEqual(Object.keys(ACCESS_CHANNEL_DEFINITIONS), Object.keys(DELIVERY_CHANNEL_DEFINITIONS)); assert.equal(ACCESS_CHANNEL_DEFINITIONS.weixin.groupSupported, false); }); test('all nine robot cards add one accessible settings gear beside existing content', () => { const account = { botId: 'bot_card_01', connected: true, state: 'connected', bot: { name: '机器人', idMasked: 'id••01', username: 'robot', appIdMasked: 'app••01', accountIdMasked: 'account••01', clientIdMasked: 'client••01', }, health: { lastCheckedAt: null, summary: '运行正常' }, workspace: null, }; const cards = [ ['feishu', FeishuBotCard, { connection: account }], ['weixin', WeixinAccountCard, { account }], ['dingtalk', DingtalkAccountCard, { account }], ['wecom', WecomAccountCard, { account }], ['qq', QqAccountCard, { account }], ['slack', SlackAccountCard, { account }], ['telegram', TelegramAccountCard, { account }], ['discord', DiscordAccountCard, { account }], ['whatsapp', WhatsappAccountCard, { account }], ]; for (const [channel, Card, props] of cards) { const markup = renderToStaticMarkup(React.createElement(Card, props)); assert.equal((markup.match(/aria-label="更多机器人设置"/g) ?? []).length, 1); assert.match(markup, /class="dim-botCardTools"/); assert.match(markup, new RegExp(`data-delivery-channel="${channel}"`)); assert.match(markup, /role="tooltip"[^>]*>更多机器人设置 { const previousWindow = globalThis.window; globalThis.window = { setInterval() { return 1; }, clearInterval() {}, setTimeout() { return 1; }, clearTimeout() {}, requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {}, }; const bot = { botId: 'wx_stable_bot', connected: true, configured: true, state: 'connected', bot: { name: '微信通知助手', accountIdMasked: 'wx••01' }, health: { status: 'healthy', summary: '微信连接正常', lastCheckedAt: Date.now() }, accessPolicy: connectedAccount.accessPolicy, }; const deliveryCalls = []; const renderer = await (async () => { let mounted; await act(async () => { mounted = create(React.createElement(IMSettingsTab, { browserLocation: { href: 'http://localhost:9527/settings' }, weixinRpcCall: async (endpoint) => { assert.equal(endpoint, 'connection.status'); return { ok: true, value: { revision: 1, bots: [bot] } }; }, deliveryRpcCall: async (endpoint, payload) => { deliveryCalls.push({ endpoint, payload }); return { ok: true, value: { targets: [] } }; }, updateRpcCall: async () => ({ ok: true, value: { runningVersion: '4.0.1', canInstall: false }, }), })); await flush(); }); return mounted; })(); t.after(async () => { await act(async () => { renderer.unmount(); await flush(); }); if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; }); const card = renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' }); await act(async () => { card.findByProps({ 'aria-label': '更多机器人设置' }).props.onClick(); await flush(); }); const page = renderer.root.findByProps({ className: 'dim-deliveryPage' }); assert.match(textOf(page), /微信通知助手/); assert.doesNotMatch(textOf(page), /调用标识/); assert.equal( page.findByProps({ className: 'dim-deliveryHeader' }).findAllByType('h2').length, 0, ); const settingsTabs = page.findAllByProps({ role: 'tab' }); assert.deepEqual(settingsTabs.map(textOf), ['投递设置', '访问设置']); const settingsTab = settingsTabs[0]; const settingsPanel = page.findByProps({ role: 'tabpanel' }); assert.equal(textOf(settingsTab), '投递设置'); assert.equal(settingsTab.props['aria-selected'], true); assert.equal(settingsTab.props.tabIndex, 0); assert.equal(settingsTab.props['aria-controls'], settingsPanel.props.id); assert.equal(settingsPanel.props['aria-labelledby'], settingsTab.props.id); const identity = settingsPanel.findByProps({ className: 'dim-deliveryIdentity' }); assert.ok(identity); assert.ok(settingsPanel.findByProps({ className: 'dim-deliveryTargets' })); assert.equal(page.findByProps({ className: 'dim-botSettingsTabsBar' }).findAllByType('a').length, 0); assert.equal( textOf(identity.findByType('h2')), '微信通知助手', ); const docsLink = identity.findByProps({ className: 'dim-deliveryDocsLink' }); assert.equal(textOf(docsLink), '使用文档↗'); assert.equal( docsLink.props.href, 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md', ); assert.equal(docsLink.props.target, '_blank'); assert.equal(docsLink.props.rel, 'noopener noreferrer'); assert.equal(page.findByType('code').props.children, 'wx_stable_bot'); assert.deepEqual(deliveryCalls, [{ endpoint: DELIVERY_ENDPOINTS.list, payload: { botId: 'wx_stable_bot' }, }]); await act(async () => { button(page, '← 返回机器人列表').props.onClick(); await flush(); }); assert.ok(renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' })); assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true); }); test('access settings preserve independent mode drafts and save direct and group atomically', async (t) => { const calls = []; const renderer = await mount(t, { channel: 'feishu', account: connectedAccount, rpcCall: async (endpoint) => { assert.equal(endpoint, DELIVERY_ENDPOINTS.list); return { ok: true, value: { targets: [] } }; }, accessRpcCall: async (endpoint, payload) => { calls.push({ endpoint, payload }); return { ok: true, value: { bots: [{ botId: connectedAccount.botId, accessPolicy: payload.policy }] }, }; }, onBack() {}, }); assert.equal(accessHelpButtons(renderer.root).length, 0); await act(async () => { button(renderer.root, '访问设置').props.onClick(); await flush(); }); assert.equal(renderer.root.findAllByProps({ role: 'tab' }).length, 2); assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }).length, 2); assert.equal(renderer.root.findAllByProps({ className: 'dim-accessOwnerNotice' }).length, 0); assert.equal(accessHelpButtons(renderer.root).length, 2); for (const [scene, title] of [['direct', '私聊'], ['group', '群聊']]) { const sceneEditor = renderer.root.findByProps({ 'data-scene': scene }); assert.equal(sceneEditor.props['aria-label'], title); const accessHelpButton = sceneEditor.findByProps({ 'aria-label': `${title} 查看访问权限说明`, }); const accessHelpTooltip = sceneEditor.findByProps({ className: 'dim-channelTooltip dim-accessHelpTooltip', }); assert.equal(accessHelpTooltip.props.role, 'tooltip'); assert.equal(accessHelpButton.props['aria-describedby'], accessHelpTooltip.props.id); assert.match(textOf(accessHelpTooltip), /原所有者或扫码接入者始终可以访问并执行命令/); } assert.equal(accessHelpButtons(renderer.root.findByProps({ className: 'dim-accessActions' })).length, 0); assert.equal(accessHelpButtons(renderer.root.findByProps({ role: 'tablist' })).length, 0); assert.match( textOf(renderer.root.findByProps({ 'data-scene': 'direct' })), /命令权限例外/, ); await act(async () => { const direct = renderer.root.findByProps({ 'data-scene': 'direct' }); const addUser = direct.findByProps({ 'aria-label': '私聊 新增用户' }); assert.equal(addUser.props.title, '新增用户'); assert.match(addUser.props.className, /dim-accessAddUser/); assert.equal(textOf(addUser), '+'); addUser.props.onClick(); await flush(); }); await act(async () => { renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({ target: { value: ' ou_override ' }, }); renderer.root.findByProps({ 'aria-label': '群聊 默认命令权限' }).props.onChange({ target: { value: 'allow' }, }); await flush(); }); await act(async () => { renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({ target: { value: 'allowlist' }, }); await flush(); }); const directAllowlistHelp = renderer.root.findByProps({ 'aria-label': '私聊 查看白名单说明', }); const directAllowlistTooltip = renderer.root.findByProps({ className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip', }); assert.equal(directAllowlistTooltip.props.role, 'tooltip'); assert.equal(directAllowlistHelp.props['aria-describedby'], directAllowlistTooltip.props.id); assert.equal( textOf(directAllowlistTooltip), '当前没有白名单用户,保存后普通用户将无法使用机器人。', ); assert.equal(renderer.root.findAllByProps({ className: 'dim-accessWarning' }).length, 0); assert.match( textOf(renderer.root.findByProps({ 'data-scene': 'direct' })), /白名单用户/, ); assert.equal( renderer.root.findAllByProps({ 'aria-label': '私聊 默认命令权限' }).length, 0, ); await act(async () => { const direct = renderer.root.findByProps({ 'data-scene': 'direct' }); direct.findByProps({ 'aria-label': '私聊 新增用户' }).props.onClick(); await flush(); }); assert.equal( renderer.root.findAllByProps({ 'aria-label': '私聊 查看白名单说明' }).length, 0, ); await act(async () => { renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({ target: { value: 'allowlist' }, }); await flush(); }); assert.ok(renderer.root.findByProps({ 'aria-label': '群聊 查看白名单说明' })); await act(async () => { renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({ target: { value: 'open' }, }); await flush(); }); await act(async () => { renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({ target: { value: ' ou_allowed ' }, }); await flush(); }); await act(async () => { renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.onChange({ target: { value: 'allow' }, }); await flush(); }); await act(async () => { renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({ target: { value: 'open' }, }); await flush(); }); assert.equal( renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value, ' ou_override ', ); assert.equal( renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value, 'deny', ); assert.equal( renderer.root.findByProps({ 'aria-label': '私聊 默认命令权限' }).props.value, 'allow', ); await act(async () => { renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({ target: { value: 'allowlist' }, }); await flush(); }); assert.equal( renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value, ' ou_allowed ', ); assert.equal( renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value, 'allow', ); await act(async () => { renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({ preventDefault() {}, }); await flush(); }); assert.deepEqual(calls, [{ endpoint: ACCESS_POLICY_ENDPOINT, payload: { botId: connectedAccount.botId, policy: { direct: { mode: 'allowlist', open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [{ id: 'ou_override', canExecuteCommands: false }], }, allowlist: { users: [{ id: 'ou_allowed', canExecuteCommands: true }], }, }, group: { mode: 'open', open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [], }, allowlist: { users: [] }, }, }, }, }]); const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' }); assert.match(textOf(feedback), /访问设置已保存/); assert.equal(feedback.props['data-tone'], 'success'); assert.equal(feedback.props.role, 'status'); await act(async () => { button(renderer.root, '投递设置').props.onClick(); await flush(); }); assert.equal(accessHelpButtons(renderer.root).length, 0); }); test('access settings keep a failed atomic save visible as an error', async (t) => { const renderer = await mount(t, { channel: 'feishu', account: connectedAccount, rpcCall: async () => ({ ok: true, value: { targets: [] } }), accessRpcCall: async () => ({ ok: false, error: { code: 'access-policy-invalid', message: '访问策略未保存。' }, }), onBack() {}, }); await act(async () => { button(renderer.root, '访问设置').props.onClick(); await flush(); }); await act(async () => { renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({ preventDefault() {}, }); await flush(); }); const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' }); assert.equal(textOf(feedback), '访问策略未保存。'); assert.equal(feedback.props['data-tone'], 'error'); assert.equal(feedback.props.role, 'alert'); }); test('WeChat keeps the shared access page but disables its unsupported group section', async (t) => { const renderer = await mount(t, { channel: 'weixin', account: { ...connectedAccount, botId: 'wx_access_01' }, rpcCall: async () => ({ ok: true, value: { targets: [] } }), accessRpcCall: async () => { throw new Error('save should not run in this rendering test'); }, onBack() {}, }); await act(async () => { button(renderer.root, '访问设置').props.onClick(); await flush(); }); const group = renderer.root.findByProps({ 'data-scene': 'group' }); assert.equal(group.props.disabled, true); assert.match(textOf(group), /当前渠道不支持群聊/); assert.equal(group.findAllByType('select').length, 0); assert.ok(renderer.root.findByProps({ 'data-scene': 'direct' })); }); test('new target waits for saved targets before generating aliases or checking duplicates', async (t) => { const pending = deferred(); const renderer = await mount(t, { channel: 'feishu', account: connectedAccount, rpcCall: async (endpoint) => { assert.equal(endpoint, DELIVERY_ENDPOINTS.list); return pending.promise; }, onBack() {}, }); assert.equal(button(renderer.root, '新建目标').props.disabled, true); await act(async () => { pending.resolve({ ok: true, value: { targets: [] } }); await flush(); }); assert.equal(button(renderer.root, '新建目标').props.disabled, false); }); test('each saved target tests only its own botId and targetId and keeps row-local feedback', async (t) => { const pending = deferred(); const calls = []; const targets = [ { targetId: 'daily-report', name: '日报群', kind: 'group', route: { chatId: 'oc_daily' } }, { targetId: 'oncall', name: '值班同学', kind: 'user', route: { openId: 'ou_oncall' } }, ]; const rpcCall = async (endpoint, payload) => { calls.push({ endpoint, payload }); if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets } }; if (endpoint === DELIVERY_ENDPOINTS.test) return pending.promise; throw new Error(`unexpected endpoint ${endpoint}`); }; const renderer = await mount(t, { channel: 'feishu', account: connectedAccount, rpcCall, onBack() {}, }); const first = renderer.root.findByProps({ 'data-target-id': 'daily-report' }); const second = renderer.root.findByProps({ 'data-target-id': 'oncall' }); await act(async () => { first.findByProps({ 'aria-label': '测试投递目标' }).props.onClick(); await flush(); }); assert.ok(first.findAllByType('button').some((entry) => textOf(entry) === '测试中…')); assert.ok(second.findAllByType('button').some((entry) => textOf(entry) === '测试')); assert.deepEqual(calls.at(-1), { endpoint: DELIVERY_ENDPOINTS.test, payload: { botId: 'bot_feishu_01', targetId: 'daily-report' }, }); pending.resolve({ ok: true, value: { sent: true } }); await act(async () => { await flush(); }); assert.match(textOf(first), /测试消息已发送,请到目标会话确认。/); assert.doesNotMatch(textOf(second), /测试消息已发送/); await act(async () => { renderer.update(React.createElement(DeliveryTargetSettingsPage, { channel: 'feishu', account: { ...connectedAccount, connected: false }, rpcCall, onBack() {}, })); await flush(); }); for (const row of renderer.root.findAllByProps({ className: 'dim-targetRow' })) { assert.equal(row.findByProps({ 'aria-label': '测试投递目标' }).props.disabled, true); } }); test('new target defaults to recent conversations and selection creates an editable unsaved draft', async (t) => { const calls = []; let targets = [ { targetId: 'user-1', name: '值班同学', kind: 'user', route: { openId: 'ou_existing_secret_123456' } }, ]; const suggestions = [ { kind: 'user', route: { openId: 'ou_existing_secret_123456' } }, { kind: 'user', route: { openId: 'ou_new_secret_123456' } }, { kind: 'group', route: { chatId: 'oc_group_secret_123456' } }, ]; const rpcCall = async (endpoint, payload) => { calls.push({ endpoint, payload }); if (endpoint === DELIVERY_ENDPOINTS.list) { return { ok: true, value: { targets: structuredClone(targets) } }; } if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) { return { ok: true, value: { suggestions: structuredClone(suggestions) } }; } if (endpoint === DELIVERY_ENDPOINTS.test) { return { ok: true, value: { sent: true } }; } if (endpoint === DELIVERY_ENDPOINTS.create) { targets = [...targets, structuredClone(payload.target)]; return { ok: true, value: structuredClone(payload.target) }; } throw new Error(`unexpected endpoint ${endpoint}`); }; const renderer = await mount(t, { channel: 'feishu', account: connectedAccount, rpcCall, onBack() {}, }); await act(async () => { button(renderer.root, '新建目标').props.onClick(); await flush(); }); assert.deepEqual(calls.at(-1), { endpoint: DELIVERY_ENDPOINTS.listSuggestions, payload: { botId: 'bot_feishu_01' }, }); const picker = renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' }); const suggestionItems = picker.findAllByProps({ className: 'dim-targetSuggestionItem' }); assert.equal(suggestionItems.length, 3); const existing = suggestionItems.find((item) => textOf(item).includes('ou_ex')); assert.equal(existing.props.disabled, true); assert.match(textOf(existing), /已添加/); assert.doesNotMatch(textOf(picker), /ou_existing_secret_123456|ou_new_secret_123456|oc_group_secret_123456/); const groupItem = suggestionItems.find((item) => textOf(item).includes('oc_gr') && !item.props.disabled); await act(async () => { groupItem.props.onClick(); }); let form = renderer.root.findByType('form'); const groupTargetId = form.findByProps({ name: 'targetId' }).props.value; assert.match(groupTargetId, /^tgt_[0-9a-f]{16}$/); assert.equal(form.findByProps({ name: 'targetId' }).props.readOnly, false); assert.equal(form.findByProps({ name: 'kind' }).props.value, 'group'); assert.equal(form.findByProps({ name: 'chatId' }).props.value, 'oc_group_secret_123456'); assert.match(form.findByProps({ name: 'name' }).props.value, /^群聊 · oc_gr/); assert.equal(calls.some((call) => call.endpoint === DELIVERY_ENDPOINTS.create), false); await act(async () => { form.findByProps({ name: 'chatId' }).props.onChange({ target: { value: 'oc_unsaved_current' } }); }); form = renderer.root.findByType('form'); assert.ok(button(form, '测试')); await act(async () => { button(form, '测试').props.onClick(); await flush(); }); assert.deepEqual(calls.at(-1), { endpoint: DELIVERY_ENDPOINTS.test, payload: { botId: 'bot_feishu_01', target: { kind: 'group', route: { chatId: 'oc_unsaved_current' } }, }, }); assert.equal(calls.some((call) => ( call.endpoint === DELIVERY_ENDPOINTS.create || call.endpoint === DELIVERY_ENDPOINTS.update )), false); assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/); await act(async () => { button(renderer.root, '取消').props.onClick(); }); const nextItems = renderer.root.findAllByProps({ className: 'dim-targetSuggestionItem' }); const userItem = nextItems.find((item) => textOf(item).includes('ou_ne') && !item.props.disabled); await act(async () => { userItem.props.onClick(); }); form = renderer.root.findByType('form'); const userTargetId = form.findByProps({ name: 'targetId' }).props.value; assert.match(userTargetId, /^tgt_[0-9a-f]{16}$/); assert.notEqual(userTargetId, groupTargetId); assert.match(form.findByProps({ name: 'name' }).props.value, /^私聊 · ou_ne/); assert.equal(form.findByProps({ name: 'kind' }).props.value, 'user'); assert.equal(form.findByProps({ name: 'openId' }).props.value, 'ou_new_secret_123456'); const fallbackName = form.findByProps({ name: 'name' }).props.value; await act(async () => { form.findByProps({ name: 'targetId' }).props.onChange({ target: { value: 'project-chat' } }); }); await act(async () => { await form.props.onSubmit({ preventDefault() {} }); await flush(); }); assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.create), { endpoint: DELIVERY_ENDPOINTS.create, payload: { botId: 'bot_feishu_01', target: { targetId: 'project-chat', name: fallbackName, kind: 'user', route: { openId: 'ou_new_secret_123456' }, }, }, }); }); test('recent conversation picker explains the empty state and refreshes on demand', async (t) => { const calls = []; const rpcCall = async (endpoint, payload) => { calls.push({ endpoint, payload }); if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets: [] } }; if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) { return { ok: true, value: { suggestions: [] } }; } throw new Error(`unexpected endpoint ${endpoint}`); }; const renderer = await mount(t, { channel: 'telegram', account: connectedAccount, rpcCall, onBack() {}, }); await act(async () => { button(renderer.root, '新建目标').props.onClick(); await flush(); }); assert.match( textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })), /先在对应平台与机器人聊一条消息/, ); assert.ok(button(renderer.root, '手动填写(高级)')); await act(async () => { button(renderer.root, '刷新').props.onClick(); await flush(); }); assert.equal(calls.filter((call) => call.endpoint === DELIVERY_ENDPOINTS.listSuggestions).length, 2); }); test('recent conversation names remain platform data in the English UI', async (t) => { setImTranslator((key) => en[key] ?? key); t.after(() => setImTranslator(null)); const rpcCall = async (endpoint) => { if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets: [] } }; if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) { return { ok: true, value: { suggestions: [{ id: 'group', name: '飞书项目群', kind: 'group', route: { chatId: 'oc_secret_123456' }, }], }, }; } throw new Error(`unexpected endpoint ${endpoint}`); }; const renderer = await mount(t, { channel: 'feishu', account: connectedAccount, rpcCall, onBack() {}, }); await act(async () => { button(renderer.root, 'New target').props.onClick(); await flush(); }); const picker = renderer.root.findByProps({ className: 'dim-targetSuggestions' }); assert.match(textOf(picker), /飞书项目群/); assert.doesNotMatch(textOf(picker), /Feishu项目群/); assert.match( textOf(picker), /Choose from conversations/, ); const docsLink = renderer.root.findByProps({ className: 'dim-deliveryDocsLink' }); assert.equal(textOf(docsLink), 'User guide↗'); assert.equal( docsLink.props.href, 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md', ); assert.deepEqual( renderer.root.findAllByProps({ role: 'tab' }).map(textOf), ['Delivery settings', 'Access settings'], ); }); test('target create, edit, copy, and delete use the minimal RPC payloads', async (t) => { const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); const copied = []; Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { clipboard: { async writeText(value) { copied.push(value); } } }, }); t.after(() => { if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); else delete globalThis.navigator; }); const calls = []; let targets = []; const rpcCall = async (endpoint, payload) => { calls.push({ endpoint, payload }); if (endpoint === DELIVERY_ENDPOINTS.list) { return { ok: true, value: { targets: structuredClone(targets) } }; } if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) { return { ok: true, value: { suggestions: [] } }; } if (endpoint === DELIVERY_ENDPOINTS.test) { return { ok: true, value: { sent: true } }; } if (endpoint === DELIVERY_ENDPOINTS.create) { targets = [...targets, structuredClone(payload.target)]; return { ok: true, value: structuredClone(payload.target) }; } if (endpoint === DELIVERY_ENDPOINTS.update) { targets = targets.map((target) => target.targetId === payload.targetId ? { targetId: payload.targetId, ...structuredClone(payload.target) } : target); return { ok: true, value: targets[0] }; } if (endpoint === DELIVERY_ENDPOINTS.delete) { targets = targets.filter((target) => target.targetId !== payload.targetId); return { ok: true, value: { deleted: true } }; } throw new Error(`unexpected endpoint ${endpoint}`); }; const renderer = await mount(t, { channel: 'telegram', account: connectedAccount, rpcCall, onBack() {}, }); await act(async () => { button(renderer.root, '复制').props.onClick(); await flush(); }); assert.equal(copied.at(-1), 'bot_feishu_01'); await act(async () => { button(renderer.root, '新建目标').props.onClick(); await flush(); }); assert.ok(renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' })); await act(async () => { button(renderer.root, '手动填写(高级)').props.onClick(); }); let createForm = renderer.root.findByType('form'); assert.ok(button(createForm, '测试')); assert.equal(button(createForm, '测试').props.disabled, true); assert.match( renderer.root.findByProps({ name: 'targetId' }).props.value, /^tgt_[0-9a-f]{16}$/, ); await act(async () => { renderer.root.findByProps({ name: 'targetId' }).props.onChange({ target: { value: 'Ops:Topic@A' } }); renderer.root.findByProps({ name: 'name' }).props.onChange({ target: { value: '告警话题' } }); renderer.root.findByProps({ name: 'kind' }).props.onChange({ target: { value: 'topic' } }); }); await act(async () => { renderer.root.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100123' } }); renderer.root.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '42' } }); }); createForm = renderer.root.findByType('form'); assert.equal(createForm.findByProps({ name: 'targetId' }).props.pattern, '[A-Za-z0-9._:@-]{1,128}'); assert.equal(createForm.findByProps({ name: 'targetId' }).props.maxLength, 128); assert.equal(button(createForm, '测试').props.disabled, false); await act(async () => { button(createForm, '测试').props.onClick(); await flush(); }); assert.deepEqual(calls.at(-1), { endpoint: DELIVERY_ENDPOINTS.test, payload: { botId: 'bot_feishu_01', target: { kind: 'topic', route: { chatId: '-100123', messageThreadId: 42 }, }, }, }); assert.equal(calls.some((call) => ( call.endpoint === DELIVERY_ENDPOINTS.create || call.endpoint === DELIVERY_ENDPOINTS.update )), false); assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/); await act(async () => { await createForm.props.onSubmit({ preventDefault() {} }); await flush(); }); assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.create), { endpoint: DELIVERY_ENDPOINTS.create, payload: { botId: 'bot_feishu_01', target: { targetId: 'Ops:Topic@A', name: '告警话题', kind: 'topic', route: { chatId: '-100123', messageThreadId: 42 }, }, }, }); const row = () => renderer.root.findByProps({ 'data-target-id': 'Ops:Topic@A' }); assert.match(textOf(row()), /告警话题/); assert.match(textOf(row()), /targetId: Ops:Topic@A/); assert.doesNotMatch(textOf(row()), /-100123/); const typeBadges = row().findByProps({ className: 'dim-targetTitle' }).findAllByType('span'); assert.equal(typeBadges.length, 1); assert.equal(textOf(typeBadges[0]), '话题'); await act(async () => { button(row(), '复制调用参数').props.onClick(); await flush(); }); assert.equal(copied.at(-1), '{"botId":"bot_feishu_01","targetId":"Ops:Topic@A"}'); await act(async () => { button(row(), '编辑').props.onClick(); }); let editForm = renderer.root.findByType('form'); assert.equal(editForm.findByProps({ name: 'targetId' }).props.readOnly, true); assert.ok(button(editForm, '测试')); await act(async () => { editForm.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100999' } }); editForm.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '77' } }); }); editForm = renderer.root.findByType('form'); await act(async () => { button(editForm, '测试').props.onClick(); await flush(); }); assert.deepEqual(calls.at(-1), { endpoint: DELIVERY_ENDPOINTS.test, payload: { botId: 'bot_feishu_01', target: { kind: 'topic', route: { chatId: '-100999', messageThreadId: 77 }, }, }, }); assert.equal(calls.filter((call) => call.endpoint === DELIVERY_ENDPOINTS.update).length, 0); assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/); await act(async () => { editForm.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100123' } }); editForm.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '42' } }); editForm.findByProps({ name: 'name' }).props.onChange({ target: { value: '夜间告警' } }); }); editForm = renderer.root.findByType('form'); await act(async () => { await editForm.props.onSubmit({ preventDefault() {} }); await flush(); }); const updateCall = calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.update); assert.deepEqual(updateCall.payload, { botId: 'bot_feishu_01', targetId: 'Ops:Topic@A', target: { name: '夜间告警', kind: 'topic', route: { chatId: '-100123', messageThreadId: 42 }, }, }); assert.equal('targetId' in updateCall.payload.target, false); await act(async () => { button(row(), '删除').props.onClick(); }); assert.match(textOf(row().findByProps({ role: 'alertdialog' })), /unknown-target/); await act(async () => { button(row(), '确认删除').props.onClick(); await flush(); }); assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.delete), { endpoint: DELIVERY_ENDPOINTS.delete, payload: { botId: 'bot_feishu_01', targetId: 'Ops:Topic@A' }, }); assert.match( textOf(renderer.root.findByProps({ className: 'dim-deliveryState dim-deliveryEmpty' })), /尚未配置投递目标/, ); });