const test = require('node:test'); const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const { resolve } = require('node:path'); const Module = require('node:module'); const ts = require('typescript'); const React = require('react'); function transpile(file) { return ts.transpileModule(readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2020 }, }).outputText; } const accounts = [ { id: 'a', name: '账号甲', platform_account_key: '11', listener_enabled: true }, { id: 'b', name: '', platform_account_key: '22', listener_enabled: true }, { id: 'off', name: '未监听', platform_account_key: '33', listener_enabled: false }, ]; const contacts = [ { account_id: 'a', account_name: '账号甲', peer_uid: '100', peer_name: '联系人', last_text: '甲的最近消息', last_message_at: '2026-06-01T12:00:00Z', message_count: 2 }, { account_id: 'b', account_name: '', peer_uid: '100', peer_name: '', last_text: '乙的最近消息', last_message_at: '2026-06-01T11:00:00Z', message_count: 1 }, ]; function requestID(n = 1) { return n.toString(16).padStart(32, '0'); } function record(id, overrides = {}) { return { id: String(id), account_id: 'a', peer_uid: '100', direction: 'inbound', message_type: 'text', text: `消息${id}`, state: 'succeeded', error: '', message_at: `2026-06-01T12:00:0${id}Z`, created_at: '', ...overrides }; } function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function harness(options = {}) { const states = [], effects = [], queued = [], calls = [], sendCalls = [], logs = [], notices = []; const timers = new Map(); let cursor = 0, generatedIDs = 0, timerID = 0; const antd = {}, x = {}; for (const name of ['Alert', 'Button', 'Card', 'Flex', 'Modal', 'Select', 'Table', 'Tag', 'Empty', 'Spin', 'Splitter', 'Tooltip']) { antd[name] = () => null; } antd.Splitter.Panel = () => null; antd.Typography = { Text: () => null, Paragraph: () => null }; antd.App = { useApp: () => ({ message: { success(value) { notices.push(value); }, error(value) { notices.push(value); } } }) }; antd.Input = () => null; antd.Input.TextArea = () => null; antd.Form = () => null; antd.Form.useForm = () => [{ resetFields() {}, setFieldsValue() {} }]; antd.Form.Item = () => null; x.Conversations = () => null; x.Bubble = () => null; x.Bubble.List = () => null; x.Sender = () => null; const utilFile = resolve(__dirname, '../src/utils/private-messages.ts'); const util = new Module(utilFile, module); util._compile(transpile(utilFile), utilFile); const file = resolve(__dirname, '../src/pages/creator/messages/index.tsx'); const loaded = new Module(file, module); loaded.filename = file; function queueEffect(callback, deps) { const index = cursor++; const prior = effects[index]; if (!prior || deps.some((v, i) => !Object.is(v, prior.deps[i]))) { prior?.cleanup?.(); effects[index] = { deps }; queued.push(() => { effects[index].cleanup = callback(); }); } } loaded.require = (id) => { if (id === 'react') return { useRef(initial) { const index = cursor++; if (!(index in states)) states[index] = { current: initial }; return states[index]; }, useState(initial) { const index = cursor++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], (value) => { states[index] = typeof value === 'function' ? value(states[index]) : value; }]; }, useEffect: queueEffect, useLayoutEffect: queueEffect, }; if (id === 'react/jsx-runtime') return require(id); if (id === 'antd') return antd; if (id === '@ant-design/x') return x; if (id === '@ant-design/icons') return Object.fromEntries(['PlusOutlined', 'CheckCircleOutlined', 'LoadingOutlined', 'CloseCircleOutlined', 'QuestionCircleOutlined'].map((name) => [name, name])); if (id === '@/utils/private-messages') return util.exports; if (id === '@/utils/helpers') return { dateTime: (value) => value ? `time:${value}` : '—', conflictMessage: (cause, fallback) => cause?.body?.reason || cause?.message || fallback }; if (id === '@/services/api') return { creatorGet: async (path) => { calls.push(path); if (options.get) { const result = options.get(path); if (result !== undefined) return result; } if (path === '/creator/accounts') return options.accounts || accounts; if (path === '/creator/private-messages/status') return options.statuses || []; const query = new URLSearchParams(path.split('?')[1]); if (path.startsWith('/creator/private-messages/conversations?')) { const rows = (options.contacts || contacts).filter((c) => !query.get('account_id') || c.account_id === query.get('account_id')); return { data: rows, total: rows.length }; } if (path.startsWith('/creator/private-messages/messages?')) return options.messages || { data: [], total: 0 }; throw new Error(`Unexpected API: ${path}`); }, creatorCreate: async (path, payload) => { sendCalls.push({ path, payload }); if (options.send) return options.send(path, payload); return record('sent', { ...payload, direction: 'outbound', state: 'succeeded' }); }, }; return require(id); }; // Timers and UUIDs are isolated per harness; polling never opens a browser. function globals(callback) { const prior = [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM]; globalThis.__dmTimers = { set(fn) { const id = ++timerID; timers.set(id, fn); return id; }, clear(id) { timers.delete(id); } }; globalThis.__dmCrypto = options.crypto || { getRandomValues(bytes) { bytes.fill(0); bytes[bytes.length - 1] = ++generatedIDs; return bytes; } }; globalThis.__dmConsole = { error: (...args) => logs.push(args) }; globalThis.__dmDOM = options.dom; try { return callback(); } finally { [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM] = prior; } } // Recompile with the per-instance globals installed so asynchronous callbacks retain them. globals(() => loaded._compile(`const setTimeout = globalThis.__dmTimers.set; const clearTimeout = globalThis.__dmTimers.clear; const crypto = globalThis.__dmCrypto; const console = globalThis.__dmConsole; const window = globalThis.__dmDOM?.window; const ResizeObserver = globalThis.__dmDOM?.ResizeObserver; const getComputedStyle = globalThis.__dmDOM?.getComputedStyle;\n${transpile(file)}`, file)); const h = { antd, x, calls, sendCalls, logs, notices, render() { cursor = 0; const tree = loaded.exports.default(); if (tree.props.ref && options.cardElement) tree.props.ref.current = options.cardElement; return tree; }, async flush() { let tree; for (let i = 0; i < 6; i++) { tree = h.render(); while (queued.length) queued.shift()(); for (let j = 0; j < 16; j++) await Promise.resolve(); } return h.render(); }, async poll() { const pending = [...timers.values()]; timers.clear(); pending.forEach((fn) => fn()); return h.flush(); }, dispose() { for (const effect of effects) effect?.cleanup?.(); }, }; return h; } function all(node, predicate) { const result = []; function visit(value) { if (Array.isArray(value)) return value.forEach(visit); if (!React.isValidElement(value)) return; if (predicate(value)) result.push(value); visit(value.props.children); } visit(node); return result; } function text(node) { if (node == null) return ''; if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(text).join(''); return React.isValidElement(node) ? text(node.props.children) : ''; } const find = (tree, type) => all(tree, (node) => node.type === type)[0]; const button = (h, tree, label) => all(tree, (node) => node.type === h.antd.Button).find((node) => text(node.props.children) === label); const alerts = (h, tree) => all(tree, (node) => node.type === h.antd.Alert).map((node) => `${text(node.props.title)} ${text(node.props.description)}`).join('\n'); const sender = (h, tree) => find(tree, h.x.Sender); const list = (h, tree) => find(tree, h.x.Bubble.List); const conversations = (h, tree) => find(tree, h.x.Conversations); async function choose(h, key) { conversations(h, h.render()).props.onActiveChange(key); return h.flush(); } async function draft(h, value) { sender(h, h.render()).props.onChange(value); return h.flush(); } test('native X chat replaces both tables and reply/login modals; account filter defaults to all', async (t) => { const h = harness(); t.after(h.dispose); const tree = await h.flush(); assert.equal(all(tree, (n) => n.type === h.antd.Table).length, 0); assert.ok(find(tree, h.antd.Splitter)); assert.ok(conversations(h, tree)); assert.ok(list(h, tree)); assert.ok(sender(h, tree)); assert.equal(all(tree, (n) => n.type === h.antd.Modal).length, 0); assert.equal(button(h, tree, '登录私信'), undefined); const filter = all(tree, (n) => n.type === h.antd.Select && n.props['aria-label'] === '所属账号')[0]; assert.equal(filter.props.value, undefined); assert.deepEqual(filter.props.options.map((o) => o.value), ['a', 'b']); assert.doesNotMatch(text(tree), /最近 50 条|不代表完整历史|已发送不代表对方已收到|仅手动发送文字|不自动回复或群发/); assert.equal(alerts(h, tree), ''); assert.equal(sender(h, tree).props.submitType, 'enter'); const source = readFileSync(resolve(__dirname, '../src/pages/creator/messages/index.tsx'), 'utf8'); assert.doesNotMatch(source, /AccountLoginModal|LoginOutlined|qr_value|login-qr|useXAgent|useXChat|useXStream|allowSpeech|onPasteFile|from ['"]@\/utils\/table/); for (const filename of ['AccountLoginModal.tsx', 'account-login.ts']) { assert.doesNotMatch(readFileSync(resolve(__dirname, '../src/components/accounts', filename), 'utf8'), /qr_value|private-messages\/accounts/); } }); test('same peer on different accounts has distinct stable keys, source names and last-message previews', async (t) => { const h = harness(); t.after(h.dispose); let tree = await h.flush(); const items = conversations(h, tree).props.items; assert.deepEqual(items.map((c) => c.key), ['a:100', 'b:100']); assert.equal(items[0].label.type, h.antd.Flex); assert.equal(items[0].label.props.vertical, true); assert.deepEqual(React.Children.toArray(items[0].label.props.children).map(text), ['联系人', '账号甲', '甲的最近消息']); assert.match(text(items[1].label), /昵称未获取.*未命名账号.*乙的最近消息/); tree = await choose(h, 'b:100'); assert.match(text(tree), /发送账号:未命名账号/); const query = new URLSearchParams(h.calls.filter((p) => p.startsWith('/creator/private-messages/messages?')).at(-1).split('?')[1]); assert.equal(query.get('account_id'), 'b'); assert.equal(query.get('peer_uid'), '100'); }); test('sync warnings can be dismissed per account without hiding changed or recurring errors', async (t) => { let statuses = [{ account_id: 'a', account_name: '账号甲', error: '连接失败' }, { account_id: 'b', account_name: '', error: '需要登录' }]; const h = harness({ get: (path) => path === '/creator/private-messages/status' ? statuses : undefined }); t.after(h.dispose); const warnings = (tree) => all(tree, (node) => node.type === h.antd.Alert && node.props.title.endsWith('私信同步异常')); let tree = await h.flush(); assert.equal(warnings(tree).length, 2); assert.equal(typeof warnings(tree)[0].props.closable?.onClose, 'function'); warnings(tree)[0].props.closable.onClose(); tree = await h.flush(); assert.equal(warnings(tree).length, 1); assert.match(warnings(tree)[0].props.description, /需要登录/); tree = await h.poll(); assert.equal(warnings(tree).length, 1); statuses = [{ ...statuses[0], error: '网关不可用' }, statuses[1]]; tree = await h.poll(); assert.equal(warnings(tree).length, 2); assert.match(warnings(tree)[0].props.description, /网关不可用/); warnings(tree)[0].props.closable.onClose(); tree = await h.flush(); assert.equal(warnings(tree).length, 1); statuses = [{ ...statuses[0], error: '' }, statuses[1]]; await h.poll(); statuses = [{ ...statuses[0], error: '网关不可用' }, statuses[1]]; tree = await h.poll(); assert.equal(warnings(tree).length, 2); assert.equal(statuses[0].error, '网关不可用'); assert.equal(h.sendCalls.length, 0); }); test('chat Card fills available height, responds to viewport and layout changes, and cleans up observers', async (t) => { let top = 200, callback, disconnected = false; const listeners = new Map(); const parentElement = {}; const dom = { window: { innerHeight: 1000, scrollY: 0, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) }, getComputedStyle: (element) => { assert.equal(element, parentElement); return { paddingBottom: '32px' }; }, ResizeObserver: class { constructor(fn) { callback = fn; } observe(element) { assert.equal(element, parentElement); } disconnect() { disconnected = true; } }, }; const h = harness({ dom, cardElement: { parentElement, getBoundingClientRect: () => ({ top }) } }); t.after(() => { h.dispose(); assert.equal(listeners.size, 0); assert.equal(disconnected, true); }); let tree = await h.flush(); assert.equal(tree.props.style.height, 768); assert.equal(tree.props.styles.body.flex, 1); assert.equal(tree.props.styles.body.minHeight, 0); const splitter = find(tree, h.antd.Splitter); assert.equal(splitter.props.style.flex, 1); assert.equal(splitter.props.style.minHeight, 0); assert.equal(splitter.props.style.height, undefined); assert.equal(conversations(h, tree).props.style.overflowY, 'auto'); tree = await choose(h, 'a:100'); assert.equal(list(h, tree).props.style.minHeight, 0); assert.equal(sender(h, tree).props.style.flexShrink, 0); dom.window.innerHeight = 1200; listeners.get('resize')(); tree = await h.flush(); assert.equal(tree.props.style.height, 968); top = 240; callback(); tree = await h.flush(); assert.equal(tree.props.style.height, 928); dom.window.scrollY = 8; top = 232; callback(); tree = await h.flush(); assert.equal(tree.props.style.height, 928); dom.window.innerHeight = 250; listeners.get('resize')(); tree = await h.flush(); assert.equal(tree.props.style.height, 0); }); test('conversation list and message header show real nicknames and never use UID as a missing-name placeholder', async (t) => { const h = harness({ contacts: [{ ...contacts[0], peer_name: '真实昵称' }, { ...contacts[1], peer_name: '' }] }); t.after(h.dispose); let tree = await h.flush(); const labels = conversations(h, tree).props.items.map((item) => text(item.label)); assert.match(labels[0], /真实昵称/); assert.match(labels[1], /昵称未获取/); assert.doesNotMatch(labels[1], /100/); tree = await choose(h, 'b:100'); assert.equal(all(tree, (node) => node.type === h.antd.Typography.Text && node.props.strong && text(node) === '昵称未获取').length, 1); assert.equal(all(tree, (node) => node.type === h.antd.Typography.Text && node.props.strong && text(node) === '100').length, 0); tree = await choose(h, 'a:100'); assert.equal(all(tree, (node) => node.type === h.antd.Typography.Text && node.props.strong && text(node) === '真实昵称').length, 1); }); test('three-line conversation items grow with content, keep vertical padding and never shrink in a long list', async (t) => { const rows = Array.from({ length: 50 }, (_, index) => ({ ...contacts[0], peer_uid: String(100 + index), peer_name: '较长的联系人名称'.repeat(10), last_text: '较长的消息摘要'.repeat(20), })); const h = harness({ contacts: rows }); t.after(h.dispose); const tree = await h.flush(); const props = conversations(h, tree).props; assert.equal(props.styles?.item?.height, 'auto'); assert.equal(props.styles.item.flexShrink, 0); assert.equal(props.styles.item.paddingBlock, 8); assert.equal(props.style.overflowY, 'auto'); assert.equal(props.items.length, rows.length); for (const item of props.items) { assert.equal(item.label.props.vertical, true); const lines = React.Children.toArray(item.label.props.children); assert.equal(lines.length, 3); assert.ok(lines.every((line) => line.props.ellipsis.tooltip)); } const selected = await choose(h, 'a:149'); assert.equal(conversations(h, selected).props.activeKey, 'a:149'); }); test('messages are chronological, received left/sent right, with real times, errors and non-text placeholders', async (t) => { const h = harness({ messages: { data: [ record(4, { message_type: 'image', text: 'not rendered as image' }), record(3, { direction: 'outbound', state: 'unknown', error: 'SDK 1008 完整错误' }), record(2, { direction: 'outbound', state: 'failed', error: '发送失败完整错误' }), record(1, { direction: 'outbound', state: 'sending' }), record(0), ], total: 5 } }); t.after(h.dispose); const tree = await h.flush(); const bubbles = list(h, tree).props; assert.deepEqual(bubbles.items.map((m) => m.key), ['0', '1', '2', '3', '4']); assert.equal(bubbles.role.inbound.placement, 'start'); assert.equal(bubbles.role.outbound.placement, 'end'); assert.equal(bubbles.items[0].role, 'inbound'); assert.equal(bubbles.items[2].role, 'outbound'); for (const [index, icon, title] of [[1, 'LoadingOutlined', '发送中'], [2, 'CloseCircleOutlined', '发送失败:发送失败完整错误'], [3, 'QuestionCircleOutlined', '结果未确认:SDK 1008 完整错误']]) { const tooltip = all(bubbles.items[index].footer, (n) => n.type === h.antd.Tooltip).find((n) => n.props.title === title); assert.equal(tooltip.props.title, title); assert.ok(find(tooltip, icon)); assert.doesNotMatch(text(bubbles.items[index].footer), /发送中|发送失败|结果未确认|完整错误/); } assert.match(text(bubbles.items[0].footer), /time:2026-06-01T12:00:00Z/); assert.match(text(bubbles.items[4].content), /图片.*暂不支持/); assert.equal(all(bubbles.items[4].content, (n) => n.type === 'img').length, 0); assert.doesNotMatch(bubbles.items.map((m) => text(m.footer)).join(''), /已读|已送达/); }); test('load more contacts merges and deduplicates; polling retains already loaded contacts', async (t) => { let polled = false; const h = harness({ get(path) { if (!path.startsWith('/creator/private-messages/conversations?')) return; const page = Number(new URLSearchParams(path.split('?')[1]).get('page')); return { data: page === 1 ? [{ ...contacts[0], last_text: polled ? '更新后的摘要' : contacts[0].last_text }] : [contacts[0], contacts[1]], total: 3 }; } }); t.after(h.dispose); let tree = await h.flush(); await button(h, tree, '加载更多联系人').props.onClick(); tree = await h.flush(); assert.deepEqual(conversations(h, tree).props.items.map((c) => c.key), ['a:100', 'b:100']); polled = true; tree = await h.poll(); assert.equal(conversations(h, tree).props.items.length, 2); assert.match(text(conversations(h, tree).props.items[0].label), /更新后的摘要/); assert.ok(h.calls.some((p) => p.startsWith('/creator/private-messages/conversations?') && new URLSearchParams(p.split('?')[1]).get('page') === '2')); }); test('stale contact pages cannot move a newer conversation down or change the selected peer', async (t) => { let stage = 0; const h = harness({ get(path) { if (!path.startsWith('/creator/private-messages/conversations?')) return; const page = Number(new URLSearchParams(path.split('?')[1]).get('page')); const newest = { ...contacts[1], last_message_at: '2026-06-01T12:00:10Z', last_text: '最新收发消息' }; const older = { ...contacts[1], last_message_at: '2026-06-01T12:00:00Z', last_text: '延迟返回的旧摘要' }; return { data: page === 1 ? [contacts[0], stage === 1 ? older : newest] : [older], total: 3 }; } }); t.after(h.dispose); let tree = await h.flush(); tree = await choose(h, 'a:100'); assert.deepEqual(conversations(h, tree).props.items.map((item) => item.key), ['b:100', 'a:100']); await button(h, tree, '加载更多联系人').props.onClick(); tree = await h.flush(); assert.deepEqual(conversations(h, tree).props.items.map((item) => item.key), ['b:100', 'a:100']); assert.match(text(conversations(h, tree).props.items[0].label), /最新收发消息/); stage = 1; tree = await h.poll(); assert.deepEqual(conversations(h, tree).props.items.map((item) => item.key), ['b:100', 'a:100']); assert.match(text(conversations(h, tree).props.items[0].label), /最新收发消息/); assert.equal(conversations(h, tree).props.activeKey, 'a:100'); assert.equal(h.sendCalls.length, 0); }); test('earlier messages are prepended, deduplicated and retained during polling', async (t) => { let polling = false; const h = harness({ get(path) { if (!path.startsWith('/creator/private-messages/messages?')) return; const page = Number(new URLSearchParams(path.split('?')[1]).get('page')); return { data: page === 1 ? [record(3, { text: polling ? '最新更新' : '消息3' }), record(2)] : [record(2), record(1)], total: 4 }; } }); t.after(h.dispose); let tree = await h.flush(); await button(h, tree, '加载更早消息').props.onClick(); tree = await h.flush(); assert.deepEqual(list(h, tree).props.items.map((m) => m.key), ['1', '2', '3']); polling = true; tree = await h.poll(); assert.deepEqual(list(h, tree).props.items.map((m) => m.key), ['1', '2', '3']); assert.equal(list(h, tree).props.items.at(-1).content, '最新更新'); }); test('account filtering resets contact pages and late responses cannot restore the old source', async (t) => { const late = deferred(); const other = harness({ get(path) { if (!path.startsWith('/creator/private-messages/conversations?')) return; const q = new URLSearchParams(path.split('?')[1]); if (q.get('account_id') === 'b') return { data: [contacts[1]], total: 1 }; if (q.get('page') === '2') return late.promise; return { data: [contacts[0]], total: 30 }; } }); t.after(other.dispose); let tree = await other.flush(); const loading = button(other, tree, '加载更多联系人').props.onClick(); const filter = all(tree, (n) => n.type === other.antd.Select && n.props['aria-label'] === '所属账号')[0]; filter.props.onChange('b'); tree = await other.flush(); late.resolve({ data: [contacts[0]], total: 30 }); await loading; tree = await other.flush(); assert.deepEqual(conversations(other, tree).props.items.map((c) => c.key), ['b:100']); const q = new URLSearchParams(other.calls.filter((p) => p.startsWith('/creator/private-messages/conversations?')).at(-1).split('?')[1]); assert.equal(q.get('account_id'), 'b'); assert.equal(q.get('page'), '1'); }); test('dependency help is absent while real account sync errors stay visible', async (t) => { const h = harness({ statuses: [{ account_id: 'a', account_name: '肉宝儿', error: '实际读取错误' }] }); t.after(h.dispose); const tree = await h.flush(); assert.doesNotMatch(alerts(h, tree), /私信同步依赖|同步失败时请查看/); assert.match(alerts(h, tree), /肉宝儿:私信同步异常.*实际读取错误/); }); test('successful bubbles use icons and record timestamps have no visible label', async (t) => { const h = harness({ messages: { data: [record('x', { direction: 'outbound', message_at: '', created_at: '2026-06-01T12:00:00Z' })], total: 1 } }); t.after(h.dispose); const footer = list(h, await h.flush()).props.items[0].footer; assert.match(text(footer), /^time:2026-06-01T12:00:00Z$/); const tooltips = all(footer, (n) => n.type === h.antd.Tooltip); assert.ok(tooltips.some((n) => n.props.title === '记录时间')); const status = tooltips.find((n) => n.props.title === '已发送'); assert.ok(status); assert.ok(find(status, 'CheckCircleOutlined')); }); test('late message responses do not leak into the newly selected conversation', async (t) => { const late = deferred(); const h = harness({ get(path) { if (!path.startsWith('/creator/private-messages/messages?')) return; const account = new URLSearchParams(path.split('?')[1]).get('account_id'); return account === 'a' ? late.promise : { data: [record('b', { account_id: 'b', text: '乙的消息' })], total: 1 }; } }); t.after(h.dispose); await h.flush(); let tree = await choose(h, 'b:100'); late.resolve({ data: [record('a', { text: '甲的旧响应' })], total: 1 }); tree = await h.flush(); assert.deepEqual(list(h, tree).props.items.map((m) => m.content), ['乙的消息']); assert.match(text(tree), /发送账号:未命名账号/); }); test('drafts are independent even for the same peer and typing never sends', async (t) => { const h = harness(); t.after(h.dispose); await h.flush(); await draft(h, '甲的草稿'); let tree = await choose(h, 'b:100'); assert.equal(sender(h, tree).props.value, ''); await draft(h, '乙的草稿'); tree = await choose(h, 'a:100'); assert.equal(sender(h, tree).props.value, '甲的草稿'); tree = await choose(h, 'b:100'); assert.equal(sender(h, tree).props.value, '乙的草稿'); await h.poll(); assert.equal(h.sendCalls.length, 0); }); test('HTTP-compatible sending generates distinct request IDs without randomUUID', async (t) => { const native = require('node:crypto').webcrypto; const crypto = { getRandomValues: (bytes) => native.getRandomValues(bytes) }; assert.equal(crypto.randomUUID, undefined); const h = harness({ crypto }); t.after(h.dispose); await h.flush(); await draft(h, '第一条'); await sender(h, await h.flush()).props.onSubmit('第一条'); await h.flush(); await draft(h, '第二条'); await sender(h, await h.flush()).props.onSubmit('第二条'); assert.equal(h.sendCalls.length, 2); const ids = h.sendCalls.map((call) => call.payload.request_id); assert.ok(ids.every((id) => /^[0-9a-f]{32}$/.test(id))); assert.notEqual(ids[0], ids[1]); assert.equal(h.logs.length, 0); }); test('request ID generation failure is handled before sending, preserves draft, and allows a manual retry', async (t) => { let calls = 0; const crypto = { getRandomValues(bytes) { if (calls++ === 0) throw new Error('随机数接口不可用'); return require('node:crypto').webcrypto.getRandomValues(bytes); } }; const h = harness({ crypto }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '未发送的私密内容'); await assert.doesNotReject(() => sender(h, tree).props.onSubmit('未发送的私密内容')); tree = await h.flush(); assert.equal(h.sendCalls.length, 0); assert.equal(sender(h, tree).props.value, '未发送的私密内容'); assert.equal(sender(h, tree).props.disabled, false); assert.doesNotMatch(alerts(h, tree), /结果未确认/); assert.match(h.notices.join(' '), /发送编号.*未发送.*随机数接口不可用/); assert.match(JSON.stringify(h.logs), /account_id.*a.*peer_uid.*100/); assert.doesNotMatch(JSON.stringify(h.logs), /未发送的私密内容/); await sender(h, tree).props.onSubmit('未发送的私密内容'); tree = await h.flush(); assert.equal(h.sendCalls.length, 1); assert.match(h.sendCalls[0].payload.request_id, /^[0-9a-f]{32}$/); assert.equal(sender(h, tree).props.value, ''); }); test('send snapshots the source/peer; switching chats never sends to the new selection or clears its draft', async (t) => { const late = deferred(); const h = harness({ send: () => late.promise }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '甲的发送内容'); const sending = sender(h, tree).props.onSubmit('甲的发送内容'); tree = await choose(h, 'b:100'); await draft(h, '乙的草稿'); assert.equal(h.sendCalls.length, 1); assert.deepEqual(h.sendCalls[0], { path: '/creator/private-messages/send', payload: { account_id: 'a', peer_uid: '100', text: '甲的发送内容', request_id: requestID() } }); late.resolve(record('sent', { direction: 'outbound', state: 'succeeded' })); await sending; tree = await h.flush(); assert.equal(sender(h, tree).props.value, '乙的草稿'); assert.equal(list(h, tree).props.items.some((m) => m.key === 'sent'), false); tree = await choose(h, 'a:100'); assert.equal(sender(h, tree).props.value, ''); }); test('successful send cannot erase input edited while the request was pending', async (t) => { const late = deferred(); const h = harness({ send: () => late.promise }); t.after(h.dispose); await h.flush(); const tree = await draft(h, '第一条'); const sending = sender(h, tree).props.onSubmit('第一条'); await draft(h, '第二条草稿'); late.resolve(record('sent', { direction: 'outbound' })); await sending; assert.equal(sender(h, await h.flush()).props.value, '第二条草稿'); }); for (const state of ['failed', 'unknown', 'sending']) { test(`${state} retains draft and complete feedback; polls and input changes never resend`, async (t) => { const h = harness({ send: () => record('attempt', { direction: 'outbound', state, error: '完整发送错误' }) }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '不能丢的正文'); await sender(h, tree).props.onSubmit('不能丢的正文'); tree = await h.flush(); assert.equal(sender(h, tree).props.value, '不能丢的正文'); assert.match(alerts(h, tree), /完整发送错误/); assert.ok(alerts(h, tree).includes(requestID())); if (state !== 'failed') { await sender(h, tree).props.onSubmit('不能丢的正文'); assert.equal(h.sendCalls.length, 1, 'uncertain or in-flight attempts must not be resent'); } await draft(h, '新的草稿'); await h.poll(); assert.equal(h.sendCalls.length, 1); assert.doesNotMatch(JSON.stringify(h.logs), /不能丢的正文|新的草稿/); }); } test('failed messages can be explicitly retried with a fresh request ID, never automatically', async (t) => { let attempt = 0; const h = harness({ send: () => record('sent', { direction: 'outbound', state: ++attempt === 1 ? 'failed' : 'succeeded', error: attempt === 1 ? '失败' : '' }) }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '手动重发'); await sender(h, tree).props.onSubmit('手动重发'); tree = await h.flush(); await h.poll(); assert.equal(h.sendCalls.length, 1); await sender(h, tree).props.onSubmit('手动重发'); tree = await h.flush(); assert.equal(h.sendCalls.length, 2); assert.notEqual(h.sendCalls[0].payload.request_id, h.sendCalls[1].payload.request_id); assert.equal(sender(h, tree).props.value, ''); }); test('network errors remain visible and preserve uncertain request IDs without exposing request bodies in logs', async (t) => { const cause = Object.assign(new Error('网络中断,结果未确认'), { body: { text: '私密正文' } }); const h = harness({ send: () => { throw cause; } }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '私密正文'); await sender(h, tree).props.onSubmit('私密正文'); tree = await h.flush(); assert.match(alerts(h, tree), /网络中断,结果未确认/); assert.ok(alerts(h, tree).includes(requestID())); assert.equal(sender(h, tree).props.value, '私密正文'); await sender(h, tree).props.onSubmit('私密正文'); await h.poll(); assert.equal(h.sendCalls.length, 1); assert.match(JSON.stringify(h.logs), new RegExp(`account_id.*a.*peer_uid.*100.*request_id.*${requestID()}`)); assert.doesNotMatch(JSON.stringify(h.logs), /私密正文/); }); test('listener closure disables sending while preserving the selected chat, draft and server errors', async (t) => { let enabled = true; const h = harness({ get(path) { if (path === '/creator/accounts') return accounts.map((a) => a.id === 'a' ? { ...a, listener_enabled: enabled } : a); } }); t.after(h.dispose); await h.flush(); await draft(h, '关闭前草稿'); enabled = false; let tree = await h.poll(); assert.equal(sender(h, tree).props.disabled, true); assert.match(alerts(h, tree), /监听.*关闭|未开启监听/); await sender(h, tree).props.onSubmit('关闭前草稿'); tree = await h.flush(); assert.equal(h.sendCalls.length, 0); assert.equal(sender(h, tree).props.value, '关闭前草稿'); }); test('server conflict reasons are not hidden by generic send feedback', async (t) => { const h = harness({ send() { throw { status: 409, body: { reason: '监听已关闭:generation=9' } }; } }); t.after(h.dispose); await h.flush(); const tree = await draft(h, '草稿'); await sender(h, tree).props.onSubmit('草稿'); assert.match(alerts(h, await h.flush()), /监听已关闭:generation=9/); }); test('new text conversation uses the sole modal and locks its explicit source instead of the filter', async (t) => { const h = harness(); t.after(h.dispose); let tree = await h.flush(); button(h, tree, '新建私信').props.onClick(); tree = await h.flush(); assert.equal(all(tree, (n) => n.type === h.antd.Modal).length, 1); const modal = find(tree, h.antd.Modal); assert.equal(modal.props.title, '新建私信'); const form = find(modal, h.antd.Form); const uid = all(form, (n) => n.type === h.antd.Form.Item && n.props.name === 'peer_uid')[0]; assert.equal(uid.props.rules[1].pattern.test('invalid'), false); await form.props.onFinish({ account_id: 'b', peer_uid: '999', text: '首次消息' }); tree = await h.flush(); assert.deepEqual(h.sendCalls[0].payload, { request_id: requestID(), account_id: 'b', peer_uid: '999', text: '首次消息' }); assert.match(text(tree), /发送账号:未命名账号/); assert.equal(all(tree, (n) => n.type === h.antd.Modal).length, 0); assert.equal(conversations(h, tree).props.activeKey, 'b:999'); }); test('sync errors and read failures remain visible without replacing existing chat data', async (t) => { let fail = false; const h = harness({ statuses: [{ account_id: 'a', account_name: '账号甲', error: '聊天客户端尚未就绪' }], messages: { data: [record(1)], total: 1 }, get(path) { if (fail && path.startsWith('/creator/private-messages/messages?')) throw new Error('消息读取真实错误'); if (fail && path.startsWith('/creator/private-messages/conversations?')) throw new Error('联系人读取真实错误'); } }); t.after(h.dispose); let tree = await h.flush(); assert.match(alerts(h, tree), /聊天客户端尚未就绪/); fail = true; tree = await h.poll(); assert.match(alerts(h, tree), /消息读取真实错误/); assert.match(alerts(h, tree), /联系人读取真实错误/); assert.equal(list(h, tree).props.items.length, 1); assert.equal(conversations(h, tree).props.items.length, 2); }); test('a new conversation on another source changes the filter and preserves failure feedback', async (t) => { const h = harness({ send: (_path, payload) => record('attempt', { account_id: payload.account_id, peer_uid: payload.peer_uid, direction: 'outbound', state: 'failed', error: '首次发送真实失败' }) }); t.after(h.dispose); let tree = await h.flush(); all(tree, (n) => n.type === h.antd.Select && n.props['aria-label'] === '所属账号')[0].props.onChange('a'); tree = await h.flush(); button(h, tree, '新建私信').props.onClick(); tree = await h.flush(); await find(tree, h.antd.Form).props.onFinish({ account_id: 'b', peer_uid: '999', text: '乙的新草稿' }); tree = await h.flush(); const filter = all(tree, (n) => n.type === h.antd.Select && n.props['aria-label'] === '所属账号')[0]; assert.equal(filter.props.value, 'b'); assert.equal(conversations(h, tree).props.activeKey, 'b:999'); assert.ok(conversations(h, tree).props.items.some((c) => c.key === 'b:999')); assert.equal(conversations(h, tree).props.items.some((c) => c.key.startsWith('a:')), false); assert.match(alerts(h, tree), /首次发送真实失败/); assert.equal(sender(h, tree).props.value, '乙的新草稿'); }); test('an older contact page cannot overwrite a newer polling summary', async (t) => { const late = deferred(); let latest = false; const h = harness({ get(path) { if (!path.startsWith('/creator/private-messages/conversations?')) return; const page = new URLSearchParams(path.split('?')[1]).get('page'); return page === '2' ? late.promise : { data: [{ ...contacts[0], last_text: latest ? '刚更新的摘要' : '旧摘要' }], total: 30 }; } }); t.after(h.dispose); let tree = await h.flush(); const loading = button(h, tree, '加载更多联系人').props.onClick(); latest = true; tree = await h.poll(); late.resolve({ data: [{ ...contacts[0], last_text: '旧分页摘要' }, contacts[1]], total: 30 }); await loading; tree = await h.flush(); assert.match(text(conversations(h, tree).props.items[0].label), /刚更新的摘要/); }); test('late earlier-message pages cannot appear in another chat', async (t) => { const late = deferred(); const h = harness({ get(path) { if (!path.startsWith('/creator/private-messages/messages?')) return; const q = new URLSearchParams(path.split('?')[1]); if (q.get('account_id') === 'b') return { data: [record('b', { account_id: 'b', text: '乙的内容' })], total: 1 }; if (q.get('page') === '2') return late.promise; return { data: [record(3)], total: 60 }; } }); t.after(h.dispose); let tree = await h.flush(); const loading = button(h, tree, '加载更早消息').props.onClick(); await choose(h, 'b:100'); late.resolve({ data: [record(1)], total: 60 }); await loading; tree = await h.flush(); assert.deepEqual(list(h, tree).props.items.map((m) => m.content), ['乙的内容']); }); for (const editWhileWaiting of [false, true]) { test(`polling resolves unknown requests without resend; newer draft preserved=${editWhileWaiting}`, async (t) => { let sent = false; const h = harness({ send: () => { sent = true; return record('attempt', { direction: 'outbound', state: 'unknown', error: 'SDK 1008' }); }, get(path) { if (sent && path.startsWith('/creator/private-messages/messages?')) return { data: [record('attempt', { direction: 'outbound', state: 'succeeded' })], total: 1 }; } }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '原草稿'); await sender(h, tree).props.onSubmit('原草稿'); tree = await h.flush(); assert.ok(alerts(h, tree).includes(requestID())); if (editWhileWaiting) await draft(h, '修改后的草稿'); tree = await h.poll(); assert.equal(h.sendCalls.length, 1); assert.equal(sender(h, tree).props.value, editWhileWaiting ? '修改后的草稿' : ''); assert.ok(!alerts(h, tree).includes(requestID())); }); } test('pagination errors and malformed data are visible without discarding existing rows', async (t) => { const h = harness({ get(path) { if (path.startsWith('/creator/private-messages/conversations?')) { return new URLSearchParams(path.split('?')[1]).get('page') === '2' ? Promise.reject(new Error('联系人第二页错误')) : { data: [contacts[0]], total: 30 }; } if (path.startsWith('/creator/private-messages/messages?')) { return new URLSearchParams(path.split('?')[1]).get('page') === '2' ? { data: null, total: 60 } : { data: [record(3)], total: 60 }; } } }); t.after(h.dispose); let tree = await h.flush(); await button(h, tree, '加载更多联系人').props.onClick(); await button(h, tree, '加载更早消息').props.onClick(); tree = await h.flush(); assert.match(alerts(h, tree), /联系人第二页错误/); assert.match(alerts(h, tree), /返回格式错误/); assert.equal(conversations(h, tree).props.items.length, 1); assert.equal(list(h, tree).props.items.length, 1); }); test('pending requests use the native loading SendButton, not an AI stop/cancel action', async (t) => { const late = deferred(); const h = harness({ send: () => late.promise }); t.after(h.dispose); await h.flush(); let tree = await draft(h, '发送中的内容'); const sending = sender(h, tree).props.onSubmit('发送中的内容'); tree = await h.flush(); const props = sender(h, tree).props; const SendButton = () => null; assert.equal(typeof props.suffix, 'function'); const action = props.suffix(null, { components: { SendButton } }); assert.equal(action.type, SendButton); assert.equal(action.props.loading, true); assert.equal(props.onCancel, undefined); assert.equal(props.disabled, false, 'input stays editable while a send is pending'); late.resolve(record('sent', { direction: 'outbound' })); await sending; await h.flush(); }); test('double submission, whitespace and oversized input cannot create unintended sends', async (t) => { const late = deferred(); const h = harness({ send: () => late.promise }); t.after(h.dispose); await h.flush(); let tree = await draft(h, ' '); await sender(h, tree).props.onSubmit(' '); tree = await draft(h, '字'.repeat(2001)); await sender(h, tree).props.onSubmit('字'.repeat(2001)); assert.equal(h.sendCalls.length, 0); assert.ok(h.notices.some((n) => n.includes('2000'))); tree = await draft(h, '手动消息'); const send = sender(h, tree).props.onSubmit('手动消息'); await sender(h, tree).props.onSubmit('手动消息'); assert.equal(h.sendCalls.length, 1); late.resolve(record('sent', { direction: 'outbound' })); await send; await h.flush(); }); test('missing timestamps stay unknown and no-enabled-account empty state cannot send', async (t) => { const h = harness({ accounts: [], contacts: [] }); t.after(h.dispose); const tree = await h.flush(); assert.ok(find(tree, h.antd.Empty)); assert.equal(button(h, tree, '新建私信').props.disabled, true); assert.equal(sender(h, tree), undefined); assert.equal(h.sendCalls.length, 0); const other = harness({ messages: { data: [record('x', { message_at: '', created_at: '' })], total: 1 } }); t.after(other.dispose); const footer = list(other, await other.flush()).props.items[0].footer; assert.match(text(footer), /时间未知/); });