mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
Merge branch 'nanin/develop' into develop
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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&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 })
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '本地未找到该视频文件' })
|
||||
})
|
||||
})
|
||||
@@ -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)])
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user