Files
creator-hub/web/tests/private-messages-page.test.cjs
T
rogee d94f83cde9
douyin-release-gate / verify (push) Failing after 18m44s
fix(messages): stabilize conversation ordering by real message time
2026-10-07 13:13:06 +08:00

724 lines
42 KiB
JavaScript

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), /时间未知/);
});