mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导 - 支持按账号安全保存数据库密钥及快速切换账号 - 完善 WCDB 历史消息分片读取和分页状态提示 - 支持导出图片、视频和语音,提供原图优先及缩略图回退 - 更新安装指引、兼容版本说明和相关自动化测试
This commit is contained in:
@@ -14,6 +14,35 @@ function renderPage(
|
||||
dbRoot: '',
|
||||
showDbKey: false,
|
||||
isFetching: false,
|
||||
isConnecting: false,
|
||||
guideStep: 1 as const,
|
||||
environment: {
|
||||
platform: 'win32',
|
||||
osVersion: 'Windows fixture',
|
||||
appVersion: 'v2.1.6',
|
||||
wechatVersion: '4.1.9.57',
|
||||
dataStructureVersion: '微信 4.x(WCDB)',
|
||||
dataDirectoryDetected: true,
|
||||
diagnosticSummary: 'WechatExplorer: v2.1.6',
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: false,
|
||||
dbConnected: false,
|
||||
encryptionAvailable: true
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-a',
|
||||
accountRoot: 'C:\\fixture\\account-a',
|
||||
directoryName: 'account-a',
|
||||
nickname: '脱敏账号 A',
|
||||
wxid: 'wxid_fixture_a',
|
||||
hasSavedDbKey: true,
|
||||
loginStatus: 'unknown' as const,
|
||||
selectedByInput: true
|
||||
}
|
||||
],
|
||||
selectedAccountId: 'account-a',
|
||||
status: '',
|
||||
statusKind: 'normal' as const,
|
||||
showMacKeyFaq: false,
|
||||
@@ -21,8 +50,16 @@ function renderPage(
|
||||
onModeChange: vi.fn(),
|
||||
onDbKeyChange: vi.fn(),
|
||||
onDbRootChange: vi.fn(),
|
||||
onSelectAccount: vi.fn(),
|
||||
onSelectDbRoot: vi.fn(),
|
||||
onToggleDbKey: vi.fn(),
|
||||
onAutoGetKey: vi.fn(),
|
||||
onRefreshEnvironment: vi.fn(),
|
||||
onGuideNext: vi.fn(),
|
||||
onGuideBack: vi.fn(),
|
||||
onGuideCancel: vi.fn(),
|
||||
onValidateConnection: vi.fn(),
|
||||
onCopyDiagnostics: vi.fn(),
|
||||
onManualConnect: vi.fn(),
|
||||
onPasteKey: vi.fn(),
|
||||
onClearKey: vi.fn(),
|
||||
@@ -52,4 +89,48 @@ describe('DatabaseConnectionPage', () => {
|
||||
expect(onManualConnect).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('restores directory editing and selection after a failed connection', async () => {
|
||||
const onDbRootChange = vi.fn()
|
||||
const onSelectDbRoot = vi.fn()
|
||||
renderPage({
|
||||
dbKey: 'b'.repeat(64),
|
||||
dbRoot: 'Z:\\missing-wechat-data',
|
||||
status: '微信数据目录不存在,请重新选择目录',
|
||||
statusKind: 'error',
|
||||
onDbRootChange,
|
||||
onSelectDbRoot
|
||||
})
|
||||
|
||||
await userEvent.clear(screen.getByLabelText('微信数据目录'))
|
||||
await userEvent.type(screen.getByLabelText('微信数据目录'), 'C:\\fixture-account')
|
||||
await userEvent.click(screen.getByRole('button', { name: '选择目录' }))
|
||||
|
||||
expect(onDbRootChange).toHaveBeenCalled()
|
||||
expect(onSelectDbRoot).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('supports forward, back, cancel and safe diagnostic actions in onboarding', async () => {
|
||||
const onGuideNext = vi.fn()
|
||||
const onCopyDiagnostics = vi.fn()
|
||||
const { rerender, props } = renderPage({
|
||||
mode: 'automatic',
|
||||
guideStep: 1,
|
||||
onGuideNext,
|
||||
onCopyDiagnostics
|
||||
})
|
||||
|
||||
expect(screen.getByText('4.1.9.57')).toBeVisible()
|
||||
expect(screen.getByText('微信 4.x(WCDB)')).toBeVisible()
|
||||
await userEvent.click(screen.getByRole('button', { name: '复制脱敏诊断摘要' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: '检查完成,继续' }))
|
||||
expect(onCopyDiagnostics).toHaveBeenCalledOnce()
|
||||
expect(onGuideNext).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />)
|
||||
expect(screen.getByRole('button', { name: '我已准备好' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '返回上一步' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '取消并重新检查' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 81 KiB |
+44
-7
@@ -23,13 +23,8 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app
|
||||
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
|
||||
const keyInput = fixture.page.getByLabel('数据库密钥')
|
||||
await keyInput.fill('b'.repeat(64))
|
||||
const errorDialog = fixture.page.waitForEvent('dialog')
|
||||
await fixture.page
|
||||
.getByRole('button', { name: '连接数据库' })
|
||||
.evaluate((element: HTMLButtonElement) => element.click())
|
||||
const dialog = await errorDialog
|
||||
expect(dialog.message()).toContain('数据库密钥无效')
|
||||
await dialog.dismiss()
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
await expect(fixture.page.getByText('数据库密钥无效')).toBeVisible()
|
||||
|
||||
await expect(keyInput).toBeVisible()
|
||||
await keyInput.fill('a'.repeat(64))
|
||||
@@ -40,6 +35,46 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app
|
||||
}
|
||||
})
|
||||
|
||||
test('P0-01 an invalid directory can be corrected and retried without restarting', async () => {
|
||||
test.skip(process.platform !== 'win32', 'Manual database directory editing is Windows-only')
|
||||
const fixture = await launchTestApp({ mode: 'disconnected' })
|
||||
try {
|
||||
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
|
||||
await fixture.page.getByLabel('数据库密钥').fill('a'.repeat(64))
|
||||
await fixture.page.getByLabel('微信数据目录').fill('Z:\\missing-wechat-data')
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
|
||||
await expect(fixture.page.getByText('微信数据目录不存在,请重新选择目录')).toBeVisible()
|
||||
await expect(fixture.page.getByLabel('微信数据目录')).toBeEditable()
|
||||
await expect(fixture.page.getByRole('button', { name: '选择目录' })).toBeEnabled()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '选择目录' }).click()
|
||||
await expect(fixture.page.getByLabel('微信数据目录')).toHaveValue('fixture-account')
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('P2-01 P2-02 guided connection exposes safe diagnostics and completes all stages', async () => {
|
||||
const fixture = await launchTestApp({ mode: 'disconnected' })
|
||||
try {
|
||||
await expect(fixture.page.getByText('4.1.9.57')).toBeVisible()
|
||||
await expect(fixture.page.getByText('微信 4.x(WCDB)')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '复制脱敏诊断摘要' })).toBeEnabled()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '检查完成,继续' }).click()
|
||||
await fixture.page.getByRole('button', { name: '我已准备好' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '微信已登录,验证连接' })).toBeEnabled()
|
||||
await fixture.page.getByRole('button', { name: '微信已登录,验证连接' }).click()
|
||||
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('KEY-03 changing one key does not invalidate archive data or unrelated settings', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
try {
|
||||
@@ -188,6 +223,7 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
||||
await expect(generate).toBeEnabled()
|
||||
await generate.click()
|
||||
@@ -218,6 +254,7 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
|
||||
|
||||
@@ -102,7 +102,7 @@ handle('key:getSavedDbKey', () => ({
|
||||
saved: Boolean(savedKey),
|
||||
encryptionAvailable: true
|
||||
}))
|
||||
handle('key:saveDbKey', (key) => {
|
||||
handle('key:saveDbKey', (_accountRoot, key) => {
|
||||
savedKey = String(key || '')
|
||||
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
|
||||
})
|
||||
@@ -112,6 +112,12 @@ handle('key:clearSavedDbKey', () => {
|
||||
})
|
||||
handle('key:getEnvironment', () => ({
|
||||
platform: process.platform,
|
||||
osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture',
|
||||
appVersion: 'v2.1.6',
|
||||
wechatVersion: '4.1.9.57',
|
||||
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到',
|
||||
dataDirectoryDetected: settings.dbRoot === 'fixture-account',
|
||||
diagnosticSummary: 'WechatExplorer: v2.1.6\n数据目录: 已检测到',
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: connected,
|
||||
@@ -128,12 +134,22 @@ handle('key:autoGetImageKey', () => ({
|
||||
verified: true
|
||||
}))
|
||||
|
||||
handle('db:init', (key) => {
|
||||
handle('db:init', (key, accountRoot) => {
|
||||
if (settings.dbRoot === 'Z:\\missing-wechat-data') {
|
||||
connected = false
|
||||
return {
|
||||
success: false,
|
||||
code: 'ROOT_UNAVAILABLE',
|
||||
error: '微信数据目录不存在,请重新选择目录',
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
if (key !== VALID_KEY) {
|
||||
connected = false
|
||||
return { success: false, error: '数据库密钥无效', monitoring: false }
|
||||
}
|
||||
connected = true
|
||||
settings.dbRoot = accountRoot || settings.dbRoot
|
||||
return { success: true, monitoring: true }
|
||||
})
|
||||
handle('db:testConnection', (key) =>
|
||||
@@ -339,6 +355,29 @@ handle('image:getStatus', () => ({
|
||||
])
|
||||
)
|
||||
}))
|
||||
handle('settings:selectDbRoot', () => ({ canceled: false, path: 'fixture-account' }))
|
||||
handle('accounts:discover', (inputPath) =>
|
||||
inputPath === 'Z:\\missing-wechat-data'
|
||||
? { success: false, accounts: [], error: '微信数据目录不存在,请重新选择目录' }
|
||||
: {
|
||||
success: true,
|
||||
inputKind: 'account',
|
||||
preselectedAccountId: 'fixture-account-id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'fixture-account-id',
|
||||
accountRoot: inputPath || 'fixture-account',
|
||||
directoryName: 'fixture-account',
|
||||
wxid: fixture.self.wxid,
|
||||
nickname: fixture.self.nickname,
|
||||
avatar: fixture.self.avatar,
|
||||
hasSavedDbKey: Boolean(savedKey),
|
||||
loginStatus: connected ? 'current' : 'unknown',
|
||||
selectedByInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
|
||||
handle('agent-hub:getLogs', () => [])
|
||||
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
|
||||
@@ -347,7 +386,6 @@ for (const channel of [
|
||||
'export:start',
|
||||
'export:cancel',
|
||||
'export:reveal',
|
||||
'settings:selectDbRoot',
|
||||
'settings:openAccountRoot',
|
||||
'db:reopenWithRoot',
|
||||
'api:skillStatus',
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { dirname, join } from 'path'
|
||||
import { 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: '',
|
||||
videoPath: '',
|
||||
messages: [] as Message[],
|
||||
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => state.documents },
|
||||
shell: { showItemInFolder: vi.fn() },
|
||||
BrowserWindow: class {}
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
listMessages: () => structuredClone(state.messages),
|
||||
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
|
||||
getContactAvatars: () => ({})
|
||||
}))
|
||||
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
||||
ImageKeyConfigService: class {
|
||||
getConfig(): { aesKey: string; xorKey: string } {
|
||||
return { aesKey: '0123456789abcdef', xorKey: '0x40' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/voice-service', () => ({
|
||||
VoiceService: class {
|
||||
async resolveVoice(
|
||||
_sessionId: string,
|
||||
localId: number
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
return localId === 1
|
||||
? {
|
||||
success: true,
|
||||
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||
}
|
||||
: { success: false, error: '本地未找到语音数据' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/image-decrypt-service', () => ({
|
||||
ImageDecryptService: class {
|
||||
findImageFile(
|
||||
_md5: string,
|
||||
_datName: string,
|
||||
options: { allowThumbnail?: boolean; preferThumbnail?: boolean }
|
||||
): string {
|
||||
state.imageLookups.push(options)
|
||||
return 'fixture-original.dat'
|
||||
}
|
||||
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
|
||||
return {
|
||||
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
|
||||
filePath: 'fixture-original.dat'
|
||||
}
|
||||
}
|
||||
isThumbnailFile(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/video-asset-service', () => ({
|
||||
VideoAssetService: class {
|
||||
resolve(): { success: boolean; url: string } {
|
||||
return { success: true, url: 'wxe-media://local/fixture-video' }
|
||||
}
|
||||
pathForUrl(): string {
|
||||
return state.videoPath
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/sticker-service', () => ({
|
||||
StickerService: class {}
|
||||
}))
|
||||
|
||||
const message = (overrides: Partial<Message>): Message => ({
|
||||
id: 'fixture',
|
||||
from: 'fixture',
|
||||
type: '普通文本',
|
||||
datetime: '2026-08-01 10:00:00',
|
||||
content: '',
|
||||
isSender: false,
|
||||
createTime: 1_785_549_600,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('media export flow', () => {
|
||||
beforeEach(() => {
|
||||
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
|
||||
state.videoPath = join(state.documents, 'fixture.mp4')
|
||||
writeFileSync(
|
||||
state.videoPath,
|
||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||
)
|
||||
state.imageLookups = []
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-ok',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 1,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
}),
|
||||
message({
|
||||
id: 'voice-missing',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 2,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
}),
|
||||
message({
|
||||
id: 'image',
|
||||
type: '图片',
|
||||
sessionId: 'fixture-session',
|
||||
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' }
|
||||
}),
|
||||
message({
|
||||
id: 'video',
|
||||
type: '视频',
|
||||
contentData: { type: 'video', md5: 'b'.repeat(32) }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(state.documents, { recursive: true, force: true }))
|
||||
|
||||
it('writes playable relative assets, keeps failures, and requests the original image first', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const progress: unknown[] = []
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: (...args: unknown[]) => progress.push(args) }
|
||||
}
|
||||
const result = await runExport(
|
||||
{
|
||||
jobId: 'fixture-export',
|
||||
userMd5: 'fixture-user',
|
||||
name: '脱敏会话',
|
||||
format: 'html',
|
||||
outputName: 'fixture',
|
||||
kinds: ['voice', 'image', 'video'],
|
||||
includeMedia: true,
|
||||
preferOriginal: true,
|
||||
fallbackThumbnail: true,
|
||||
keepMissing: true
|
||||
},
|
||||
win as never
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const html = readFileSync(result.outputPath!, 'utf8')
|
||||
const outputDir = dirname(result.outputPath!)
|
||||
expect(readFileSync(join(outputDir, 'voices/voice_1_1.wav')).subarray(0, 4).toString()).toBe(
|
||||
'RIFF'
|
||||
)
|
||||
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
|
||||
'ftyp'
|
||||
)
|
||||
expect(html).toContain('src="voices/voice_1_1.wav"')
|
||||
expect(html).toContain('src="media/video_4.mp4"')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(state.imageLookups[0]).toMatchObject({
|
||||
allowThumbnail: false,
|
||||
preferThumbnail: false
|
||||
})
|
||||
expect(progress.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
userData: `${process.env.TEMP || process.env.TMP || '.'}/wxe-account-discovery-tests`
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => mocked.userData }
|
||||
}))
|
||||
|
||||
import { discoverAccounts } from '../../src/main/services/account-discovery'
|
||||
|
||||
describe('account discovery', () => {
|
||||
let root: string
|
||||
const keyStore = {
|
||||
getStatus: vi.fn(async (accountRoot: string) => ({
|
||||
saved: accountRoot.endsWith('account-b'),
|
||||
encryptionAvailable: true
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'wxe-accounts-'))
|
||||
await Promise.all(
|
||||
['account-a', 'account-b', 'account-c'].map((name) =>
|
||||
fs.ensureDir(path.join(root, name, 'db_storage'))
|
||||
)
|
||||
)
|
||||
await fs.ensureDir(path.join(root, 'Backup'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(root)
|
||||
await fs.remove(mocked.userData)
|
||||
})
|
||||
|
||||
it('rejects an invalid Backup directory without continuing', async () => {
|
||||
const result = await discoverAccounts(path.join(root, 'Backup'), keyStore as never)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.accounts).toEqual([])
|
||||
})
|
||||
|
||||
it('lists every direct account and never preselects one from a root directory', async () => {
|
||||
const result = await discoverAccounts(root, keyStore as never)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.accounts.map((account) => account.directoryName).sort()).toEqual([
|
||||
'account-a',
|
||||
'account-b',
|
||||
'account-c'
|
||||
])
|
||||
expect(result.preselectedAccountId).toBeUndefined()
|
||||
expect(
|
||||
result.accounts.find((account) => account.directoryName === 'account-b')?.hasSavedDbKey
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('preselects a directly selected account directory while retaining its card', async () => {
|
||||
const accountRoot = path.join(root, 'account-c')
|
||||
const result = await discoverAccounts(accountRoot, keyStore as never)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.accounts).toHaveLength(1)
|
||||
expect(result.accounts[0].accountRoot).toBe(accountRoot)
|
||||
expect(result.preselectedAccountId).toBe(result.accounts[0].id)
|
||||
expect(result.accounts[0].selectedByInput).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSafeDiagnosticSummary } from '../../src/shared/connection-diagnostics'
|
||||
|
||||
describe('connection diagnostics', () => {
|
||||
it('contains useful versions and readiness without secrets or full account paths', () => {
|
||||
const summary = buildSafeDiagnosticSummary({
|
||||
platform: 'win32',
|
||||
osVersion: 'Windows 11 fixture',
|
||||
appVersion: 'v2.1.6',
|
||||
wechatVersion: '4.1.9.57',
|
||||
dataStructureVersion: '微信 4.x(WCDB)',
|
||||
dataDirectoryDetected: true,
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: true,
|
||||
dbConnected: false,
|
||||
encryptionAvailable: true
|
||||
})
|
||||
|
||||
expect(summary).toContain('WechatExplorer: v2.1.6')
|
||||
expect(summary).toContain('微信客户端: 4.1.9.57')
|
||||
expect(summary).not.toContain('0123456789abcdef')
|
||||
expect(summary).not.toContain('C:\\Users\\fixture\\xwechat_files\\wxid_secret')
|
||||
expect(summary).not.toContain('wxid_')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderExportPage } from '../../src/main/export-html-template'
|
||||
import { getImageExportAttempts } from '../../src/shared/export-media'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const baseMessage = (overrides: Partial<Message>): Message => ({
|
||||
id: 'fixture-message',
|
||||
from: 'fixture',
|
||||
type: '文本',
|
||||
datetime: '2026-08-01 10:00:00',
|
||||
content: '',
|
||||
isSender: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('export media', () => {
|
||||
it('always attempts the original before an explicitly enabled thumbnail fallback', () => {
|
||||
const first = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
|
||||
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
|
||||
|
||||
expect(first).toEqual([
|
||||
{ allowThumbnail: false, preferThumbnail: false, fallback: false },
|
||||
{ allowThumbnail: true, preferThumbnail: true, fallback: true }
|
||||
])
|
||||
expect(repeated).toEqual(first)
|
||||
})
|
||||
|
||||
it('renders movable relative audio and video assets plus accurate missing-media details', () => {
|
||||
const html = renderExportPage('脱敏导出', [
|
||||
baseMessage({ id: 'voice', type: '语音', voiceDataUrl: 'voices/voice_1.wav' }),
|
||||
baseMessage({
|
||||
id: 'video',
|
||||
type: '视频',
|
||||
exportMediaType: 'video',
|
||||
exportMediaUrl: 'media/video_2.mp4'
|
||||
}),
|
||||
baseMessage({
|
||||
id: 'missing',
|
||||
type: '语音',
|
||||
exportMediaError: '语音文件缺失:本地未找到语音数据'
|
||||
})
|
||||
])
|
||||
|
||||
expect(html).toContain(
|
||||
'audio class="audio" controls preload="metadata" src="voices/voice_1.wav"'
|
||||
)
|
||||
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
|
||||
})
|
||||
|
||||
it('renders explicit and keyboard-accessible lightbox closing controls', () => {
|
||||
const html = renderExportPage('图片预览', [
|
||||
baseMessage({ id: 'image', type: '图片', exportMediaUrl: 'media/image.jpg' })
|
||||
])
|
||||
|
||||
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()")
|
||||
})
|
||||
})
|
||||
@@ -20,4 +20,14 @@ describe('message pagination', () => {
|
||||
)
|
||||
expect(merged.map((message) => message.id)).toEqual(['oldest', 'overlap', 'latest'])
|
||||
})
|
||||
|
||||
it('keeps cross-year pages continuous through the earliest fixture record', () => {
|
||||
const page2025 = [makeMessage('2025', 1_735_689_600), makeMessage('2026', 1_767_225_600)]
|
||||
const page2017 = [makeMessage('2017', 1_483_228_800), makeMessage('2025', 1_735_689_600)]
|
||||
|
||||
const merged = mergeMessagePages(page2017, page2025)
|
||||
|
||||
expect(merged.map((message) => message.id)).toEqual(['2017', '2025', '2026'])
|
||||
expect(new Set(merged.map((message) => message.id)).size).toBe(merged.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
|
||||
|
||||
const message = (id: string, year: number): Wcdb4Message => ({
|
||||
mesLocalID: id,
|
||||
serverId: `server-${id}`,
|
||||
mesDes: 0,
|
||||
messageType: '1',
|
||||
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
|
||||
msgContent: `fixture-${year}`,
|
||||
raw: {}
|
||||
})
|
||||
|
||||
describe('WCDB message shard pagination', () => {
|
||||
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)])
|
||||
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
|
||||
wcdbGetMessageTableStats: vi.fn(),
|
||||
wcdbExecQuery: vi.fn(),
|
||||
getMessagesByCursorAsync: cursor,
|
||||
getMessagesByTableScanAsync: tableScan
|
||||
}) as Wcdb4Client
|
||||
|
||||
const result = await client.getMessagesAsync(
|
||||
'fixture@chatroom',
|
||||
undefined,
|
||||
Math.floor(Date.UTC(2026, 0, 1) / 1000),
|
||||
{ limit: 20 }
|
||||
)
|
||||
|
||||
expect(tableScan).toHaveBeenCalledOnce()
|
||||
expect(result.map((item) => item.msgContent)).toEqual(['fixture-2017', 'fixture-2025'])
|
||||
})
|
||||
|
||||
it('reports an unsupported shard query instead of claiming history ended', async () => {
|
||||
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
|
||||
getMessagesByCursorAsync: vi.fn(async () => [])
|
||||
}) as Wcdb4Client
|
||||
|
||||
await expect(
|
||||
client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 })
|
||||
).rejects.toThrow('无法检查历史消息分片')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user