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