feat: 优化聊天档案浏览与增量导出

1. 增加档案加载状态、错误提示和延迟数据加载。
2. 优化移动端工具栏、消息布局及横向滚动控制。
3. 支持按年份折叠时间轴,并同步可见月份定位。
4. 使用自定义文件名作为档案标题。
5. 增量导出时保留历史头像,仅在视觉变化后新增头像版本。
6. 修复附件消息被误判为合并转发的问题。
7. 补充单元、集成及端到端测试覆盖。
This commit is contained in:
majun.jason
2026-08-05 15:00:31 +08:00
parent 4e84b52cc4
commit 933a87ebbb
7 changed files with 842 additions and 84 deletions
+218 -7
View File
@@ -44,6 +44,55 @@ const zipDirectory = async (
})
}
test('EXPORT-ARCHIVE-00 shows a loading state while archive data is still loading', async ({
page
}, testInfo) => {
let releaseData!: () => void
const dataReady = new Promise<void>((resolve) => {
releaseData = resolve
})
await page.route('http://archive.test/**', async (route) => {
if (route.request().url().endsWith('/data/messages.js')) {
await dataReady
await route.fulfill({
contentType: 'application/javascript',
body: `window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'loading-fixture',
name: '大量消息',
exportedAt: '2026-08-05T00:00:00.000Z',
messages: [
archiveMessage('loading-1', 'loading-fixture', '大量消息', '加载完成', 1_767_225_600)
]
})};`
})
return
}
await route.fulfill({
contentType: 'text/html',
body: renderExportPage('大量消息')
})
})
await page.setViewportSize({ width: 1440, height: 900 })
const navigation = page.goto('http://archive.test/index.html')
const loading = page.locator('#archive-loading')
await expect(loading).toBeVisible()
await expect(loading).toContainText('正在加载聊天档案')
await expect(loading).toHaveAttribute('aria-busy', 'true')
await page.screenshot({ path: testInfo.outputPath('archive-loading-1440.png') })
await page.setViewportSize({ width: 390, height: 844 })
await expect(loading).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('archive-loading-390.png') })
releaseData()
await navigation
await expect(loading).toBeHidden()
await expect(page.getByText('加载完成')).toBeVisible()
})
test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobile', async ({
page
}, testInfo) => {
@@ -60,13 +109,24 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
name: '合并聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 2 },
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 3 },
{ id: 'beta', name: '文件传输助手', type: 'user', messageCount: 1 }
],
messages: [
archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200),
archiveMessage('beta-1', 'beta', '文件传输助手', '个人聊天消息', 1_769_904_000),
archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400)
archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400),
{
...archiveMessage(
'alpha-sent',
'alpha',
'Jamie',
'那边多少度呀 热不,这是用于验证移动端右侧头像不会被裁切的消息',
1_775_315_283
),
isSender: true,
name: 'Nanin'
}
]
})};\n`,
'utf8'
@@ -83,12 +143,17 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
const conversationSelect = page.getByLabel('筛选聊天')
await expect(conversationSelect).toHaveValue('all')
await expect(conversationSelect.locator('option')).toHaveCount(3)
await expect(conversationSelect.locator('option')).toHaveText([
'全部聊天',
'项目群',
'文件传输助手'
])
await expect(page.locator('#archive-title')).toBeHidden()
await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible()
await expect(page.locator('#archive-meta')).toHaveText(/^更新于 /)
await expect(page.locator('#archive-meta')).not.toContainText('条消息')
await expect(page.locator('.message')).toHaveCount(3)
await expect(page.locator('.conversation-source')).toHaveCount(3)
await expect(page.locator('.message')).toHaveCount(4)
await expect(page.locator('.conversation-source')).toHaveCount(4)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
@@ -121,7 +186,91 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
expect(positions.conversationBottom).toBeLessThanOrEqual(positions.toolbarBottom)
expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom)
expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth)
await expect(page.locator('.message')).toHaveCount(3)
await expect(page.locator('.message')).toHaveCount(4)
const searchInput = page.getByLabel('搜索消息')
await expect(conversationSelect).toBeVisible()
const compactControlBounds = await page.evaluate(() => {
const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect()
const search = document.querySelector('#query')!.getBoundingClientRect()
return {
conversationTop: conversations.top,
conversationBottom: conversations.bottom,
conversationWidth: conversations.width,
searchTop: search.top,
searchBottom: search.bottom,
searchWidth: search.width
}
})
expect(
Math.abs(compactControlBounds.conversationTop - compactControlBounds.searchTop)
).toBeLessThanOrEqual(1)
expect(
Math.abs(compactControlBounds.conversationBottom - compactControlBounds.searchBottom)
).toBeLessThanOrEqual(1)
expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth)
await conversationSelect.selectOption('beta')
await expect(page.locator('.message')).toHaveCount(1)
await conversationSelect.selectOption('all')
await expect(page.locator('.message')).toHaveCount(4)
await expect(searchInput).toBeVisible()
const mobileFilterButtons = page.locator('.filter-button:visible')
await expect(mobileFilterButtons).toHaveCount(7)
const filterButtonTops = await mobileFilterButtons.evaluateAll((buttons) =>
buttons.map((button) => button.getBoundingClientRect().top)
)
expect(Math.max(...filterButtonTops) - Math.min(...filterButtonTops)).toBeLessThanOrEqual(1)
const countTop = await page
.locator('#count')
.evaluate((element) => element.getBoundingClientRect().top)
const filterBottom = await mobileFilterButtons
.first()
.evaluate((element) => element.getBoundingClientRect().bottom)
expect(countTop).toBeGreaterThanOrEqual(filterBottom)
expect(positions.toolbarBottom - positions.toolbarTop).toBeLessThanOrEqual(150)
await page.getByRole('button', { name: '文字', exact: true }).click()
const messageList = page.locator('#messages')
const sentMessageBounds = await page.locator('.message.sent').evaluate((element) => {
const list = element.parentElement!.getBoundingClientRect()
const message = element.getBoundingClientRect()
const row = element.querySelector('.row')!.getBoundingClientRect()
const avatar = element.querySelector('.avatar')!.getBoundingClientRect()
return {
listLeft: list.left,
listRight: list.right,
messageLeft: message.left,
messageRight: message.right,
rowLeft: row.left,
rowRight: row.right,
avatarLeft: avatar.left,
avatarRight: avatar.right
}
})
expect(Math.abs(sentMessageBounds.rowLeft - sentMessageBounds.messageLeft)).toBeLessThanOrEqual(
1
)
expect(
Math.abs(sentMessageBounds.rowRight - sentMessageBounds.messageRight)
).toBeLessThanOrEqual(1)
expect(sentMessageBounds.rowLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft)
expect(sentMessageBounds.rowRight).toBeLessThanOrEqual(sentMessageBounds.listRight)
expect(sentMessageBounds.avatarLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft)
expect(sentMessageBounds.avatarRight).toBeLessThanOrEqual(sentMessageBounds.listRight)
const mobileScrollBehavior = await messageList.evaluate((element) => {
const styles = getComputedStyle(element)
return {
overflowX: styles.overflowX,
overscrollBehaviorX: styles.overscrollBehaviorX,
touchAction: styles.touchAction
}
})
expect(mobileScrollBehavior).toEqual({
overflowX: 'hidden',
overscrollBehaviorX: 'none',
touchAction: 'pan-y'
})
await messageList.hover()
await page.mouse.wheel(80, 120)
await expect.poll(() => messageList.evaluate((element) => element.scrollLeft)).toBe(0)
await page.screenshot({ path: testInfo.outputPath('merged-archive-390.png'), fullPage: true })
} finally {
rmSync(fixtureRoot, { recursive: true, force: true })
@@ -171,7 +320,7 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a
test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({
page
}) => {
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
@@ -218,6 +367,19 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing
const messages = page.locator('#messages')
const activeMonth = page.locator('.timeline-month.active')
await expect(activeMonth).toHaveAttribute('data-month', '2026-04')
const expandedYear = page.locator('.timeline-year[aria-expanded="true"]')
const latestYear = page.locator('.timeline-year[data-year="2026"]')
await expect(expandedYear).toHaveCount(1)
await expect(expandedYear).toHaveText('2026 年')
await expect(page.locator('.timeline-month:visible')).toHaveCount(1)
await latestYear.click()
await expect(latestYear).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('.timeline-month:visible')).toHaveCount(0)
await latestYear.click()
await expect(latestYear).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.timeline-month:visible')).toHaveCount(1)
await expect(page.locator('#archive-loading')).toBeHidden()
await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-1440.png') })
expect(
await messages.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
@@ -236,10 +398,59 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing
expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop)
expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1)
const selectedYear = page.locator('.timeline-year[data-year="2020"]')
await selectedYear.click()
await expect(expandedYear).toHaveText('2020 年')
await expect(selectedYear).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.timeline-year[data-year="2026"]')).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(page.locator('.timeline-month:visible')).toHaveCount(12)
const selectedMonth = page.locator('.timeline-month[data-month="2020-07"]')
await selectedMonth.click()
await expect(selectedMonth).toHaveClass(/active/)
await expect(activeMonth).toHaveAttribute('data-month', '2020-07')
const visibleMonths = await messages.evaluate((element) => {
const bounds = element.getBoundingClientRect()
const anchor = bounds.top + Math.min(24, bounds.height / 4)
const items = Array.from(element.querySelectorAll<HTMLElement>('.message'))
return {
firstVisible: items.find((item) => item.getBoundingClientRect().bottom > bounds.top)
?.dataset.month,
firstAnchored: items.find((item) => item.getBoundingClientRect().bottom > anchor)?.dataset
.month
}
})
expect(visibleMonths).toEqual({ firstVisible: '2020-06', firstAnchored: '2020-07' })
await messages.evaluate((element) => {
element.scrollTop = 0
})
await expect(activeMonth).toHaveAttribute('data-month', '2006-01')
await expect(expandedYear).toHaveText('2006 年')
await expect(page.locator('.timeline-month:visible')).toHaveCount(12)
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
const mobileLayoutBounds = await page.evaluate(() => {
const layout = document.querySelector('.archive-layout')!.getBoundingClientRect()
const messages = document.querySelector('#messages')!.getBoundingClientRect()
return {
viewportWidth: window.innerWidth,
layoutLeft: layout.left,
layoutRight: layout.right,
messagesLeft: messages.left,
messagesRight: messages.right
}
})
expect(mobileLayoutBounds.layoutLeft).toBeGreaterThanOrEqual(0)
expect(mobileLayoutBounds.layoutRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth)
expect(mobileLayoutBounds.messagesLeft).toBeGreaterThanOrEqual(0)
expect(mobileLayoutBounds.messagesRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth)
await expect(expandedYear).toHaveText('2006 年')
await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-390.png') })
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
@@ -491,7 +702,7 @@ test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under
fullPage: true
})
await page.getByRole('button', { name: '系统 / 其他', exact: true }).click()
await page.getByRole('button', { name: '系统', exact: true }).click()
await expect(page.locator('.message')).toHaveCount(6)
await expect(page.getByText('我给你发了一个红包')).toBeVisible()
await expect(page.getByText('收到转账¥1000.00元')).toBeVisible()
+137 -2
View File
@@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({
documents: '',
accountRoot: '',
videoPath: '',
selfAvatar: undefined as string | undefined,
avatarMap: {} as Record<string, string>,
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportReads: [] as string[],
@@ -44,6 +46,26 @@ const state = vi.hoisted(() => ({
vi.mock('electron', () => ({
app: { getPath: () => state.documents },
shell: { showItemInFolder: vi.fn() },
nativeImage: {
createFromBuffer: (buffer: Buffer) => {
const reversed = buffer.toString().includes('different-avatar')
const bitmap = Buffer.alloc(9 * 8 * 4)
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 9; x += 1) {
const offset = (y * 9 + x) * 4
const value = reversed ? 240 - x * 20 : 40 + x * 20
bitmap[offset] = value
bitmap[offset + 1] = value
bitmap[offset + 2] = value
bitmap[offset + 3] = 255
}
}
return {
isEmpty: () => false,
resize: () => ({ toBitmap: () => bitmap })
}
}
},
BrowserWindow: class {}
}))
vi.mock('../../src/main/services/chat-service', () => ({
@@ -60,10 +82,11 @@ vi.mock('../../src/main/services/chat-service', () => ({
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
})
}),
getContactAvatars: () => ({}),
getContactAvatars: () => ({ ...state.avatarMap }),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
})
}))
@@ -192,6 +215,8 @@ describe('media export flow', () => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
state.accountRoot = join(state.documents, 'fixture-account')
state.videoPath = join(state.documents, 'fixture.mp4')
state.selfAvatar = undefined
state.avatarMap = {}
writeFileSync(
state.videoPath,
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
@@ -283,7 +308,8 @@ describe('media export flow', () => {
'ftyp'
)
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/)
@@ -305,6 +331,33 @@ describe('media export flow', () => {
expect(state.exportReads).toEqual(['fixture-user'])
})
it('uses the customized file name as the HTML archive title', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [message({ id: 'custom-title', content: '标题测试' })]
const result = await runExport(
{
jobId: 'custom-title',
targets: [target('fixture-user', '联系人原名')],
format: 'html',
outputName: '我修改后的文件名',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success).toBe(true)
const html = readFileSync(result.outputPath!, 'utf8')
const archive = readArchive(result.outputPath!)
expect(html).toContain('<title>我修改后的文件名 - 聊天记录</title>')
expect(html).toContain('<span class="title" id="archive-title">我修改后的文件名</span>')
expect(html).not.toContain('<title>联系人原名 - 聊天记录</title>')
expect(archive.name).toBe('我修改后的文件名')
expect(archive.conversations[0].name).toBe('联系人原名')
})
it('keeps one-to-one sender sides and fills both display names', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
@@ -481,6 +534,88 @@ describe('media export flow', () => {
expect(existsSync(imagePath)).toBe(true)
})
it('keeps historical avatars and creates a new version only after a real visual change', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const encodedAvatar = (value: string): string =>
`data:image/jpeg;base64,${Buffer.from(value).toString('base64')}`
const request = {
targets: [target('fixture-user', '头像版本会话')],
format: 'html' as const,
outputName: 'avatar-version-fixture',
kinds: ['text'] as const,
includeMedia: false,
includeAvatars: true
}
const oldMessage = message({
id: 'avatar-old',
isSender: true,
senderId: 'a969409112',
content: '历史消息',
createTime: 1_785_549_600
})
const sameAvatarFirstEncoding = encodedAvatar('same-visual-encoding-one')
state.selfAvatar = sameAvatarFirstEncoding
state.avatarMap = { a969409112: sameAvatarFirstEncoding }
state.messages = [oldMessage]
const first = await runExport(
{ ...request, jobId: 'avatar-version-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstAvatarUrl = readArchive(first.outputPath!).messages[0].exportAvatarUrl
const newMessageBeforeChange = message({
id: 'avatar-new-same',
isSender: true,
senderId: 'a969409112',
content: '头像未变时的新消息',
createTime: 1_785_549_700
})
const sameAvatarSecondEncoding = encodedAvatar('same-visual-encoding-two')
state.selfAvatar = sameAvatarSecondEncoding
state.avatarMap = { a969409112: sameAvatarSecondEncoding }
state.messages = [oldMessage, newMessageBeforeChange]
const second = await runExport(
{ ...request, jobId: 'avatar-version-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
expect(readArchive(second.outputPath!).messages.map((item) => item.exportAvatarUrl)).toEqual([
firstAvatarUrl,
firstAvatarUrl
])
const newMessageAfterChange = message({
id: 'avatar-new-changed',
isSender: true,
senderId: 'a969409112',
content: '真正换头像后的新消息',
createTime: 1_785_549_800
})
const changedAvatar = encodedAvatar('different-avatar')
state.selfAvatar = changedAvatar
state.avatarMap = { a969409112: changedAvatar }
state.messages = [oldMessage, newMessageBeforeChange, newMessageAfterChange]
const third = await runExport(
{ ...request, jobId: 'avatar-version-third', kinds: [...request.kinds] },
win as never
)
expect(third.success, third.error).toBe(true)
const thirdArchive = readArchive(third.outputPath!)
expect(thirdArchive.messages.slice(0, 2).map((item) => item.exportAvatarUrl)).toEqual([
firstAvatarUrl,
firstAvatarUrl
])
expect(thirdArchive.messages[2].exportAvatarUrl).not.toBe(firstAvatarUrl)
expect(
readdirSync(join(dirname(third.outputPath!), 'avatars')).filter((name) =>
name.startsWith('avatar_')
)
).toHaveLength(2)
})
it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
+11 -2
View File
@@ -25,8 +25,13 @@ describe('export media', () => {
const html = renderExportPage('脱敏导出')
expect(EXPORT_PAGE_SIZE).toBe(240)
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
expect(html).toContain('正在加载聊天档案')
expect(html).toContain('requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))')
expect(html).toContain('aria-label="聊天时间轴"')
expect(html).toContain('aria-expanded="')
expect(html).toContain('setExpandedTimelineYear')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
@@ -91,6 +96,10 @@ describe('export media', () => {
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
expect(
dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]')
).toHaveLength(1)
expect(dom.window.document.querySelectorAll('.timeline-months:not([hidden])')).toHaveLength(1)
dom.window.close()
})
@@ -124,7 +133,7 @@ describe('export media', () => {
expect(dom.window.document.querySelector('#archive-meta')?.textContent).toMatch(/^更新于 /)
expect(select.options).toHaveLength(3)
expect(select.value).toBe('all')
expect(select.options[0].textContent).toBe('全部聊天3')
expect(select.options[0].textContent).toBe('全部聊天')
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 3 / 筛选 3 / 全部 3'
+28
View File
@@ -28,6 +28,34 @@ describe('message parser', () => {
}
})
it.each([
['6', '测试附件.pdf'],
['74', '发送中的附件.zip']
])(
'keeps file app message type %s when attachment metadata contains record tags',
(typeVal, title) => {
const parsed = parseMessageContent(
`<appmsg><type>${typeVal}</type><title>${title}</title><des>1 MB</des><appattach><recorditem>legacy metadata</recorditem><dataitem datatype="8"><datatitle>${title}</datatitle></dataitem></appattach></appmsg>`,
49
)
expect(parsed).toMatchObject({
type: 'share',
title,
typeVal
})
}
)
it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent(
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',
49
)
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',