391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const vm = require('node:vm')
|
|
const ts = require('typescript')
|
|
const vue = require('vue')
|
|
const { parse, compileTemplate } = require('vue/compiler-sfc')
|
|
|
|
const root = path.join(__dirname, '../src/views/chat/list')
|
|
const plain = (value) => JSON.parse(JSON.stringify(value))
|
|
const transpile = (source) =>
|
|
ts.transpileModule(source, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
const utilsContext = { exports: {}, URL, Intl, Date }
|
|
vm.runInNewContext(
|
|
transpile(fs.readFileSync(path.join(root, 'chat-utils.ts'), 'utf8')),
|
|
utilsContext
|
|
)
|
|
const utils = utilsContext.exports
|
|
function createModerationState() {
|
|
const context = {
|
|
exports: {},
|
|
require: (name) => {
|
|
if (name === 'vue') return vue
|
|
throw new Error(name)
|
|
}
|
|
}
|
|
vm.runInNewContext(
|
|
transpile(fs.readFileSync(path.join(root, '../components/message-moderation.ts'), 'utf8')),
|
|
context
|
|
)
|
|
return context.exports
|
|
}
|
|
const deferred = () => {
|
|
let resolve, reject
|
|
const promise = new Promise((yes, no) => {
|
|
resolve = yes
|
|
reject = no
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
const conversation = (first = 1, second = 2) => ({
|
|
userA: { id: first, nickname: `用户${first}` },
|
|
userB: { id: second, nickname: `用户${second}` },
|
|
messageCount: 4,
|
|
lastMessage: {
|
|
id: 4,
|
|
sendId: second,
|
|
recvId: first,
|
|
type: 0,
|
|
status: 3,
|
|
content: '你好',
|
|
sendTime: 1793664000000
|
|
}
|
|
})
|
|
|
|
function createHarness(component, overrides = {}, moderation = createModerationState()) {
|
|
const calls = []
|
|
const lifecycle = {}
|
|
const context = {
|
|
exports: {},
|
|
defineOptions() {},
|
|
defineExpose(value) {
|
|
context.exposed = value
|
|
},
|
|
document: { activeElement: null },
|
|
HTMLElement: class {},
|
|
require(name) {
|
|
if (name === 'vue')
|
|
return {
|
|
...vue,
|
|
onMounted(callback) {
|
|
lifecycle.mounted = callback
|
|
},
|
|
onBeforeUnmount(callback) {
|
|
lifecycle.unmount = callback
|
|
},
|
|
onDeactivated(callback) {
|
|
lifecycle.deactivate = callback
|
|
}
|
|
}
|
|
if (name === '@/api/chat/private-msg')
|
|
return {
|
|
async conversationPage(params) {
|
|
calls.push(plain(params))
|
|
return { list: [], total: 0 }
|
|
},
|
|
async page(params) {
|
|
calls.push(plain(params))
|
|
return { list: [], total: 0 }
|
|
},
|
|
...overrides
|
|
}
|
|
if (name.endsWith('chat-utils')) return utils
|
|
if (name.endsWith('message-moderation')) return moderation
|
|
if (name.endsWith('.vue')) return {}
|
|
throw new Error(`Unexpected import ${name}`)
|
|
}
|
|
}
|
|
const source = fs.readFileSync(path.join(root, component), 'utf8')
|
|
const { descriptor } = parse(source)
|
|
const exports =
|
|
component === 'index.vue'
|
|
? 'filters, applied, pagination, list, total, loading, error, getList, handleQuery, resetQuery, handleSizeChange, drawerRef, openConversation'
|
|
: 'filters, applied, pagination, messages, total, loading, error, visible, getList, handleQuery, resetQuery, handleSizeChange, open, handleClose, restoreFocus, scrollRef, searchRef, focusSearch'
|
|
const scope = vue.effectScope()
|
|
scope.run(() =>
|
|
vm.runInNewContext(
|
|
transpile(descriptor.scriptSetup.content) + `\nglobalThis.state = { ${exports} };`,
|
|
context
|
|
)
|
|
)
|
|
const unmount = lifecycle.unmount
|
|
lifecycle.unmount = () => {
|
|
unmount?.()
|
|
scope.stop()
|
|
}
|
|
return { state: context.state, exposed: context.exposed, calls, lifecycle, moderation }
|
|
}
|
|
|
|
test('China date ranges include the entire final calendar day independently of machine timezone', () => {
|
|
assert.deepEqual(plain(utils.toChinaDateRange(['2026-11-03', '2026-11-03'])), [
|
|
Date.parse('2026-11-02T16:00:00.000Z'),
|
|
Date.parse('2026-11-03T15:59:59.999Z')
|
|
])
|
|
assert.equal(utils.toChinaDateRange(null), undefined)
|
|
assert.equal(utils.toChinaDateRange(['2026-11-04', '2026-11-03']), undefined)
|
|
assert.equal(utils.formatChatTime('2026-11-03 08:01:02'), '2026-11-03 08:01:02')
|
|
assert.equal(utils.formatChatTime('2026-11-03T00:01:02Z'), '2026-11-03 08:01:02')
|
|
})
|
|
|
|
test('media payload failures and dangerous protocols have safe fallbacks', () => {
|
|
for (const value of [undefined, null, '', '{broken', 'null', '[]', '1'])
|
|
assert.equal(utils.parseMedia(value), null)
|
|
for (const url of [
|
|
'javascript:alert(1)',
|
|
'data:text/html,hello',
|
|
'file:///etc/passwd',
|
|
'//evil.example/x',
|
|
'https://user:pass@example.com/file',
|
|
'https://x.example/\nfile',
|
|
'\\evil.example\\x'
|
|
]) {
|
|
assert.equal(utils.safeResourceUrl(url, 'https://cdn.example/media/'), '')
|
|
}
|
|
assert.equal(
|
|
utils.safeResourceUrl('a.png', 'https://cdn.example/media'),
|
|
'https://cdn.example/media/a.png'
|
|
)
|
|
assert.equal(utils.safeResourceUrl('https://cdn.example/a.png'), 'https://cdn.example/a.png')
|
|
assert.equal(utils.messagePreview({ type: 1, status: 2 }), '[已撤回/屏蔽] [图片]')
|
|
assert.equal(utils.messagePreview({ type: 10, content: '1234', status: 0 }), '撤回了一条消息')
|
|
assert.equal(
|
|
utils.messagePreview({ type: 40, content: '通话时长 01:23', status: 3 }),
|
|
'[语音通话] 通话时长 01:23'
|
|
)
|
|
})
|
|
|
|
test('text links preserve text, keep HTML escaped, and support uppercase HTTP', () => {
|
|
const input = '<img onerror="alert(1)"> HTTP://example.com?a=1&b=2, www.example.com。'
|
|
const parts = utils.messageTextParts(input)
|
|
assert.equal(parts.map((part) => part.text).join(''), input)
|
|
assert.deepEqual(plain(parts.filter((part) => part.href).map((part) => part.href)), [
|
|
'http://example.com/?a=1&b=2',
|
|
'https://www.example.com/'
|
|
])
|
|
assert.equal(utils.escapeMessageText('<script>"&\''), '<script>"&'')
|
|
assert.ok(utils.messageTextParts('javascript:alert(1)').every((part) => !part.href))
|
|
})
|
|
|
|
test('conversation search resets pagination and uses applied filters for subsequent page requests', async () => {
|
|
const { state, calls } = createHarness('index.vue')
|
|
state.pagination.pageNum = 4
|
|
state.filters.userKeyword = ' 张三 '
|
|
state.filters.peerKeyword = ' 李四 '
|
|
state.filters.dates = ['2026-11-01', '2026-11-03']
|
|
await state.handleQuery()
|
|
assert.equal(calls[0].pageNum, 1)
|
|
assert.equal(calls[0].pageSize, 20)
|
|
assert.equal(calls[0].userKeyword, '张三')
|
|
assert.equal(calls[0].peerKeyword, '李四')
|
|
assert.equal(calls[0].lastSendTimeList[1], Date.parse('2026-11-03T23:59:59.999+08:00'))
|
|
state.filters.userKeyword = '尚未提交的筛选'
|
|
state.pagination.pageNum = 2
|
|
await state.getList()
|
|
assert.equal(calls[1].userKeyword, '张三')
|
|
await state.resetQuery()
|
|
assert.deepEqual(calls.at(-1), { pageNum: 1, pageSize: 20 })
|
|
})
|
|
|
|
test('late conversation responses cannot replace a newer search or update an unmounted page', async () => {
|
|
const first = deferred(),
|
|
second = deferred(),
|
|
third = deferred()
|
|
const pending = [first, second, third]
|
|
const { state, lifecycle } = createHarness('index.vue', {
|
|
conversationPage: () => pending.shift().promise
|
|
})
|
|
const oldRequest = state.getList()
|
|
const newRequest = state.handleQuery()
|
|
second.resolve({ list: [conversation(3, 4)], total: 1 })
|
|
await newRequest
|
|
first.resolve({ list: [conversation(1, 2)], total: 99 })
|
|
await oldRequest
|
|
assert.equal(state.list.value[0].userA.id, 3)
|
|
assert.equal(state.total.value, 1)
|
|
const disposedRequest = state.getList()
|
|
lifecycle.unmount()
|
|
third.resolve({ list: [conversation(5, 6)], total: 100 })
|
|
await disposedRequest
|
|
assert.equal(state.list.value[0].userA.id, 3)
|
|
})
|
|
|
|
test('drawer opens bilateral history with independent filters and reverses each latest-first page', async () => {
|
|
const calls = []
|
|
const { state } = createHarness('components/ConversationDrawer.vue', {
|
|
async page(params) {
|
|
calls.push(plain(params))
|
|
return { list: [{ id: 3 }, { id: 2 }, { id: 1 }], total: 3 }
|
|
}
|
|
})
|
|
state.filters.content = '旧会话筛选'
|
|
state.filters.dates = ['2026-10-01', '2026-10-02']
|
|
await state.open(conversation(11, 22))
|
|
assert.deepEqual(calls[0], { pageNum: 1, pageSize: 20, sendId: 11, recvId: 22 })
|
|
assert.deepEqual(plain(state.messages.value.map((message) => message.id)), [1, 2, 3])
|
|
state.pagination.pageNum = 7
|
|
state.filters.content = ' 内容 '
|
|
state.filters.dates = ['2026-11-03', '2026-11-03']
|
|
await state.handleQuery()
|
|
assert.equal(calls[1].pageNum, 1)
|
|
assert.equal(calls[1].content, '内容')
|
|
assert.equal(calls[1].sendTimeList[1], Date.parse('2026-11-03T23:59:59.999+08:00'))
|
|
await state.resetQuery()
|
|
assert.deepEqual(calls[2], { pageNum: 1, pageSize: 20, sendId: 11, recvId: 22 })
|
|
})
|
|
|
|
test('switching or closing a drawer invalidates older requests and restores its trigger focus', async () => {
|
|
const first = deferred(),
|
|
second = deferred(),
|
|
third = deferred()
|
|
const pending = [first, second, third]
|
|
const { state } = createHarness('components/ConversationDrawer.vue', {
|
|
page: () => pending.shift().promise
|
|
})
|
|
let focused = 0
|
|
const trigger = {
|
|
isConnected: true,
|
|
focus() {
|
|
focused++
|
|
}
|
|
}
|
|
const oldRequest = state.open(conversation(1, 2), trigger)
|
|
const newRequest = state.open(conversation(3, 4), trigger)
|
|
second.resolve({ list: [{ id: 33 }], total: 1 })
|
|
await newRequest
|
|
first.resolve({ list: [{ id: 11 }], total: 10 })
|
|
await oldRequest
|
|
assert.equal(state.messages.value[0].id, 33)
|
|
const closing = state.getList()
|
|
state.visible.value = false
|
|
state.handleClose()
|
|
third.resolve({ list: [{ id: 99 }], total: 100 })
|
|
await closing
|
|
assert.equal(state.messages.value.length, 0)
|
|
assert.equal(state.loading.value, false)
|
|
state.restoreFocus()
|
|
assert.equal(focused, 1)
|
|
})
|
|
|
|
test('failed history stays retryable and empty history is a successful result', async () => {
|
|
let attempt = 0
|
|
const { state } = createHarness('components/ConversationDrawer.vue', {
|
|
async page() {
|
|
if (!attempt++) throw new Error('offline')
|
|
return { list: null, total: 0 }
|
|
}
|
|
})
|
|
await state.open(conversation())
|
|
assert.equal(state.error.value, true)
|
|
assert.equal(state.loading.value, false)
|
|
await state.getList()
|
|
assert.equal(state.error.value, false)
|
|
assert.equal(state.messages.value.length, 0)
|
|
})
|
|
|
|
test('drawer focuses search without an event payload and closes on keep-alive deactivation', async () => {
|
|
const { state, lifecycle } = createHarness('components/ConversationDrawer.vue')
|
|
let focused = 0
|
|
state.searchRef.value = {
|
|
focus() {
|
|
focused++
|
|
}
|
|
}
|
|
await state.focusSearch()
|
|
assert.equal(focused, 1)
|
|
await state.open(conversation())
|
|
lifecycle.deactivate()
|
|
assert.equal(state.visible.value, false)
|
|
assert.equal(state.messages.value.length, 0)
|
|
})
|
|
|
|
test('refreshing a page removed by data changes returns to the last valid page', async () => {
|
|
const listRequests = []
|
|
const list = createHarness('index.vue', {
|
|
async conversationPage(params) {
|
|
listRequests.push(params.pageNum)
|
|
return { list: params.pageNum === 1 ? [conversation()] : [], total: 1 }
|
|
}
|
|
})
|
|
list.state.pagination.pageNum = 3
|
|
await list.state.getList()
|
|
assert.deepEqual(listRequests, [3, 1])
|
|
assert.equal(list.state.list.value.length, 1)
|
|
const detailRequests = []
|
|
const detail = createHarness('components/ConversationDrawer.vue', {
|
|
async page(params) {
|
|
detailRequests.push(params.pageNum)
|
|
return { list: params.pageNum === 1 ? [{ id: 1 }] : [], total: 1 }
|
|
}
|
|
})
|
|
await detail.state.open(conversation())
|
|
detail.state.pagination.pageNum = 5
|
|
await detail.state.getList()
|
|
assert.deepEqual(detailRequests, [1, 5, 1])
|
|
assert.equal(detail.state.messages.value.length, 1)
|
|
})
|
|
|
|
test('a moderation success refreshes list and history while preserving detail filters and reading position', async () => {
|
|
const shared = createModerationState()
|
|
let hidden = false,
|
|
listReads = 0
|
|
const detailQueries = []
|
|
const list = createHarness(
|
|
'index.vue',
|
|
{
|
|
async conversationPage() {
|
|
listReads++
|
|
const row = conversation()
|
|
row.lastMessage.status = hidden ? 2 : 3
|
|
return { list: [row], total: 1 }
|
|
}
|
|
},
|
|
shared
|
|
)
|
|
const drawer = createHarness(
|
|
'components/ConversationDrawer.vue',
|
|
{
|
|
async page(params) {
|
|
detailQueries.push(plain(params))
|
|
return { list: [{ ...conversation().lastMessage, status: hidden ? 2 : 3 }], total: 100 }
|
|
}
|
|
},
|
|
shared
|
|
)
|
|
await list.state.getList()
|
|
await drawer.state.open(conversation())
|
|
drawer.state.filters.content = '待保留的条件'
|
|
await drawer.state.handleQuery()
|
|
drawer.state.pagination.pageNum = 2
|
|
drawer.state.scrollRef.value = { scrollTop: 480, scrollHeight: 4000 }
|
|
hidden = true
|
|
shared.notifyMessageModerated(4)
|
|
for (let index = 0; index < 6; index++) await vue.nextTick()
|
|
assert.equal(listReads, 2)
|
|
assert.equal(list.state.list.value[0].lastMessage.status, 2)
|
|
assert.equal(detailQueries.at(-1).content, '待保留的条件')
|
|
assert.equal(detailQueries.at(-1).pageNum, 2)
|
|
assert.equal(drawer.state.scrollRef.value.scrollTop, 480)
|
|
assert.equal(drawer.state.messages.value[0].status, 2)
|
|
list.lifecycle.unmount()
|
|
drawer.lifecycle.unmount()
|
|
})
|
|
|
|
test('all conversation Vue templates compile', () => {
|
|
for (const file of [
|
|
'index.vue',
|
|
'components/ConversationDrawer.vue',
|
|
'components/ConversationMessage.vue',
|
|
'components/ConversationUser.vue'
|
|
]) {
|
|
const filename = path.join(root, file)
|
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
|
assert.deepEqual(errors, [], file)
|
|
const result = compileTemplate({ source: descriptor.template.content, filename, id: file })
|
|
assert.deepEqual(result.errors, [], file)
|
|
}
|
|
})
|