mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 完善聊天导出与档案浏览
1. 修复数据量较大时,历史数据可能无法完整导出的问题 2. 优化分享 Tab 的信息展示,支持小程序、链接、地图等消息 3. 优化系统 Tab 的信息展示,支持红包、转账、拍一拍、撤回等消息 4. 修复默认进入时,时间轴不随消息自动定位的问题 5. 增加“定位到聊天位置”功能
This commit is contained in:
@@ -168,3 +168,360 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({
|
||||
page
|
||||
}) => {
|
||||
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')
|
||||
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)
|
||||
|
||||
await messages.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await expect(activeMonth).toHaveAttribute('data-month', '2006-01')
|
||||
} 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()
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
@@ -22,6 +24,8 @@ const state = vi.hoisted(() => ({
|
||||
videoPath: '',
|
||||
messages: [] as Message[],
|
||||
messagesByUser: {} as Record<string, Message[]>,
|
||||
exportReads: [] as string[],
|
||||
voiceLookups: [] as number[],
|
||||
videoLookups: [] as {
|
||||
createTime?: number
|
||||
duration?: number
|
||||
@@ -46,8 +50,15 @@ vi.mock('../../src/main/services/chat-service', () => ({
|
||||
listMessages: () => 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: () => ({}),
|
||||
getSelfAccountInfoAsync: async () => ({
|
||||
@@ -69,6 +80,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,
|
||||
@@ -187,6 +199,8 @@ describe('media export flow', () => {
|
||||
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'), '附件内容')
|
||||
@@ -272,7 +286,7 @@ describe('media export flow', () => {
|
||||
expect(html).toContain('<script src="data/messages.js"></script>')
|
||||
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
|
||||
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
|
||||
expect(file.exportMediaUrl).toMatch(/^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,
|
||||
@@ -288,6 +302,42 @@ describe('media export flow', () => {
|
||||
height: 630
|
||||
})
|
||||
expect(progress.length).toBeGreaterThan(0)
|
||||
expect(state.exportReads).toEqual(['fixture-user'])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
@@ -316,6 +366,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 }),
|
||||
@@ -344,9 +407,80 @@ 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 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 = {
|
||||
targets: [target('fixture-user', '资源复用会话')],
|
||||
format: 'html' as const,
|
||||
outputName: 'resource-reuse-fixture',
|
||||
kinds: ['voice', 'image', 'video', 'file'] as const,
|
||||
includeMedia: true,
|
||||
keepMissing: true
|
||||
}
|
||||
|
||||
const first = await runExport(
|
||||
{ ...request, jobId: 'resource-reuse-first', kinds: [...request.kinds] },
|
||||
win as never
|
||||
)
|
||||
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: 'resource-reuse-second', kinds: [...request.kinds] },
|
||||
win as never
|
||||
)
|
||||
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 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() } }
|
||||
@@ -440,10 +574,11 @@ describe('media export flow', () => {
|
||||
['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(2)
|
||||
expect(new Set(imagePaths).size).toBe(1)
|
||||
for (const imagePath of imagePaths) {
|
||||
expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true)
|
||||
}
|
||||
@@ -495,9 +630,15 @@ describe('media export flow', () => {
|
||||
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')
|
||||
expect(entries).toMatch(/zip-fixture\/avatars\/conversation_[0-9a-f]{16}\.png/)
|
||||
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
|
||||
|
||||
@@ -148,6 +148,97 @@ describe('export media', () => {
|
||||
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'))
|
||||
;(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('目标文字')
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('keeps relative media, file download, quote, and missing-media renderers', () => {
|
||||
const html = renderExportPage('媒体档案')
|
||||
|
||||
@@ -190,3 +281,12 @@ function messageForArchive(
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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