mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 完善聊天解析与导出体验
- 修复引用消息名称和图片布局 - 明确单会话图片测试日志范围 - 支持导出文件附件 - 完善图片批测、会话刷新和安全退出
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConversationSidebarHeader } from '../../src/renderer/src/components/conversation/ConversationSidebarHeader'
|
||||
|
||||
describe('ConversationSidebarHeader', () => {
|
||||
it('refreshes the conversation list from the archive header', async () => {
|
||||
const onRefresh = vi.fn()
|
||||
render(
|
||||
<ConversationSidebarHeader
|
||||
totalCount={1306}
|
||||
searchValue=""
|
||||
onSearchChange={vi.fn()}
|
||||
refreshing={false}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('1306 个会话')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: '刷新会话列表' }))
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables the refresh button while contacts are loading', () => {
|
||||
render(
|
||||
<ConversationSidebarHeader
|
||||
totalCount={12}
|
||||
searchValue="测试"
|
||||
onSearchChange={vi.fn()}
|
||||
refreshing
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '正在刷新会话列表' })).toBeDisabled()
|
||||
expect(screen.getByDisplayValue('测试')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ vi.mock('../../src/renderer/src/components/image-loader', () => ({
|
||||
}))
|
||||
|
||||
import { ImageBubble } from '../../src/renderer/src/components/ImageBubble'
|
||||
import { RichMessageBubble } from '../../src/renderer/src/components/RichMessageBubble'
|
||||
|
||||
const thumbnail =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||
@@ -50,4 +51,24 @@ describe('ImageBubble', () => {
|
||||
await userEvent.click(screen.getByText('加载失败'))
|
||||
expect(await screen.findByAltText('图片')).toBeVisible()
|
||||
})
|
||||
|
||||
it('places a quoted image on the line below the quoted sender', async () => {
|
||||
requestImage.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
|
||||
const { container } = render(
|
||||
<RichMessageBubble
|
||||
contentData={{
|
||||
type: 'quote',
|
||||
content: '回复内容',
|
||||
quotedContent: '[图片]',
|
||||
quotedSender: '测试群成员',
|
||||
quotedImageMd5: 'fixture-image'
|
||||
}}
|
||||
sessionId="fixture-session"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('测试群成员')).toBeInTheDocument()
|
||||
expect(container.querySelector('.quoted-message')).toHaveClass('quoted-message-image')
|
||||
expect(await screen.findByAltText('图片')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ImageTestSection } from '../../src/renderer/src/features/settings/image-decryption/ImageTestSection'
|
||||
import { initialImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/imageDecryptionReducer'
|
||||
import type { ImageBatchTestState } from '../../src/renderer/src/features/settings/image-decryption/types'
|
||||
|
||||
const emptyBatchTest: ImageBatchTestState = {
|
||||
running: false,
|
||||
stopRequested: false,
|
||||
elapsedMs: 0,
|
||||
items: []
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ImageTestSection', () => {
|
||||
it('keeps the file step successful when decryption fails and copies diagnostics', async () => {
|
||||
const onCopyLog = vi.fn()
|
||||
render(
|
||||
<ImageTestSection
|
||||
state={{
|
||||
...initialImageDecryptionState,
|
||||
phase: 'test-failed',
|
||||
selectedUserMd5: 'conversation-md5',
|
||||
contacts: [
|
||||
{
|
||||
md5: 'conversation-md5',
|
||||
m_nsUsrName: 'fixture-user',
|
||||
m_nsNickName: '测试会话',
|
||||
type: 'user'
|
||||
}
|
||||
],
|
||||
testResult: {
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '图片密钥与当前账号不匹配',
|
||||
fileFound: true,
|
||||
decrypted: false,
|
||||
readable: false,
|
||||
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)'
|
||||
}
|
||||
}}
|
||||
batchTest={emptyBatchTest}
|
||||
disabled={false}
|
||||
canSave={false}
|
||||
onSelect={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
onBatchTest={vi.fn()}
|
||||
onStopBatchTest={vi.fn()}
|
||||
onCopyLog={onCopyLog}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('找到图片文件').closest('li')).toHaveClass('image-step-ok')
|
||||
expect(screen.getByText('解密成功').closest('li')).toHaveClass('image-step-fail')
|
||||
expect(screen.getByText('图片可以读取').closest('li')).toHaveClass('image-step-skipped')
|
||||
expect(screen.getByText('仅当前会话的单次测试日志')).toHaveAttribute(
|
||||
'title',
|
||||
expect.stringContaining('不包含批量测试结果')
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
|
||||
expect(onCopyLog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables log copying before a test result exists', () => {
|
||||
render(
|
||||
<ImageTestSection
|
||||
state={initialImageDecryptionState}
|
||||
batchTest={emptyBatchTest}
|
||||
disabled={false}
|
||||
canSave={false}
|
||||
onSelect={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
onBatchTest={vi.fn()}
|
||||
onStopBatchTest={vi.fn()}
|
||||
onCopyLog={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '复制日志' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('filters groups and contacts before starting a batch test', async () => {
|
||||
const onBatchTest = vi.fn()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
render(
|
||||
<ImageTestSection
|
||||
state={{
|
||||
...initialImageDecryptionState,
|
||||
contacts: [
|
||||
{
|
||||
md5: 'group-md5',
|
||||
m_nsUsrName: 'group@chatroom',
|
||||
m_nsNickName: '测试群聊',
|
||||
type: 'group'
|
||||
},
|
||||
{
|
||||
md5: 'user-md5',
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
m_nsNickName: '测试联系人',
|
||||
type: 'user'
|
||||
}
|
||||
]
|
||||
}}
|
||||
batchTest={emptyBatchTest}
|
||||
disabled={false}
|
||||
canSave={false}
|
||||
onSelect={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
onBatchTest={onBatchTest}
|
||||
onStopBatchTest={vi.fn()}
|
||||
onCopyLog={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '群聊 1' }))
|
||||
expect(screen.getByRole('option', { name: '测试群聊' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '测试联系人' })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '测试筛选结果(1)' }))
|
||||
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('测试 1 个会话'))
|
||||
expect(onBatchTest).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ md5: 'group-md5', type: 'group' })
|
||||
])
|
||||
})
|
||||
|
||||
it('shows per-conversation batch result and elapsed time', () => {
|
||||
render(
|
||||
<ImageTestSection
|
||||
state={initialImageDecryptionState}
|
||||
batchTest={{
|
||||
running: false,
|
||||
stopRequested: false,
|
||||
elapsedMs: 1320,
|
||||
items: [
|
||||
{
|
||||
contact: {
|
||||
md5: 'success-md5',
|
||||
m_nsUsrName: 'success@chatroom',
|
||||
m_nsNickName: '成功群聊',
|
||||
type: 'group'
|
||||
},
|
||||
status: 'success',
|
||||
elapsedMs: 120
|
||||
},
|
||||
{
|
||||
contact: {
|
||||
md5: 'no-image-md5',
|
||||
m_nsUsrName: 'wxid_no_image',
|
||||
m_nsNickName: '没有图片的联系人',
|
||||
type: 'user'
|
||||
},
|
||||
status: 'no-image',
|
||||
elapsedMs: 88,
|
||||
error: '最近 300 条消息中没有图片'
|
||||
}
|
||||
]
|
||||
}}
|
||||
disabled={false}
|
||||
canSave={false}
|
||||
onSelect={vi.fn()}
|
||||
onTest={vi.fn()}
|
||||
onBatchTest={vi.fn()}
|
||||
onStopBatchTest={vi.fn()}
|
||||
onCopyLog={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('成功群聊')).toBeInTheDocument()
|
||||
expect(screen.getByText('没有图片的联系人')).toBeInTheDocument()
|
||||
expect(screen.getByText('成功 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('无图片 1')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2\/2 · 已耗时 1\.3 秒/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -327,7 +327,8 @@ handle('image:testConfig', () => ({
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable: true
|
||||
readable: true,
|
||||
diagnosticLog: 'WechatExplorer 图片解析测试日志(已脱敏)\n测试结果:成功(SUCCESS)'
|
||||
}))
|
||||
handle('image:clearConfig', () => ({ success: true }))
|
||||
handle('image:getDecoderStatus', () => ({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { dirname, join } from 'path'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
documents: '',
|
||||
accountRoot: '',
|
||||
videoPath: '',
|
||||
messages: [] as Message[],
|
||||
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
|
||||
@@ -18,7 +19,9 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
listMessages: () => structuredClone(state.messages),
|
||||
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
|
||||
getChatDb: () => ({
|
||||
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
|
||||
}),
|
||||
getContactAvatars: () => ({})
|
||||
}))
|
||||
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
||||
@@ -92,12 +95,16 @@ const message = (overrides: Partial<Message>): Message => ({
|
||||
describe('media export flow', () => {
|
||||
beforeEach(() => {
|
||||
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
|
||||
state.accountRoot = join(state.documents, 'fixture-account')
|
||||
state.videoPath = join(state.documents, 'fixture.mp4')
|
||||
writeFileSync(
|
||||
state.videoPath,
|
||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||
)
|
||||
state.imageLookups = []
|
||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||
mkdirSync(fileMonth, { recursive: true })
|
||||
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-ok',
|
||||
@@ -123,6 +130,11 @@ describe('media export flow', () => {
|
||||
id: 'video',
|
||||
type: '视频',
|
||||
contentData: { type: 'video', md5: 'b'.repeat(32) }
|
||||
}),
|
||||
message({
|
||||
id: 'file',
|
||||
type: '文件',
|
||||
contentData: { type: 'share', typeVal: '6', title: '测试附件.txt', url: '' }
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -143,7 +155,7 @@ describe('media export flow', () => {
|
||||
name: '脱敏会话',
|
||||
format: 'html',
|
||||
outputName: 'fixture',
|
||||
kinds: ['voice', 'image', 'video'],
|
||||
kinds: ['voice', 'image', 'video', 'file'],
|
||||
includeMedia: true,
|
||||
preferOriginal: true,
|
||||
fallbackThumbnail: true,
|
||||
@@ -161,8 +173,10 @@ describe('media export flow', () => {
|
||||
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
|
||||
'ftyp'
|
||||
)
|
||||
expect(readFileSync(join(outputDir, 'media/file_5_测试附件.txt'), 'utf8')).toBe('附件内容')
|
||||
expect(html).toContain('src="voices/voice_1_1.wav"')
|
||||
expect(html).toContain('src="media/video_4.mp4"')
|
||||
expect(html).toContain('href="media/file_5_测试附件.txt" download')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(state.imageLookups[0]).toMatchObject({
|
||||
allowThumbnail: false,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WechatDb } from '../../src/main/wechat-db'
|
||||
import { listContactsAsync, setChatDb } from '../../src/main/services/chat-service'
|
||||
import {
|
||||
closeChatDbForQuit,
|
||||
isReady,
|
||||
listContactsAsync,
|
||||
setChatDb
|
||||
} from '../../src/main/services/chat-service'
|
||||
|
||||
describe('chat service contacts', () => {
|
||||
afterEach(() => setChatDb(null))
|
||||
@@ -36,4 +41,30 @@ describe('chat service contacts', () => {
|
||||
})
|
||||
expect(contacts[0]?.m_nsNickName).toBe('测试群聊')
|
||||
})
|
||||
|
||||
it('detaches the database immediately and awaits native cleanup on quit', async () => {
|
||||
let finishClose: ((value: boolean) => void) | undefined
|
||||
const closeAsync = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishClose = resolve
|
||||
})
|
||||
)
|
||||
const fakeDb = {
|
||||
close: vi.fn(),
|
||||
closeAsync
|
||||
} as unknown as WechatDb
|
||||
|
||||
setChatDb(fakeDb)
|
||||
const closing = closeChatDbForQuit()
|
||||
|
||||
expect(isReady()).toBe(false)
|
||||
expect(closeAsync).toHaveBeenCalledOnce()
|
||||
finishClose?.(true)
|
||||
await expect(closing).resolves.toBe(true)
|
||||
|
||||
const lateDb = { close: vi.fn() } as unknown as WechatDb
|
||||
expect(setChatDb(lateDb)).toBe(false)
|
||||
expect(lateDb.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,14 @@ describe('export media', () => {
|
||||
id: 'missing',
|
||||
type: '语音',
|
||||
exportMediaError: '语音文件缺失:本地未找到语音数据'
|
||||
}),
|
||||
baseMessage({
|
||||
id: 'file',
|
||||
type: '文件',
|
||||
contentData: { type: 'share', typeVal: '6', title: '示例附件.zip', url: '' },
|
||||
exportMediaType: 'file',
|
||||
exportMediaName: '示例附件.zip',
|
||||
exportMediaUrl: 'media/file_4_示例附件.zip'
|
||||
})
|
||||
])
|
||||
|
||||
@@ -46,6 +54,7 @@ describe('export media', () => {
|
||||
)
|
||||
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(html).toContain('class="file-attachment" href="media/file_4_示例附件.zip" download')
|
||||
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { FileAssetService } from '../../src/main/file-asset-service'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('file asset service', () => {
|
||||
it('prefers the message month and supports duplicate download suffixes', () => {
|
||||
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-file-asset-'))
|
||||
roots.push(accountRoot)
|
||||
const august = join(accountRoot, 'msg', 'file', '2026-08')
|
||||
const july = join(accountRoot, 'msg', 'file', '2026-07')
|
||||
mkdirSync(august, { recursive: true })
|
||||
mkdirSync(july, { recursive: true })
|
||||
writeFileSync(join(july, '产品说明.txt'), 'old')
|
||||
writeFileSync(join(august, '产品说明(1).txt'), 'current')
|
||||
|
||||
const service = new FileAssetService({ getAccountRoot: () => accountRoot })
|
||||
const result = service.resolve('产品说明.txt', new Date(2026, 7, 4).getTime() / 1000)
|
||||
|
||||
expect(result).toMatchObject({ success: true, fileName: '产品说明(1).txt' })
|
||||
expect(result.filePath).toBe(realpathSync(join(august, '产品说明(1).txt')))
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import { ImageDecryptService } from '../../src/main/image-decrypt-service'
|
||||
|
||||
const aesKey = '0123456789abcdef'
|
||||
const xorKey = 0x40
|
||||
const originalResourcesPath = process.resourcesPath
|
||||
|
||||
function writeV2Dat(file: string): Buffer {
|
||||
const aesPlain = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
||||
@@ -35,22 +36,95 @@ function writeV2Dat(file: string): Buffer {
|
||||
}
|
||||
|
||||
describe('DAT image decryption', () => {
|
||||
beforeAll(() => mkdirSync(root, { recursive: true }))
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
beforeAll(() => {
|
||||
mkdirSync(root, { recursive: true })
|
||||
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: root })
|
||||
})
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
configurable: true,
|
||||
value: originalResourcesPath
|
||||
})
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('decrypts a synthetic V2 AES/raw/XOR fixture', () => {
|
||||
const file = join(root, 'fixture.dat')
|
||||
const expected = writeV2Dat(file)
|
||||
expect(new ImageDecryptService('0x40', aesKey).decryptImage(file)).toEqual(expected)
|
||||
const service = new ImageDecryptService('0x40', aesKey)
|
||||
expect(service.decryptImage(file)).toEqual(expected)
|
||||
expect(service.decryptImageToBase64(file)).toMatch(/^data:image\/png;base64,/)
|
||||
expect(service.getLastDecodeDiagnostic()).toMatchObject({
|
||||
code: 'SUCCESS',
|
||||
datVersion: 2,
|
||||
imageFormat: 'PNG'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects the wrong AES key and unsupported legacy signatures accurately', () => {
|
||||
const file = join(root, 'fixture.dat')
|
||||
writeV2Dat(file)
|
||||
expect(new ImageDecryptService('0x40', 'fedcba9876543210').decryptImage(file)).toBeNull()
|
||||
const wrongKeyService = new ImageDecryptService('0x40', 'fedcba9876543210')
|
||||
expect(wrongKeyService.decryptImage(file)).toBeNull()
|
||||
expect(wrongKeyService.getLastDecodeDiagnostic()).toMatchObject({
|
||||
code: 'AES_DECRYPT_FAILED',
|
||||
datVersion: 2
|
||||
})
|
||||
|
||||
const legacy = join(root, 'legacy.dat')
|
||||
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
|
||||
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull()
|
||||
writeFileSync(legacy, Buffer.from([0x12, 0x34, 0x56, 0x78]))
|
||||
const legacyService = new ImageDecryptService('0x40', aesKey)
|
||||
expect(legacyService.decryptImage(legacy)).toBeNull()
|
||||
expect(legacyService.getLastDecodeDiagnostic()).toMatchObject({
|
||||
code: 'UNSUPPORTED_DAT_VERSION',
|
||||
datVersion: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('finds modern _M variants and reads plain images stored with a DAT extension', async () => {
|
||||
const accountRoot = join(root, 'modern-account')
|
||||
const sessionId = '77705c31c50e8a4242a9d527fe9433de'
|
||||
const imageDirectory = join(accountRoot, 'msg', 'attach', sessionId, '2025-10', 'Img')
|
||||
mkdirSync(imageDirectory, { recursive: true })
|
||||
|
||||
const imageBase = '9718e38ad90f57f9e833d17ff2373abd'
|
||||
const mediumFile = join(imageDirectory, `${imageBase}_M.dat`)
|
||||
writeFileSync(mediumFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
|
||||
|
||||
const service = new ImageDecryptService('0x40', aesKey)
|
||||
await expect(
|
||||
service.findImageFileAsync(undefined, imageBase, {
|
||||
allowThumbnail: false,
|
||||
accountDir: accountRoot,
|
||||
sessionId
|
||||
})
|
||||
).resolves.toBe(mediumFile)
|
||||
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
|
||||
expect(service.getLastDecodeDiagnostic()).toMatchObject({
|
||||
code: 'DIRECT_IMAGE',
|
||||
imageFormat: 'PNG'
|
||||
})
|
||||
await expect(service.decryptImageToBase64WithFallbackAsync(mediumFile, true)).resolves.toEqual(
|
||||
expect.objectContaining({ filePath: mediumFile })
|
||||
)
|
||||
|
||||
const thumbnailBase = '37a9000000000000000000000000ceaa'
|
||||
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
|
||||
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
|
||||
expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
|
||||
await expect(
|
||||
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
|
||||
allowThumbnail: false,
|
||||
accountDir: accountRoot,
|
||||
sessionId
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
|
||||
allowThumbnail: true,
|
||||
accountDir: accountRoot,
|
||||
sessionId
|
||||
})
|
||||
).resolves.toBe(thumbnailFile)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-image-diagnostic-'))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => root,
|
||||
getVersion: () => '2.1.7-test'
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/image-decrypt-service', () => ({
|
||||
ImageDecryptService: class {},
|
||||
inspectImageDecoderStatus: vi.fn()
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({}))
|
||||
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
||||
validateImageKeyRequest: vi.fn()
|
||||
}))
|
||||
vi.mock('../../src/main/services/wechat-process-status', () => ({
|
||||
isWechatRunning: vi.fn()
|
||||
}))
|
||||
|
||||
import { buildImageTestDiagnosticLog } from '../../src/main/services/image-decryption-status-service'
|
||||
|
||||
describe('image decryption diagnostic log', () => {
|
||||
beforeAll(() => mkdirSync(join(root, 'private-account', 'msg', 'attach'), { recursive: true }))
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
it('keeps useful failure details without exposing keys or absolute paths', () => {
|
||||
const resourceRoot = join(root, 'private-account')
|
||||
const aesKey = '0123456789abcdef'
|
||||
const imageMd5 = '1234567890abcdef1234567890abcdef'
|
||||
const log = buildImageTestDiagnosticLog({
|
||||
request: {
|
||||
userMd5: 'conversation-secret',
|
||||
resourceRoot,
|
||||
xorKey: '0x40',
|
||||
aesKey
|
||||
},
|
||||
result: {
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '图片密钥与当前账号不匹配,或图片文件已损坏',
|
||||
fileFound: true,
|
||||
decrypted: false,
|
||||
readable: false,
|
||||
isThumbnail: false
|
||||
},
|
||||
startedAt: Date.now() - 25,
|
||||
testedImage: {
|
||||
md5: imageMd5,
|
||||
datName: `${imageMd5}_h.dat`,
|
||||
sessionId: 'wxid_private_session',
|
||||
selection: '自动测试样本'
|
||||
},
|
||||
filePath: join(
|
||||
resourceRoot,
|
||||
'msg',
|
||||
'attach',
|
||||
imageMd5,
|
||||
'2026-08',
|
||||
'Img',
|
||||
`${imageMd5}_h.dat`
|
||||
),
|
||||
decodeDiagnostic: {
|
||||
code: 'AES_DECRYPT_FAILED',
|
||||
detail: 'AES 解密校验失败,密钥可能与当前账号不匹配',
|
||||
datVersion: 2,
|
||||
fileSize: 2048
|
||||
}
|
||||
})
|
||||
|
||||
expect(log).toContain('AES_DECRYPT_FAILED')
|
||||
expect(log).toContain('WeChat 4.0 V2')
|
||||
expect(log).toContain('内容未记录')
|
||||
expect(log).not.toContain(aesKey)
|
||||
expect(log).not.toContain(resourceRoot)
|
||||
expect(log).not.toContain(imageMd5)
|
||||
expect(log).not.toContain('conversation-secret')
|
||||
expect(log).not.toContain('wxid_private_session')
|
||||
})
|
||||
|
||||
it('describes plain images with a DAT extension without reporting an unsupported protocol', () => {
|
||||
const resourceRoot = join(root, 'private-account')
|
||||
const filePath = join(resourceRoot, 'msg', 'attach', 'plain-image_M.dat')
|
||||
writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]))
|
||||
const log = buildImageTestDiagnosticLog({
|
||||
request: {
|
||||
userMd5: 'conversation-secret',
|
||||
resourceRoot,
|
||||
xorKey: '0x40',
|
||||
aesKey: '0123456789abcdef'
|
||||
},
|
||||
result: {
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable: true,
|
||||
isThumbnail: false
|
||||
},
|
||||
startedAt: Date.now() - 10,
|
||||
filePath,
|
||||
decodeDiagnostic: {
|
||||
code: 'DIRECT_IMAGE',
|
||||
detail: 'DAT 文件内容是可直接读取的图片',
|
||||
fileSize: 8,
|
||||
imageFormat: 'PNG'
|
||||
}
|
||||
})
|
||||
|
||||
expect(log).toContain('DAT 协议:明文图片(无需 DAT 解密)')
|
||||
expect(log).not.toContain('不受支持/旧版格式')
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,19 @@ describe('message parser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
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>',
|
||||
49
|
||||
)
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'quote',
|
||||
quotedSender: 'wxid_fixture_member',
|
||||
quotedContent: '被引用内容'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an explicit unknown type for unsupported messages', () => {
|
||||
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
|
||||
type: 'unknown',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
import { enrichQuotedMessages } from '../../src/renderer/src/utils/quoted-messages'
|
||||
|
||||
const message = (overrides: Partial<Message>): Message => ({
|
||||
id: 'fixture',
|
||||
from: 'user',
|
||||
type: '普通文本',
|
||||
datetime: '2026-08-04 12:00:00',
|
||||
content: '',
|
||||
isSender: false,
|
||||
createTime: 1_785_816_000,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('quoted message enrichment', () => {
|
||||
it('maps an internal quoted sender id to the loaded group member name', () => {
|
||||
const quoted = message({
|
||||
id: 'quote',
|
||||
contentData: {
|
||||
type: 'quote',
|
||||
content: '回复',
|
||||
quotedContent: '[图片]',
|
||||
quotedSender: 'wxid_fixture_member',
|
||||
quotedImageMd5: 'a'.repeat(32)
|
||||
}
|
||||
})
|
||||
|
||||
const [result] = enrichQuotedMessages([quoted], [quoted], (senderId) =>
|
||||
senderId === 'wxid_fixture_member' ? '测试群成员' : undefined
|
||||
)
|
||||
|
||||
expect(result.contentData).toMatchObject({ quotedSender: '测试群成员' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Wcdb4Client } from '../../src/main/wcdb4-client'
|
||||
|
||||
function setPrivate(target: object, key: string, value: unknown): void {
|
||||
Reflect.set(target, key, value)
|
||||
}
|
||||
|
||||
describe('Wcdb4Client shutdown', () => {
|
||||
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const shutdown = vi.fn(() => 0)
|
||||
const inFlight = new Set<Promise<unknown>>()
|
||||
let finishCall: (() => void) | undefined
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finishCall = resolve
|
||||
})
|
||||
inFlight.add(pending)
|
||||
void pending.then(() => inFlight.delete(pending))
|
||||
|
||||
setPrivate(client, 'nativeCallsInFlight', inFlight)
|
||||
setPrivate(client, 'handle', 1)
|
||||
setPrivate(client, 'wcdbShutdown', shutdown)
|
||||
setPrivate(client, 'monitorStarted', false)
|
||||
setPrivate(client, 'displayNameCache', new Map())
|
||||
setPrivate(client, 'avatarCache', new Map())
|
||||
setPrivate(client, 'sessionStatusCache', new Map())
|
||||
setPrivate(client, 'groupNicknameCache', new Map())
|
||||
|
||||
const closing = client.closeAsync(1_000)
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
|
||||
finishCall?.()
|
||||
await expect(closing).resolves.toBe(true)
|
||||
if (process.platform === 'win32') {
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(shutdown).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user