Merge branch 'nanin/develop' into develop

This commit is contained in:
Wxw-Gu
2026-08-05 18:44:25 +08:00
37 changed files with 5185 additions and 492 deletions
+162
View File
@@ -0,0 +1,162 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ExportWorkspace } from '../../src/renderer/src/components/export/ExportWorkspace'
import { ExportTaskCenter } from '../../src/renderer/src/components/export/ExportTaskCenter'
import type { ExportTaskRecord } from '../../src/shared/export'
import type { Contact, Message } from '../../src/shared/types'
const contacts: Contact[] = Array.from({ length: 6 }, (_, index) => ({
md5: `contact-${index + 1}`,
m_nsUsrName: `wxid_contact_${index + 1}`,
m_nsNickName: `聊天 ${String.fromCharCode(65 + index)}`,
type: index === 2 ? 'group' : 'user'
}))
const previewMessage = (contact: Contact): Message => ({
id: `preview-${contact.md5}`,
from: 'user',
type: '普通文本',
datetime: '',
content: `${contact.m_nsNickName} 的预览`,
isSender: false,
createTime: contacts.indexOf(contact) + 1
})
describe('ExportWorkspace multi-chat selection', () => {
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
onExportProgress: vi.fn(() => vi.fn()),
getGroupSnapshot: vi.fn(async () => ({ members: [] })),
cancelExport: vi.fn(async () => ({ success: true })),
revealExport: vi.fn(async () => ({ success: true }))
}
})
})
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false }))
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
<ExportWorkspace
contacts={contacts}
initialContact={contacts[0]}
selfInfo={{ wxid: 'self', nickname: '本人', accountRoot: '/fixture' }}
dbReady
loadPreviewMessages={loadPreviewMessages}
onOpenSettings={vi.fn()}
exportTasks={[]}
onStartExport={onStartExport}
onCancelExport={vi.fn(async () => undefined)}
/>
)
return { loadPreviewMessages }
}
it('defaults to one chat, forces HTML after adding another, merges the preview, and resets locally', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
const { loadPreviewMessages } = renderWorkspace(onStartExport)
expect(screen.getAllByText('聊天 A')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(await screen.findByText('聊天 A 的预览')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '+ 添加聊天' }))
await userEvent.click(screen.getByRole('button', { name: /聊天 B/ }))
expect(screen.getByText('已选 2 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: 'CSV' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'JSON' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Markdown' })).toBeDisabled()
expect(screen.getByRole('button', { name: /HTML/ })).toHaveClass('active')
expect(await screen.findByText('聊天 B 的预览')).toBeVisible()
expect(screen.getByText('2 个聊天 · 合并预览')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
format: 'html',
outputName: '聊天 A等2个聊天_合并档案',
targets: [
{ userMd5: 'contact-1', name: '聊天 A' },
{ userMd5: 'contact-2', name: '聊天 B' }
]
})
await userEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.queryByText('已选 2 / 5 个')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(screen.getAllByText('聊天 A').length).toBeGreaterThanOrEqual(2)
expect(loadPreviewMessages).toHaveBeenCalledWith(contacts[0])
expect(contacts[0].md5).toBe('contact-1')
})
it('does not allow removing the last chat and disables unselected chats at five', async () => {
renderWorkspace()
await userEvent.click(screen.getByRole('button', { name: '+ 添加聊天' }))
await userEvent.click(screen.getByRole('button', { name: /聊天 A/ }))
expect(screen.getByText('已选 1 / 5 个')).toBeVisible()
for (const name of ['聊天 B', '聊天 C', '聊天 D', '聊天 E']) {
await userEvent.click(screen.getByRole('button', { name: new RegExp(name) }))
}
expect(screen.getByText('已选 5 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeDisabled()
await userEvent.click(screen.getByRole('button', { name: /聊天 B/ }))
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeEnabled()
})
})
describe('ExportTaskCenter details', () => {
it('shows the exported message count for success and the reason for failure', () => {
const tasks: ExportTaskRecord[] = [
{
jobId: 'success',
targetIds: ['contact-1'],
targetNames: ['聊天 A'],
targetLabel: '聊天 A',
format: 'html',
status: 'completed',
progress: {
jobId: 'success',
phase: 'completed',
processed: 125,
total: 125,
percent: 100
},
createdAt: 1
},
{
jobId: 'failure',
targetIds: ['contact-1', 'contact-2'],
targetNames: ['聊天 A', '聊天 B'],
targetLabel: '聊天 A 等 2 个聊天',
format: 'html',
status: 'failed',
progress: {
jobId: 'failure',
phase: 'failed',
processed: 0,
percent: 0,
error: '视频文件没有写入权限'
},
createdAt: 2
}
]
render(
<ExportTaskCenter open taskCount={0} tasks={tasks} onToggle={vi.fn()} onCancel={vi.fn()} />
)
expect(screen.getByText('HTML · 已完成')).toBeVisible()
expect(screen.getByText('成功导出 125 条消息')).toBeVisible()
expect(screen.getByText('HTML · 导出失败')).toBeVisible()
expect(screen.getByText('失败原因:视频文件没有写入权限')).toBeVisible()
})
})
+32
View File
@@ -114,6 +114,38 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
}
})
test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => {
const fixture = await launchTestApp()
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
await navigation.getByRole('button', { name: '导出' }).click()
const contactList = fixture.page.locator('.export-contact-list')
await expect(contactList.getByRole('button', { name: /文件传输助手/ })).toHaveAttribute(
'aria-pressed',
'true'
)
await fixture.page.getByRole('button', { name: '+ 添加聊天' }).click()
await contactList.getByRole('button', { name: /产品测试群/ }).click()
await expect(fixture.page.getByText('已选 2 / 5 个')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: 'CSV' })).toBeDisabled()
await expect(fixture.page.getByRole('button', { name: /HTML/ })).toHaveClass(/active/)
await expect(fixture.page.getByText('文件传输助手、产品测试群 · 共 2 个聊天')).toBeVisible()
await expect(
fixture.page.locator('.export-preview-bubble').filter({ hasText: '这是一条脱敏测试消息' })
).toHaveCount(1)
await navigation.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
const fixture = await launchTestApp()
try {
+830
View File
@@ -0,0 +1,830 @@
import { expect, test } from '@playwright/test'
import { execFileSync } from 'child_process'
import { createWriteStream, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { pathToFileURL } from 'url'
import { ZipArchive } from 'archiver'
import { renderExportPage } from '../../src/main/export-html-template'
import type { Message } from '../../src/shared/types'
const archiveMessage = (
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message => ({
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
name: '脱敏成员',
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
})
const zipDirectory = async (
sourceDir: string,
zipPath: string,
folderName: string
): Promise<void> => {
const output = createWriteStream(zipPath)
const archive = new ZipArchive({ zlib: { level: 6 } })
await new Promise<void>((resolve, reject) => {
output.on('close', resolve)
output.on('error', reject)
archive.on('error', reject)
archive.pipe(output)
archive.directory(sourceDir, folderName)
void archive.finalize().catch(reject)
})
}
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) => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'wxe-merged-archive-e2e-'))
const outputDir = join(fixtureRoot, 'source')
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('合并聊天档案'), 'utf8')
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 2,
name: '合并聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{
id: 'alpha',
name: '项目群',
type: 'group',
avatarUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath fill='%23176b57' d='M0 0h10v10H0z'/%3E%3C/svg%3E",
messageCount: 3
},
{
id: 'beta',
name: '文件传输助手',
type: 'user',
avatarUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath fill='%23d9f0e2' d='M0 0h10v10H0z'/%3E%3C/svg%3E",
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-sent',
'alpha',
'Jamie',
'那边多少度呀 热不,这是用于验证移动端右侧头像不会被裁切的消息',
1_775_315_283
),
isSender: true,
name: 'Nanin'
}
]
})};\n`,
'utf8'
)
const zipPath = join(fixtureRoot, 'merged-archive.zip')
const extractedDir = join(fixtureRoot, 'extracted')
await zipDirectory(outputDir, zipPath, '合并聊天档案')
mkdirSync(extractedDir, { recursive: true })
execFileSync('unzip', ['-q', zipPath, '-d', extractedDir])
const offlineIndex = join(extractedDir, '合并聊天档案', 'index.html')
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(offlineIndex).href)
const conversationTrigger = page.getByRole('button', { name: '筛选聊天' })
const conversationMenu = page.getByRole('listbox', { name: '选择聊天' })
const chooseConversation = async (name: string): Promise<void> => {
await conversationTrigger.click()
await conversationMenu.getByRole('option', { name, exact: true }).click()
}
await expect(conversationTrigger).toHaveAttribute('aria-expanded', 'false')
await expect(conversationMenu).toBeHidden()
await expect(conversationTrigger.locator('.conversation-switch-icon')).toBeVisible()
await expect(
conversationTrigger.locator('.conversation-trigger-name + .conversation-switch-icon')
).toBeVisible()
await expect(conversationTrigger.locator('.conversation-chevron')).toHaveCount(0)
await expect(page.locator('#conversation-trigger-name')).toHaveText('全部聊天')
await expect(page.locator('#conversation-trigger-avatar img')).toHaveCount(2)
await expect(page.locator('#archive-title')).toBeHidden()
await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible()
await expect(page.locator('#archive-meta')).toHaveCount(0)
await expect(page.locator('.message')).toHaveCount(4)
await expect(page.locator('.conversation-source')).toHaveCount(4)
const desktopSwitcherBounds = await page.evaluate(() => {
const trigger = document.querySelector('#conversation-trigger')!.getBoundingClientRect()
const name = document.querySelector('#conversation-trigger-name')!.getBoundingClientRect()
const icon = document.querySelector('.conversation-switch-icon')!.getBoundingClientRect()
return { triggerWidth: trigger.width, nameToIcon: icon.left - name.right }
})
expect(desktopSwitcherBounds.triggerWidth).toBeLessThan(200)
expect(desktopSwitcherBounds.nameToIcon).toBeLessThanOrEqual(12)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await conversationTrigger.click()
await expect(conversationTrigger).toHaveAttribute('aria-expanded', 'true')
await expect(conversationMenu).toBeVisible()
await expect(conversationMenu.getByRole('option')).toHaveCount(3)
await expect(conversationMenu.getByRole('option', { name: '全部聊天' })).toBeVisible()
await expect(conversationMenu.getByRole('option', { name: '项目群' })).toBeVisible()
await expect(conversationMenu.getByRole('option', { name: '文件传输助手' })).toBeVisible()
await expect(conversationMenu).not.toContainText('条消息')
await expect(conversationMenu.getByRole('option', { name: '全部聊天' })).toHaveAttribute(
'aria-selected',
'true'
)
await expect(conversationTrigger).toHaveCSS('border-top-width', '0px')
await page.screenshot({ path: testInfo.outputPath('conversation-menu-1440.png') })
await page.keyboard.press('Escape')
await expect(conversationMenu).toBeHidden()
await page.screenshot({ path: testInfo.outputPath('merged-archive-1440.png'), fullPage: true })
await chooseConversation('文件传输助手')
await expect(page.locator('.message')).toHaveCount(1)
await expect(page.locator('#conversation-trigger-name')).toHaveText('文件传输助手')
await expect(page.locator('#conversation-trigger-avatar img')).toHaveCount(1)
await expect(page.locator('.conversation-source')).toHaveCount(0)
await chooseConversation('全部聊天')
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.locator('.timeline-year')).toHaveCount(2)
await expect(page.locator('.timeline-year').first()).toBeVisible()
await expect(page.locator('.timeline-year').first()).toHaveText('2025 年')
const positions = await page.evaluate(() => {
const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect()
const toolbar = document.querySelector('.toolbar')!.getBoundingClientRect()
const timeline = document.querySelector('#timeline')!.getBoundingClientRect()
return {
conversationTop: conversations.top,
conversationBottom: conversations.bottom,
toolbarTop: toolbar.top,
toolbarBottom: toolbar.bottom,
timelineTop: timeline.top,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth
}
})
expect(positions.conversationTop).toBeGreaterThanOrEqual(positions.toolbarTop)
expect(positions.conversationBottom).toBeLessThanOrEqual(positions.toolbarBottom)
expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom)
expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth)
await expect(page.locator('.message')).toHaveCount(4)
const searchInput = page.getByLabel('搜索消息')
await expect(conversationTrigger).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,
searchFontSize: getComputedStyle(document.querySelector('#query')!).fontSize
}
})
expect(
Math.abs(compactControlBounds.conversationTop - compactControlBounds.searchTop)
).toBeLessThanOrEqual(1)
expect(
Math.abs(compactControlBounds.conversationBottom - compactControlBounds.searchBottom)
).toBeLessThanOrEqual(1)
expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth)
expect(compactControlBounds.searchFontSize).toBe('16px')
await expect(conversationTrigger.locator('.conversation-switch-icon')).toHaveCSS(
'width',
'13px'
)
await conversationTrigger.click()
await expect(conversationMenu).toBeVisible()
const mobileMenuBounds = await conversationMenu.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return { left: bounds.left, right: bounds.right, viewportWidth: window.innerWidth }
})
expect(mobileMenuBounds.left).toBeGreaterThanOrEqual(0)
expect(mobileMenuBounds.right).toBeLessThanOrEqual(mobileMenuBounds.viewportWidth)
await page.screenshot({ path: testInfo.outputPath('conversation-menu-390.png') })
await conversationMenu.getByRole('option', { name: '文件传输助手' }).click()
await expect(page.locator('.message')).toHaveCount(1)
await chooseConversation('全部聊天')
await expect(page.locator('.message')).toHaveCount(4)
await expect(searchInput).toBeVisible()
await searchInput.fill('个人聊天消息')
await expect(page.locator('.search-highlight')).toHaveText('个人聊天消息')
const mobileSearchResult = page.locator('.message')
const mobileLocateButton = mobileSearchResult.getByRole('button', {
name: '定位到聊天位置'
})
await mobileSearchResult.hover()
await expect(mobileLocateButton).toHaveCSS('opacity', '1')
await page.screenshot({
path: testInfo.outputPath('search-highlight-390.png'),
animations: 'disabled'
})
await mobileLocateButton.click()
await expect(searchInput).toHaveValue('')
await expect(page.locator('.message.located')).toContainText('个人聊天消息')
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 })
}
})
test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', async ({
page
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-single-archive-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('单聊天档案'), 'utf8')
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'single',
name: '单聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages: [archiveMessage('single-1', 'single', '单聊天档案', '单聊天消息', 1_767_225_600)]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await expect(page.locator('#conversation-filter')).toBeHidden()
await expect(page.locator('#archive-title')).toBeVisible()
await expect(page.locator('#archive-title')).toHaveText('单聊天档案')
await expect(page.locator('#archive-meta')).toHaveCount(0)
await expect(page.locator('.archive-layout')).toHaveClass(/single-conversation/)
await expect(page.locator('.message')).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('single-archive-1440.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await expect(page.locator('.message')).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('single-archive-390.png'), fullPage: true })
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
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')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('时间轴同步档案'), 'utf8')
const oldVoiceMessages = Array.from({ length: 240 }, (_, index) => ({
...archiveMessage(
`old-voice-${index}`,
'timeline',
'时间轴同步档案',
`旧语音-${index}`,
Date.UTC(2006 + Math.floor(index / 12), index % 12, 1) / 1000
),
type: '语音'
}))
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'timeline',
name: '时间轴同步档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages: [
...oldVoiceMessages,
{
...archiveMessage(
'latest-voice',
'timeline',
'时间轴同步档案',
'最新语音',
1_775_520_000
),
type: '语音'
}
]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await page.getByRole('button', { name: '语音', exact: true }).click()
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
)
).toBeLessThanOrEqual(2)
const timelinePosition = await activeMonth.evaluate((element) => {
const button = element.getBoundingClientRect()
const timeline = element.parentElement!.getBoundingClientRect()
return {
buttonTop: button.top,
buttonBottom: button.bottom,
timelineTop: timeline.top,
timelineBottom: timeline.bottom
}
})
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 })
}
})
test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('Tab 位置档案'), 'utf8')
const messages = Array.from({ length: 600 }, (_, index) => ({
...archiveMessage(
`message-${index}`,
'tab-position',
'Tab 位置档案',
`${index % 2 === 0 ? '文字' : '语音'}消息-${index}`,
1_735_689_600 + index * 86_400
),
type: index % 2 === 0 ? '普通文本' : '语音'
}))
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'tab-position',
name: 'Tab 位置档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await page.getByRole('button', { name: '文字', exact: true }).click()
const messageList = page.locator('#messages')
const target = page.locator('.message[data-index="100"]')
await target.evaluate((element) => {
const list = element.parentElement!
list.scrollTop += element.getBoundingClientRect().top - list.getBoundingClientRect().top - 37
})
await expect(target).toBeInViewport()
const before = await target.evaluate((element) => {
const message = element.getBoundingClientRect()
const list = element.parentElement!.getBoundingClientRect()
return message.top - list.top
})
await page.getByRole('button', { name: '语音', exact: true }).click()
await page.getByRole('button', { name: '文字', exact: true }).click()
await expect(target).toBeInViewport()
const after = await target.evaluate((element) => {
const message = element.getBoundingClientRect()
const list = element.parentElement!.getBoundingClientRect()
return message.top - list.top
})
expect(Math.abs(after - before)).toBeLessThanOrEqual(1)
expect(
await messageList.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
)
).toBeGreaterThan(100)
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under system', async ({
page
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-structured-archive-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('结构化消息档案'), 'utf8')
const message = (
id: string,
type: string,
createTime: number,
contentData: Message['contentData']
): Message => ({
...archiveMessage(id, 'structured', '结构化消息档案', '', createTime),
type,
contentData
})
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'structured',
name: '结构化消息档案',
exportedAt: '2026-08-04T12:57:32.000Z',
messages: [
message('article', '公众号链接', 1_775_000_001, {
type: 'share',
typeVal: '5',
title: '真正的公众号标题',
des: '文章摘要与关键内容',
appname: '示例公众号',
url: 'https://example.com/article?a=1&amp;b=2'
}),
message('mini', '小程序', 1_775_000_002, {
type: 'miniProgram',
title: '小程序商品标题',
description: '商品的真实描述',
appName: '示例小程序'
}),
message('channel', '视频号', 1_775_000_003, {
type: 'share',
typeVal: '51',
title: '当前微信版本不支持展示该内容,请升级至最新版本。',
des: '视频号真实标题\n视频号正文内容',
url: 'https://example.com/channel'
}),
message('forward', '合并转发', 1_775_000_004, {
type: 'forwardBundle',
title: '项目群的聊天记录',
description: '项目成员: 项目结论',
items: [
{
messageType: 1,
sender: '项目成员',
sentAt: '2026-08-04 20:00',
text: '项目结论已经确认'
}
]
}),
message('red-packet', '微信红包', 1_775_000_005, {
type: 'redPacket',
title: '微信红包',
description: '我给你发了一个红包'
}),
message('transfer', '转账', 1_775_000_006, {
type: 'share',
typeVal: '2000',
title: '微信转账',
des: '收到转账¥1000.00元',
url: ''
}),
message('voip', '通话', 1_775_000_007, {
type: 'voip',
status: '通话时长 2分15秒'
}),
message('location', '位置', 1_775_000_008, {
type: 'location',
poiname: '望和公园南园',
label: '北京市朝阳区望京街道北四环东路41号望和公园',
lat: 39.986984,
lng: 116.448578
}),
{
...message('legacy-recall', '系统消息', 1_775_000_009, {
type: 'system',
content: '"联系人" 撤回了一条消息'
}),
from: 'system',
content: '"联系人" 撤回了一条消息',
name: ''
},
{
...message('structured-recall', '系统消息', 1_775_000_010, {
type: 'system',
content: '你撤回了一条消息',
recall: {
targetId: 'fixture-target',
replacement: '你撤回了一条消息',
actor: '你'
}
}),
from: 'system',
content: '你撤回了一条消息',
name: ''
},
{
...message('location-sharing-ended', '系统消息', 1_775_000_011, {
type: 'system',
content: '位置共享已经结束'
}),
from: 'system',
content: '位置共享已经结束',
name: ''
}
]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await expect(page.locator('[data-rich-kind="share"]')).toHaveCount(2)
await expect(page.getByText('真正的公众号标题')).toBeVisible()
await expect(page.getByText('文章摘要与关键内容')).toBeVisible()
await expect(page.getByText('小程序商品标题')).toBeVisible()
await expect(page.getByText('视频号真实标题')).toBeVisible()
await expect(page.getByText('当前微信版本不支持展示该内容,请升级至最新版本。')).toHaveCount(0)
await expect(page.getByText('项目群的聊天记录')).toBeVisible()
await page.getByText('展开 1 条消息').click()
await expect(page.getByText('项目结论已经确认')).toBeVisible()
const searchInput = page.getByLabel('搜索消息')
await searchInput.fill('真正的公众号标题')
await expect(page.locator('.message')).toHaveCount(1)
await expect(page.locator('.search-highlight')).toHaveText('真正的公众号标题')
const searchResult = page.locator('.message')
await searchResult.hover()
await page.screenshot({
path: testInfo.outputPath('search-highlight-1440.png'),
animations: 'disabled'
})
await searchResult.getByRole('button', { name: '定位到聊天位置' }).click()
await expect(searchInput).toHaveValue('')
await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/)
await expect(page.locator('.search-highlight')).toHaveCount(0)
await expect(page.locator('.message.located')).toContainText('真正的公众号标题')
await page.getByRole('button', { name: '分享', exact: true }).click()
await expect(page.locator('.message')).toHaveCount(5)
await expect(page.locator('[data-rich-kind="forwardBundle"]')).toHaveCount(1)
const locationCard = page.locator('[data-rich-kind="location"]')
await expect(locationCard).toHaveCount(1)
await expect(locationCard.getByText('望和公园南园')).toBeVisible()
await expect(locationCard.getByText('北京市朝阳区望京街道北四环东路41号望和公园')).toBeVisible()
await expect(locationCard.getByText('39.986984, 116.448578')).toBeVisible()
await expect(locationCard.getByText('在地图中打开')).toBeVisible()
await expect(locationCard.locator('xpath=ancestor::a')).toHaveAttribute(
'href',
/^https:\/\/maps\.apple\.com\/\?q=.*&ll=39\.986984,116\.448578$/
)
await expect(page.locator('.content', { hasText: '[位置]' })).toHaveCount(0)
await expect(page.locator('[data-rich-kind="transfer"]')).toHaveCount(0)
const locationMessage = locationCard.locator('xpath=ancestor::article')
const locateButton = locationMessage.getByRole('button', { name: '定位到聊天位置' })
const locateLabel = locateButton.locator('.locate-label')
await page.mouse.move(0, 0)
await expect(locateButton).toHaveCSS('opacity', '0')
await locationMessage.hover()
await expect(locateButton).toHaveCSS('opacity', '1')
await expect(locateLabel).toHaveCSS('opacity', '0')
await locateButton.hover()
await expect(locateLabel).toHaveCSS('opacity', '1')
await page.screenshot({ path: testInfo.outputPath('locate-hover-1440.png') })
await locateButton.click()
await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/)
await expect(page.locator('.message.located')).toContainText('望和公园南园')
const locatedPosition = await page.locator('.message.located').evaluate((element) => {
const messageRect = element.getBoundingClientRect()
const listRect = element.parentElement!.getBoundingClientRect()
return {
messageTop: messageRect.top,
messageBottom: messageRect.bottom,
listTop: listRect.top,
listBottom: listRect.bottom
}
})
expect(locatedPosition.messageBottom).toBeGreaterThan(locatedPosition.listTop)
expect(locatedPosition.messageTop).toBeLessThan(locatedPosition.listBottom)
await page.locator('#messages').evaluate((element) => {
element.scrollTop = 0
})
await page.screenshot({
path: testInfo.outputPath('structured-archive-1440.png'),
fullPage: true
})
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()
await expect(page.getByText('通话时长 2分15秒')).toBeVisible()
const systemNotices = page.locator('.message.system')
await expect(systemNotices).toHaveCount(3)
const recallNotices = systemNotices.filter({ hasText: '撤回了一条消息' })
await expect(recallNotices).toHaveCount(2)
await expect(recallNotices.locator('.avatar')).toHaveCount(0)
await expect(recallNotices.locator('.sender').first()).toBeHidden()
const locationNotice = systemNotices.filter({ hasText: '位置共享已经结束' })
await expect(locationNotice).toHaveCount(1)
await expect(locationNotice.locator('.avatar')).toHaveCount(0)
const recallAlignment = await recallNotices.first().evaluate((element) => {
const messageRect = element.getBoundingClientRect()
const bubbleRect = element.querySelector('.bubble')!.getBoundingClientRect()
return Math.abs(
messageRect.left + messageRect.width / 2 - (bubbleRect.left + bubbleRect.width / 2)
)
})
expect(recallAlignment).toBeLessThan(1)
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await page.screenshot({
path: testInfo.outputPath('structured-archive-390.png'),
fullPage: true
})
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
+564 -66
View File
@@ -1,15 +1,39 @@
import { dirname, join } from 'path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
unlinkSync,
utimesSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import fsExtra from 'fs-extra'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExportTarget } from '../../src/shared/export'
import type { Message } from '../../src/shared/types'
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[],
voiceLookups: [] as number[],
videoLookups: [] as {
createTime?: number
duration?: number
width?: number
height?: number
}[],
imageLookups: [] as {
allowThumbnail?: boolean
preferThumbnail?: boolean
@@ -22,18 +46,47 @@ 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', () => ({
listMessages: () => structuredClone(state.messages),
listMessagesAsync: async () => structuredClone(state.messages),
listMessagesAsync: async (userMd5: string) =>
structuredClone(state.messagesByUser[userMd5] || state.messages),
listMessagesForExport: async (userMd5: string) => {
state.exportReads.push(userMd5)
return structuredClone(state.messagesByUser[userMd5] || state.messages)
},
getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
getWcdb4Client: () => ({
getAccountRoot: () => state.accountRoot,
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
})
}),
getContactAvatars: () => ({}),
getContactAvatars: () => ({ ...state.avatarMap }),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
})
}))
@@ -50,6 +103,7 @@ vi.mock('../../src/main/voice-service', () => ({
_sessionId: string,
localId: number
): Promise<{ success: boolean; data?: string; error?: string }> {
state.voiceLookups.push(localId)
return localId === 1
? {
success: true,
@@ -107,7 +161,11 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
}))
vi.mock('../../src/main/video-asset-service', () => ({
VideoAssetService: class {
resolve(): { success: boolean; url: string } {
resolve(
_hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
): { success: boolean; url: string } {
state.videoLookups.push(options || {})
return { success: true, url: 'wxe-media://local/fixture-video' }
}
pathForUrl(): string {
@@ -130,17 +188,26 @@ const message = (overrides: Partial<Message>): Message => ({
...overrides
})
const readArchive = (outputPath: string): { sourceId: string; messages: Message[] } => {
const target = (userMd5 = 'fixture-user', name = '脱敏会话'): ExportTarget => ({
userMd5,
name,
type: 'user'
})
const readArchive = (
outputPath: string
): {
version: 2
conversations: { id: string; name: string; avatarUrl?: string; messageCount: number }[]
messages: Message[]
} => {
const source = readFileSync(join(dirname(outputPath), 'data', 'messages.js'), 'utf8')
return JSON.parse(
source
.slice(source.indexOf('=') + 1)
.trim()
.replace(/;\s*$/, '')
) as {
sourceId: string
messages: Message[]
}
)
}
describe('media export flow', () => {
@@ -148,11 +215,17 @@ 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')
)
state.imageLookups = []
state.videoLookups = []
state.messagesByUser = {}
state.exportReads = []
state.voiceLookups = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
@@ -180,7 +253,13 @@ describe('media export flow', () => {
message({
id: 'video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
contentData: {
type: 'video',
md5: 'b'.repeat(32),
duration: 68,
width: 279,
height: 630
}
}),
message({
id: 'file',
@@ -202,26 +281,16 @@ describe('media export flow', () => {
const result = await runExport(
{
jobId: 'fixture-export',
userMd5: 'fixture-user',
name: '脱敏会话',
targets: [target()],
format: 'html',
outputName: 'fixture',
kinds: ['voice', 'image', 'video', 'file'],
includeMedia: true,
includeVoiceTranscripts: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true
},
win as never,
{
recognize: vi.fn().mockResolvedValue({
success: true,
transcript: '这是导出的固定语音转写',
language: 'zh',
cached: true
})
} as never
win as never
)
expect(result.success).toBe(true)
@@ -239,11 +308,11 @@ 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(voice.voiceTranscript).toBe('这是导出的固定语音转写')
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/)
expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/)
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false,
@@ -252,7 +321,76 @@ describe('media export flow', () => {
sessionMd5: 'fixture-user',
createTime: 1_785_549_600
})
expect(state.videoLookups[0]).toEqual({
createTime: 1_785_549_600,
duration: 68,
width: 279,
height: 630
})
expect(progress.length).toBeGreaterThan(0)
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() } }
state.messages = [
message({ id: 'peer-message', content: '对方消息', isSender: false, name: '', senderId: '' }),
message({ id: 'self-message', content: '我的消息', isSender: true, name: '', senderId: '' })
]
const result = await runExport(
{
jobId: 'one-to-one-identity',
targets: [target('jamie', 'Jamie')],
format: 'html',
outputName: 'one-to-one-identity',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success, result.error).toBe(true)
const archive = readArchive(result.outputPath!)
expect(
archive.messages.map(({ id, isSender, name, senderId }) => ({
id,
isSender,
name,
senderId
}))
).toEqual([
{ id: 'peer-message', isSender: false, name: 'Jamie', senderId: 'wxid_jamie' },
{ id: 'self-message', isSender: true, name: '濑岛田井卫', senderId: 'a969409112' }
])
})
it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => {
@@ -260,8 +398,7 @@ describe('media export flow', () => {
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
jobId: 'incremental-first',
userMd5: 'fixture-user',
name: '增量会话',
targets: [target('fixture-user', '增量会话')],
format: 'html' as const,
outputName: 'incremental-fixture',
kinds: ['voice', 'text'] as const,
@@ -282,6 +419,19 @@ describe('media export flow', () => {
expect(first.success).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl
const outputDir = dirname(first.outputPath!)
firstArchive.messages.find((item) => item.id === 'voice-old')!.img =
'data:image/jpeg;base64,bGVnYWN5LWlubGluZS1hdmF0YXI='
writeFileSync(
join(outputDir, 'data', 'messages.js'),
`window.__WECHAT_EXPORT__ = ${JSON.stringify(firstArchive)};\n`,
'utf8'
)
writeFileSync(join(outputDir, 'voices', 'voice_orphan.wav'), 'orphan voice')
writeFileSync(join(outputDir, 'media', 'image_orphan.png'), 'orphan image')
writeFileSync(join(outputDir, 'media', 'file_orphan.txt'), 'legacy orphan file')
writeFileSync(join(outputDir, 'files', 'file_orphan.txt'), 'orphan file')
writeFileSync(join(outputDir, 'avatars', 'avatar_orphan.png'), 'orphan avatar')
state.messages = [
message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }),
@@ -310,53 +460,399 @@ describe('media export flow', () => {
expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe(
oldVoiceUrl
)
expect(secondArchive.messages.every((item) => item.img == null)).toBe(true)
expect(existsSync(join(outputDir, oldVoiceUrl!))).toBe(true)
expect(existsSync(join(outputDir, 'voices', 'voice_orphan.wav'))).toBe(false)
expect(existsSync(join(outputDir, 'media', 'image_orphan.png'))).toBe(false)
expect(existsSync(join(outputDir, 'media', 'file_orphan.txt'))).toBe(false)
expect(existsSync(join(outputDir, 'files', 'file_orphan.txt'))).toBe(false)
expect(existsSync(join(outputDir, 'avatars', 'avatar_orphan.png'))).toBe(false)
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
})
it('reuses existing video and file assets when Windows rejects an overwrite', async () => {
it('reuses unchanged resources and retries only missing or unresolved media', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
userMd5: 'fixture-user',
name: '媒体复用会话',
targets: [target('fixture-user', '资源复用会话')],
format: 'html' as const,
outputName: 'reused-media-fixture',
kinds: ['video', 'file'] as const,
outputName: 'resource-reuse-fixture',
kinds: ['voice', 'image', 'video', 'file'] as const,
includeMedia: true,
keepMissing: true
}
const first = await runExport(
{ ...request, jobId: 'media-reuse-first', kinds: [...request.kinds] },
{ ...request, jobId: 'resource-reuse-first', kinds: [...request.kinds] },
win as never
)
expect(first.success).toBe(true)
const originalCopyFile = fsExtra.copyFile.bind(fsExtra)
const copyFile = vi
.spyOn(fsExtra, 'copyFile')
.mockRejectedValueOnce(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
)
.mockRejectedValueOnce(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
)
.mockImplementation(originalCopyFile)
expect(first.success, first.error).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const outputDir = dirname(first.outputPath!)
const voicePath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'voice-ok')!.voiceDataUrl!
)
const imagePath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'image')!.exportMediaUrl!
)
const videoPath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'video')!.exportMediaUrl!
)
const filePath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'file')!.exportMediaUrl!
)
const oldTimestamp = new Date(1_000_000)
utimesSync(videoPath, oldTimestamp, oldTimestamp)
utimesSync(filePath, oldTimestamp, oldTimestamp)
const second = await runExport(
{ ...request, jobId: 'media-reuse-second', kinds: [...request.kinds] },
{ ...request, jobId: 'resource-reuse-second', kinds: [...request.kinds] },
win as never
)
copyFile.mockRestore()
expect(second.success, second.error).toBe(true)
expect(state.imageLookups).toHaveLength(1)
expect(state.videoLookups).toHaveLength(1)
expect(state.voiceLookups).toEqual([1, 2, 2])
expect(statSync(videoPath).mtimeMs).toBe(oldTimestamp.getTime())
expect(statSync(filePath).mtimeMs).toBe(oldTimestamp.getTime())
unlinkSync(voicePath)
unlinkSync(imagePath)
const third = await runExport(
{ ...request, jobId: 'resource-reuse-third', kinds: [...request.kinds] },
win as never
)
expect(third.success, third.error).toBe(true)
expect(state.imageLookups).toHaveLength(2)
expect(state.videoLookups).toHaveLength(1)
expect(state.voiceLookups).toEqual([1, 2, 2, 1, 2])
expect(existsSync(voicePath)).toBe(true)
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() } }
chmodSync(state.videoPath, 0o444)
state.messages = [
message({
id: 'read-only-video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
})
]
const request = {
targets: [target('fixture-user', '只读视频会话')],
format: 'html' as const,
outputName: 'read-only-video-fixture',
kinds: ['video'] as const,
includeMedia: true
}
const first = await runExport(
{ ...request, jobId: 'read-only-video-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const videoPath = join(
dirname(first.outputPath!),
firstArchive.messages[0].exportMediaUrl as string
)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
chmodSync(videoPath, 0o444)
const second = await runExport(
{ ...request, jobId: 'read-only-video-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
expect(second.outputPath).toBe(first.outputPath)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
})
it('merges two conversations in stable order without colliding identical message ids or media', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messagesByUser = {
alpha: [
message({
id: 'same-id',
type: '图片',
createTime: 100,
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'same.dat' }
}),
message({ id: 'alpha-later', content: 'A2', createTime: 200 })
],
beta: [
message({
id: 'same-id',
type: '图片',
createTime: 100,
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'same.dat' }
}),
message({ id: 'beta-later', content: 'B2', createTime: 150 })
]
}
const result = await runExport(
{
jobId: 'multi-conversation',
targets: [target('alpha', '聊天 A'), target('beta', '聊天 B')],
format: 'html',
outputName: 'multi-conversation',
kinds: ['text', 'image'],
includeMedia: true
},
win as never
)
expect(result.success).toBe(true)
const archive = readArchive(result.outputPath!)
expect(archive.version).toBe(2)
expect(archive.conversations.map(({ id, messageCount }) => ({ id, messageCount }))).toEqual([
{ id: 'alpha', messageCount: 2 },
{ id: 'beta', messageCount: 2 }
])
expect(
archive.messages.map((item) => [item.exportConversationId, item.id, item.createTime])
).toEqual([
['alpha', 'same-id', 100],
['beta', 'same-id', 100],
['beta', 'beta-later', 150],
['alpha', 'alpha-later', 200]
])
expect(state.exportReads).toEqual(['alpha', 'beta'])
const imagePaths = archive.messages
.filter((item) => item.id === 'same-id')
.map((item) => item.exportMediaUrl)
expect(new Set(imagePaths).size).toBe(1)
for (const imagePath of imagePaths) {
expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true)
}
})
it('creates a replaceable ZIP containing the complete top-level archive folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: unknown[][] = []
const win = {
isDestroyed: () => false,
webContents: { send: (...args: unknown[]) => progress.push(args) }
}
state.messages = [
message({
id: 'zip-image',
type: '图片',
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' }
})
]
const request = {
targets: [
{
...target('fixture-user', 'ZIP 会话'),
avatarUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
}
],
format: 'html' as const,
outputName: 'zip-fixture',
kinds: ['image'] as const,
includeMedia: true,
includeAvatars: true,
zip: true
}
const first = await runExport(
{ ...request, jobId: 'zip-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstSize = readFileSync(first.outputPath!).length
const second = await runExport(
{ ...request, jobId: 'zip-second', kinds: [...request.kinds] },
win as never
)
expect(first.success).toBe(true)
expect(second.success).toBe(true)
const archive = readArchive(second.outputPath!)
expect(archive.messages.find((item) => item.id === 'video')?.exportMediaUrl).toMatch(
/^media\/video_/
expect(second.outputPath).toBe(first.outputPath)
expect(firstSize).toBeGreaterThan(0)
expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK')
const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' })
const htmlPath = join(state.documents, 'WechatExplorer', '导出', 'zip-fixture', 'index.html')
const archive = readArchive(htmlPath)
expect(entries).toContain('zip-fixture/index.html')
expect(entries).toContain('zip-fixture/data/messages.js')
const avatarEntries = entries
.split('\n')
.filter((entry) => /zip-fixture\/avatars\/avatar_[0-9a-f]{16}\.png$/.test(entry))
expect(avatarEntries).toHaveLength(1)
expect(archive.conversations[0].avatarUrl).toBe(archive.messages[0].exportAvatarUrl)
expect(entries).toMatch(/zip-fixture\/media\/image_[0-9a-f]{16}\.png/)
expect(progress.some((args) => (args[1] as { phase?: string })?.phase === 'compressing')).toBe(
true
)
expect(archive.messages.find((item) => item.id === 'file')?.exportMediaUrl).toMatch(
/^media\/file_/
expect(
readdirSync(join(state.documents, 'WechatExplorer', '导出')).some((name) =>
name.startsWith('zip-fixture.zip.tmp-')
)
).toBe(false)
})
it('keeps the last complete ZIP when a replacement is cancelled during compression', async () => {
const { cancelExport, runExport } = await import('../../src/main/export-service')
state.messages = [message({ id: 'zip-cancel', content: '保留完整压缩包' })]
const request = {
targets: [target('fixture-user', '取消压缩会话')],
format: 'html' as const,
outputName: 'zip-cancel-fixture',
kinds: ['text'] as const,
includeMedia: false,
zip: true
}
const silentWindow = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const first = await runExport(
{ ...request, jobId: 'zip-cancel-first', kinds: [...request.kinds] },
silentWindow as never
)
expect(first.success, first.error).toBe(true)
const completeZip = readFileSync(first.outputPath!)
const cancellingWindow = {
isDestroyed: () => false,
webContents: {
send: (_channel: string, progress: { phase: string }): void => {
if (progress.phase === 'compressing') cancelExport('zip-cancel-second')
}
}
}
const cancelled = await runExport(
{ ...request, jobId: 'zip-cancel-second', kinds: [...request.kinds] },
cancellingWindow as never
)
expect(cancelled).toEqual({ success: false, error: '已取消' })
expect(readFileSync(first.outputPath!)).toEqual(completeZip)
expect(
readdirSync(join(state.documents, 'WechatExplorer', '导出')).some((name) =>
name.startsWith('zip-cancel-fixture.zip.tmp-')
)
).toBe(false)
})
it('normalizes a legacy v1 single-chat archive into v2', async () => {
const { readHtmlArchive } = await import('../../src/main/export-service')
const outputDir = join(state.documents, 'legacy-archive')
mkdirSync(join(outputDir, 'data'), { recursive: true })
writeFileSync(
join(outputDir, 'data', 'messages.js'),
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'legacy-user',
name: '旧档案',
exportedAt: '2026-08-01T00:00:00.000Z',
messages: [message({ id: 'legacy-message', content: '旧消息' })]
})};\n`,
'utf8'
)
const archive = await readHtmlArchive(outputDir, [target('legacy-user', '旧档案')], '旧档案')
expect(archive.version).toBe(2)
expect(archive.conversations).toEqual([
expect.objectContaining({ id: 'legacy-user', name: '旧档案', messageCount: 1 })
])
expect(archive.messages[0]).toMatchObject({
id: 'legacy-message',
exportConversationId: 'legacy-user',
exportConversationName: '旧档案'
})
})
it('refuses to merge a different conversation into an existing named archive', async () => {
@@ -365,8 +861,7 @@ describe('media export flow', () => {
state.messages = [message({ id: 'text', content: 'fixture' })]
const baseRequest = {
jobId: 'source-first',
userMd5: 'first-user',
name: '第一个会话',
targets: [target('first-user', '第一个会话')],
format: 'html' as const,
outputName: 'same-name',
kinds: ['text'] as const,
@@ -377,8 +872,7 @@ describe('media export flow', () => {
{
...baseRequest,
jobId: 'source-second',
userMd5: 'second-user',
name: '第二个会话',
targets: [target('second-user', '第二个会话')],
kinds: [...baseRequest.kinds]
},
win as never
@@ -386,8 +880,10 @@ describe('media export flow', () => {
expect(first.success).toBe(true)
expect(second.success).toBe(false)
expect(second.error).toContain('另一个会话')
expect(readArchive(first.outputPath!).sourceId).toBe('first-user')
expect(second.error).toContain('聊天集合不同')
expect(readArchive(first.outputPath!).conversations.map((item) => item.id)).toEqual([
'first-user'
])
})
it('uses message content as the stable fallback when the database supplies a random id', async () => {
@@ -414,13 +910,16 @@ describe('media export flow', () => {
const result = await runExport(
{
jobId: 'self-name',
userMd5: 'fixture-user',
name: '本人昵称',
targets: [
{
...target('fixture-user', '本人昵称'),
nameMap: { a969409112: 'a969409112' }
}
],
format: 'html',
outputName: 'self-name',
kinds: ['text'],
includeMedia: false,
nameMap: { a969409112: 'a969409112' }
includeMedia: false
},
win as never
)
@@ -433,8 +932,7 @@ describe('media export flow', () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
userMd5: 'fixture-user',
name: '增量昵称',
targets: [target('fixture-user', '增量昵称')],
format: 'html' as const,
outputName: 'incremental-self-name',
kinds: ['text'] as const,
+259 -4
View File
@@ -25,10 +25,16 @@ 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('font-size: 16px;')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
expect(html).toContain('scheduleWindowSlide')
@@ -68,7 +74,7 @@ describe('export media', () => {
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(EXPORT_PAGE_SIZE)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 240 / 筛选结果 500 / 全部 500'
'已显示 240 / 筛选 500 / 全部 500'
)
const list = dom.window.document.querySelector('#messages')!
expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('260')
@@ -90,8 +96,227 @@ describe('export media', () => {
search.value = 'needle'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
expect(dom.window.document.querySelector('#count')?.textContent).toBe('筛选结果 5 / 全部 500')
expect(dom.window.document.querySelector('#count')?.textContent).toContain('筛选 5')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.locate-all')).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()
})
it('does not match hidden sender ids when searching visible message text', () => {
const html = renderExportPage('搜索测试')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const messages: Message[] = [
{
id: 'hidden-sender-id-match',
from: 'user',
type: '普通文本',
datetime: '',
content: '这条消息不应命中',
name: 'Jamie',
senderId: 'wxid_fixture_member',
isSender: false,
createTime: 1_767_225_600
},
{
id: 'visible-content-match',
from: 'user',
type: '普通文本',
datetime: '',
content: 'https://example.com/xi',
name: 'Cherry',
senderId: 'wxid_fixture_self',
isSender: true,
createTime: 1_767_225_601
}
]
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '搜索测试',
exportedAt: '2026-08-05T00:00:00.000Z',
messages
}
})
dom.window.eval(inlineScriptOf(html))
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = 'xi'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.message')?.textContent).toContain(
'https://example.com/xi'
)
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 全部 2'
)
dom.window.close()
})
it('filters a v2 merged archive by conversation before search and month counts', () => {
const html = renderExportPage('合并档案')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 2,
name: '合并档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '聊天 A', type: 'user', messageCount: 2 },
{ id: 'beta', name: '聊天 B', type: 'group', messageCount: 1 }
],
messages: [
messageForArchive('alpha-1', 'alpha', '聊天 A', '共同关键词', 1_767_225_600),
messageForArchive('beta-1', 'beta', '聊天 B', '共同关键词', 1_769_904_000),
messageForArchive('alpha-2', 'alpha', '聊天 A', '仅 A 可见', 1_769_990_400)
]
}
})
dom.window.eval(inlineScriptOf(html))
const filter = dom.window.document.querySelector('#conversation-filter')!
const trigger = dom.window.document.querySelector('#conversation-trigger') as HTMLButtonElement
const menu = dom.window.document.querySelector('#conversation-menu') as HTMLElement
expect(filter.hasAttribute('hidden')).toBe(false)
expect(filter.parentElement?.classList.contains('archive-heading')).toBe(true)
expect((dom.window.document.querySelector('#archive-title') as HTMLElement).hidden).toBe(true)
expect(trigger.textContent).toContain('全部聊天')
expect(menu.querySelectorAll('[data-conversation-id]')).toHaveLength(3)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 3 / 筛选 3 / 全部 3'
)
trigger.click()
;(menu.querySelector('[data-conversation-id="alpha"]') as HTMLButtonElement).click()
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(2)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(0)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 2 / 筛选 2 / 当前聊天 2'
)
expect(dom.window.document.querySelectorAll('.timeline-month')).toHaveLength(2)
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '共同关键词'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 当前聊天 2'
)
dom.window.close()
})
it('locates every filtered message kind in all messages, including outside the latest window', () => {
const html = renderExportPage('定位消息')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const categorized: Message[] = [
{
...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1),
type: '普通文本'
},
{
...messageForArchive('target-media', 'fixture', '定位消息', '', 2),
type: '图片',
exportMediaType: 'image',
exportMediaUrl: 'media/target.jpg'
},
{
...messageForArchive('target-voice', 'fixture', '定位消息', '', 3),
type: '语音',
voiceDataUrl: 'voices/target.wav'
},
{
...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
type: '文件',
exportMediaType: 'file',
exportMediaUrl: 'files/target.pdf'
},
{
...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
type: '分享',
contentData: { type: 'share', typeVal: '5', title: '目标分享' }
},
{
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
from: 'system',
type: '系统消息',
contentData: { type: 'system', content: '目标系统消息' }
}
]
const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({
...messageForArchive(
`later-${index}`,
'fixture',
'定位消息',
`稍后消息-${index}`,
100 + index
),
type: '普通文本'
}))
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '定位消息',
messages: [...categorized, ...laterMessages]
}
})
dom.window.eval(inlineScriptOf(html))
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
filterButton.click()
const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置')
locateButton.click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(
dom.window.document.querySelector('.message.located')?.getAttribute('data-index')
).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind)))
}
const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement
textFilter.click()
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '目标文字'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('目标文字')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe(
'0'
)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字')
search.value = '稍后消息-137'
search.dispatchEvent(new dom.window.Event('input'))
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('稍后消息-137')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(search.value).toBe('')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(0)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain(
'稍后消息-137'
)
dom.window.close()
})
@@ -158,6 +383,36 @@ describe('export media', () => {
expect(html).toContain('aria-label="关闭图片预览"')
expect(html).toContain("closeButton.addEventListener('click', closeLightbox)")
expect(html).toContain('if (event.target === box) closeLightbox()')
expect(html).toContain("if (event.key === 'Escape') closeLightbox()")
expect(html).toContain("if (event.key === 'Escape')")
expect(html).toContain('closeLightbox()')
})
})
function messageForArchive(
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message {
return {
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
}
}
function kindOfFixture(message: Message): string {
if (message.exportMediaType === 'image') return 'media'
if (message.voiceDataUrl) return 'voice'
if (message.exportMediaType === 'file') return 'file'
if (message.contentData?.type === 'share') return 'share'
if (message.contentData?.type === 'system') return 'system'
return 'text'
}
+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>',
+78
View File
@@ -0,0 +1,78 @@
import crypto from 'crypto'
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({ userData: '' }))
vi.mock('electron', () => ({
app: { getPath: () => state.userData }
}))
const message = (createTime: number, serverId?: string): Message => ({
id: `message-${createTime}`,
from: 'user',
isSender: false,
type: '普通文本',
datetime: new Date(createTime * 1000).toISOString(),
content: String(createTime),
img: '',
name: 'Jamie',
sessionId: 'fixture-user',
localId: 1,
serverId,
createTime
})
describe('recall archive message identity', () => {
beforeAll(() => {
state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-'))
})
it('does not collapse messages whose local ids repeat across database shards', async () => {
const accountRoot = '/fixture/account'
const sessionMd5 = 'fixture-session'
const archiveDir = join(state.userData, 'recall-archive')
const archiveName = crypto
.createHash('sha1')
.update(`${process.platform}:${accountRoot}`)
.digest('hex')
.slice(0, 16)
mkdirSync(archiveDir, { recursive: true })
writeFileSync(
join(archiveDir, `${archiveName}.json`),
JSON.stringify({
version: 1,
accountRoot,
updatedAt: Date.now(),
sessions: {
[sessionMd5]: {
username: 'fixture-user',
updatedAt: Date.now(),
messages: [],
recalls: []
}
}
})
)
const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } =
await import('../../src/main/services/recall-archive-service')
configureRecallArchive(accountRoot)
const oldMessage = message(1_731_327_263)
const newMessage = message(1_765_000_000)
expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage))
expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([
oldMessage,
newMessage
])
})
it('prefers the globally unique server id when one is available', async () => {
const { messageIdentity } = await import('../../src/main/services/recall-archive-service')
expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024')
})
})
+23 -3
View File
@@ -5,9 +5,16 @@ import { dirname, join, resolve } from 'path'
import { afterAll, describe, expect, it } from 'vitest'
const nodeRequire = createRequire(import.meta.url)
const { validateFfmpegRuntime, validateSherpaRuntime, validateSilkWasmRuntime } = nodeRequire(
'../../scripts/after-pack.cjs'
) as {
const asar = nodeRequire('@electron/asar') as {
createPackage: (source: string, destination: string) => Promise<void>
}
const {
validateAsarRuntimeDependencies,
validateFfmpegRuntime,
validateSherpaRuntime,
validateSilkWasmRuntime
} = nodeRequire('../../scripts/after-pack.cjs') as {
validateAsarRuntimeDependencies: (runtimeResources: string) => void
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateSilkWasmRuntime: (runtimeResources: string) => void
@@ -33,6 +40,19 @@ describe('production runtime packaging', () => {
expect(config).toContain('node_modules/silk-wasm/**')
})
it('rejects an app archive with missing runtime dependencies', async () => {
const resources = join(root, 'asar-resources')
const source = join(root, 'asar-source')
mkdirSync(source, { recursive: true })
writeFileSync(join(source, 'package.json'), '{}')
mkdirSync(resources, { recursive: true })
await asar.createPackage(source, join(resources, 'app.asar'))
expect(() => validateAsarRuntimeDependencies(resources)).toThrow(
/Missing packaged runtime dependencies:.*@electron-toolkit\/utils/
)
})
it('requires and unpacks the bundled ffmpeg-static executable', () => {
const resources = join(root, 'ffmpeg-resources')
const ffmpegPath = join(
+140
View File
@@ -0,0 +1,140 @@
import { createHash } from 'crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { VideoAssetService } from '../../src/main/video-asset-service'
const temporaryDirectories: string[] = []
const box = (type: string, payload: Buffer): Buffer => {
const header = Buffer.alloc(8)
header.writeUInt32BE(header.length + payload.length, 0)
header.write(type, 4, 4, 'ascii')
return Buffer.concat([header, payload])
}
const mp4Fixture = (durationSeconds: number, marker: string): Buffer => {
const movieHeader = Buffer.alloc(20)
movieHeader.writeUInt32BE(1000, 12)
movieHeader.writeUInt32BE(Math.round(durationSeconds * 1000), 16)
return Buffer.concat([
box('ftyp', Buffer.from('isom0000', 'ascii')),
box('moov', box('mvhd', movieHeader)),
box('mdat', Buffer.from(marker, 'utf8'))
])
}
const jpegFixture = (width: number, height: number): Buffer =>
Buffer.from([
0xff,
0xd8,
0xff,
0xc0,
0x00,
0x11,
0x08,
(height >> 8) & 0xff,
height & 0xff,
(width >> 8) & 0xff,
width & 0xff,
0x03,
0x01,
0x11,
0x00,
0x02,
0x11,
0x00,
0x03,
0x11,
0x00,
0xff,
0xd9
])
const createService = (): {
accountRoot: string
service: VideoAssetService
} => {
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-video-assets-'))
temporaryDirectories.push(accountRoot)
return {
accountRoot,
service: new VideoAssetService({
getAccountRoot: () => accountRoot,
resolveVideoHardlink: () => null
} as never)
}
}
const monthTimestamp = (year: number, month: number): number =>
Math.floor(new Date(year, month - 1, 15, 12).getTime() / 1000)
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('VideoAssetService local fallback', () => {
it('finds a video by its content MD5 when the hardlink mapping is absent', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-07')
mkdirSync(month, { recursive: true })
const content = mp4Fixture(22, 'content-md5-match')
const filePath = join(month, `${'2'.repeat(32)}.mp4`)
writeFileSync(filePath, content)
const contentHash = createHash('md5').update(content).digest('hex')
const result = await service.resolve([contentHash], {
createTime: monthTimestamp(2026, 7)
})
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(filePath)
})
it('finds a uniquely matching video by month, thumbnail size, and duration', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2025-11')
mkdirSync(month, { recursive: true })
const stem = '66cecd68e095d87175fb5ed138de3cef'
const filePath = join(month, `${stem}.mp4`)
const posterPath = join(month, `${stem}_thumb.jpg`)
writeFileSync(filePath, mp4Fixture(68.441, 'metadata-match'))
writeFileSync(posterPath, jpegFixture(279, 630))
const result = await service.resolve(
['c92c54c8eae4471be9cc18396daf8015', '021e8a18a765ce14f4c54f40065db98e'],
{
createTime: monthTimestamp(2025, 11),
duration: 68,
width: 279,
height: 630
}
)
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(filePath)
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
})
it('does not guess when multiple files match the same metadata', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-02')
mkdirSync(month, { recursive: true })
for (const stem of ['a'.repeat(32), 'b'.repeat(32)]) {
writeFileSync(join(month, `${stem}.mp4`), mp4Fixture(12, stem))
writeFileSync(join(month, `${stem}_thumb.jpg`), jpegFixture(224, 398))
}
const result = await service.resolve(['c'.repeat(32)], {
createTime: monthTimestamp(2026, 2),
duration: 12,
width: 224,
height: 398
})
expect(result).toEqual({ success: false, error: '本地未找到该视频文件' })
})
})
+17 -2
View File
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
const message = (id: string, year: number): Wcdb4Message => ({
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
mesLocalID: id,
serverId: `server-${id}`,
serverId,
mesDes: 0,
messageType: '1',
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
@@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({
})
describe('WCDB message shard pagination', () => {
it('keeps messages whose local ids repeat across database shards', async () => {
const cursor = vi.fn(async () => [
message('1', 2024, 'server-2024'),
message('1', 2025, 'server-2025')
])
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
getMessagesByCursorAsync: cursor
}) as Wcdb4Client
const result = await client.getMessagesAsync('fixture@chatroom')
expect(result).toHaveLength(2)
expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025'])
})
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
const cursor = vi.fn(async () => [message('2025', 2025)])
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import { WechatDb, type WechatMessage } from '../../src/main/wechat-db'
describe('WechatDb normalized messages', () => {
it('keeps normalized identity fields when raw table columns conflict', async () => {
const normalized = {
mesLocalID: '1',
serverId: 'server-1',
mesDes: 0,
messageType: '1',
msgCreateTime: '1731327263',
msgContent: 'fixture',
sender: 'wxid_self',
senderNickname: 'Nanin',
raw: {
mesDes: 1,
sender: '',
senderNickname: ''
}
}
const client = { getMessagesAsync: vi.fn(async () => [normalized]) }
const db = Object.assign(Object.create(WechatDb.prototype), {
wcdb4Client: client,
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
ensureChatTableMapping: vi.fn()
}) as WechatDb
const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000)
const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000)
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
expect(messages[0]).toMatchObject({
mesDes: 0,
sender: 'wxid_self',
senderNickname: 'Nanin'
})
})
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
const row = (id: string, createTime: number): WechatMessage => ({
mesLocalID: id,
serverId: `server-${id}`,
mesDes: 0,
messageType: '1',
msgCreateTime: String(createTime),
msgContent: id,
raw: {}
})
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
const getMessagesAsync = vi.fn(async () => [
row('before-range', start - 1),
row('newest', start + 48 * 60 * 60),
shardBoundaryMessage,
{ ...shardBoundaryMessage },
row('after-range', end + 1)
])
const db = Object.assign(Object.create(WechatDb.prototype), {
wcdb4Client: { getMessagesAsync },
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
ensureChatTableMapping: vi.fn()
}) as WechatDb
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
expect(getMessagesAsync).toHaveBeenCalledOnce()
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
})
})