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 = '
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('