mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
test: 建立桌面端自动化回归测试体系并完善跨平台 CI
(cherry picked from commit 74267ae2f63f8256c3da84b04f5b330e6e9d4c67)
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Contact, Message } from '../../src/shared/types'
|
||||
|
||||
const userData = mkdtempSync(join(tmpdir(), 'wxe-bootstrap-test-'))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => userData }
|
||||
}))
|
||||
|
||||
import {
|
||||
clearBootstrapCache,
|
||||
flushBootstrapCacheWritesSync,
|
||||
getBootstrapCache,
|
||||
getCachedMessages,
|
||||
saveBootstrapContacts,
|
||||
saveCachedMessages
|
||||
} from '../../src/main/services/bootstrap-cache'
|
||||
|
||||
const accountRoot = 'fixture-account-root'
|
||||
const contact: Contact = {
|
||||
m_nsUsrName: 'fixture-user',
|
||||
m_nsNickName: '脱敏联系人',
|
||||
md5: 'fixture-md5',
|
||||
type: 'user'
|
||||
}
|
||||
|
||||
function findFile(name: string): string {
|
||||
const visit = (directory: string): string | null => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
const nested = visit(file)
|
||||
if (nested) return nested
|
||||
} else if (entry.name === name) return file
|
||||
}
|
||||
return null
|
||||
}
|
||||
const result = visit(userData)
|
||||
if (!result) throw new Error(`${name} was not written`)
|
||||
return result
|
||||
}
|
||||
|
||||
describe('bootstrap cache', () => {
|
||||
beforeAll(() => rmSync(userData, { recursive: true, force: true }))
|
||||
beforeEach(() => clearBootstrapCache())
|
||||
afterAll(() => rmSync(userData, { recursive: true, force: true }))
|
||||
|
||||
it('persists contacts and caps each message bucket', () => {
|
||||
saveBootstrapContacts(accountRoot, [contact])
|
||||
const messages: Message[] = Array.from({ length: 140 }, (_, index) => ({
|
||||
id: String(index),
|
||||
from: 'user',
|
||||
type: '文本',
|
||||
datetime: '2026-08-01 10:00:00',
|
||||
content: `fixture-${index}`,
|
||||
isSender: false,
|
||||
createTime: index + 1
|
||||
}))
|
||||
saveCachedMessages(accountRoot, contact.md5, undefined, undefined, messages)
|
||||
flushBootstrapCacheWritesSync()
|
||||
clearBootstrapCache()
|
||||
|
||||
expect(getBootstrapCache(accountRoot)?.contacts).toEqual([contact])
|
||||
const cached = getCachedMessages(accountRoot, contact.md5)
|
||||
expect(cached).toHaveLength(120)
|
||||
expect(cached[0].id).toBe('20')
|
||||
})
|
||||
|
||||
it('degrades to a cache miss when persisted JSON is corrupted', () => {
|
||||
saveBootstrapContacts(accountRoot, [contact])
|
||||
flushBootstrapCacheWritesSync()
|
||||
const startup = findFile('startup.json')
|
||||
expect(readFileSync(startup, 'utf8')).toContain('fixture-user')
|
||||
writeFileSync(startup, '{broken', 'utf8')
|
||||
clearBootstrapCache()
|
||||
expect(getBootstrapCache(accountRoot)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import crypto from 'crypto'
|
||||
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-test-'))
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => root } }))
|
||||
vi.mock('../../src/main/services/settings-store', () => ({
|
||||
loadSettings: () => ({ ffmpegPath: '' })
|
||||
}))
|
||||
vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} }))
|
||||
|
||||
import { ImageDecryptService } from '../../src/main/image-decrypt-service'
|
||||
|
||||
const aesKey = '0123456789abcdef'
|
||||
const xorKey = 0x40
|
||||
|
||||
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])
|
||||
const padded = Buffer.concat([aesPlain, Buffer.alloc(16, 16)])
|
||||
const cipher = crypto.createCipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii'), null)
|
||||
cipher.setAutoPadding(false)
|
||||
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()])
|
||||
const raw = Buffer.from([13, 14])
|
||||
const tailPlain = Buffer.from([15, 16])
|
||||
const tailCipher = Buffer.from(tailPlain.map((value) => value ^ xorKey))
|
||||
const header = Buffer.alloc(15)
|
||||
Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]).copy(header)
|
||||
header.writeInt32LE(aesPlain.length, 6)
|
||||
header.writeInt32LE(tailPlain.length, 10)
|
||||
writeFileSync(file, Buffer.concat([header, encrypted, raw, tailCipher]))
|
||||
return Buffer.concat([aesPlain, raw, tailPlain])
|
||||
}
|
||||
|
||||
describe('DAT image decryption', () => {
|
||||
beforeAll(() => mkdirSync(root, { recursive: true }))
|
||||
afterAll(() => 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)
|
||||
})
|
||||
|
||||
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 legacy = join(root, 'legacy.dat')
|
||||
writeFileSync(legacy, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
|
||||
expect(new ImageDecryptService('0x40', aesKey).decryptImage(legacy)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => 'fixture-settings' },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
import {
|
||||
isDatabaseKeyFormatValid,
|
||||
mapAutoDetectPhase,
|
||||
normalizeDatabaseKey
|
||||
} from '../../src/renderer/src/features/settings/database-key/utils'
|
||||
import {
|
||||
normalizeImageXorKey,
|
||||
validateImageKeyRequest
|
||||
} from '../../src/main/services/image-key-config-service'
|
||||
|
||||
describe('database key validation', () => {
|
||||
it('normalizes a prefixed key without accepting the wrong length', () => {
|
||||
const key = `0x${'a'.repeat(64)}`
|
||||
expect(normalizeDatabaseKey(key)).toBe('a'.repeat(64))
|
||||
expect(isDatabaseKeyFormatValid(key)).toBe(true)
|
||||
expect(isDatabaseKeyFormatValid('a'.repeat(63))).toBe(false)
|
||||
expect(isDatabaseKeyFormatValid('z'.repeat(64))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps automatic detection progress into stable phases', () => {
|
||||
expect(mapAutoDetectPhase('正在查找微信进程')).toBeGreaterThan(0)
|
||||
expect(mapAutoDetectPhase('已获取数据库密钥')).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('image key validation', () => {
|
||||
it.each([
|
||||
[64, '0x40'],
|
||||
['64', '0x40'],
|
||||
['0xff', '0xFF'],
|
||||
['', '0x40']
|
||||
])('normalizes %s to %s', (input, expected) => {
|
||||
expect(normalizeImageXorKey(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps database and image key validation independent', () => {
|
||||
const result = validateImageKeyRequest({
|
||||
resourceRoot: ' fixture-root ',
|
||||
xorKey: '64',
|
||||
aesKey: '0123456789abcdef'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
resourceRoot: 'fixture-root',
|
||||
xorKey: '0x40',
|
||||
aesKey: '0123456789abcdef'
|
||||
})
|
||||
expect(
|
||||
validateImageKeyRequest({ resourceRoot: 'fixture-root', xorKey: '999', aesKey: 'short' })
|
||||
.success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
import { mergeMessagePages } from '../../src/renderer/src/utils/message-pages'
|
||||
|
||||
const makeMessage = (id: string, createTime: number): Message => ({
|
||||
id,
|
||||
from: 'user',
|
||||
type: '文本',
|
||||
datetime: new Date(createTime * 1000).toISOString(),
|
||||
content: id,
|
||||
isSender: false,
|
||||
createTime
|
||||
})
|
||||
|
||||
describe('message pagination', () => {
|
||||
it('sorts older pages and removes overlapping records', () => {
|
||||
const merged = mergeMessagePages(
|
||||
[makeMessage('oldest', 1), makeMessage('overlap', 2)],
|
||||
[makeMessage('overlap', 2), makeMessage('latest', 3)]
|
||||
)
|
||||
expect(merged.map((message) => message.id)).toEqual(['oldest', 'overlap', 'latest'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMessageContent } from '../../src/main/message-parser'
|
||||
|
||||
describe('message parser', () => {
|
||||
it('parses image, voice and sticker messages without confusing their types', () => {
|
||||
expect(parseMessageContent('<img md5="0123456789abcdef0123456789abcdef" />', 3)).toMatchObject({
|
||||
type: 'image',
|
||||
md5: '0123456789abcdef0123456789abcdef'
|
||||
})
|
||||
expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' })
|
||||
expect(
|
||||
parseMessageContent(
|
||||
'<emoji md5="abcdefabcdefabcdefabcdefabcdefab" cdnurl="https://fixture.invalid/a" />',
|
||||
47
|
||||
)
|
||||
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
|
||||
})
|
||||
|
||||
it('parses merged forwards and preserves nested visible text', () => {
|
||||
const parsed = parseMessageContent(
|
||||
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
|
||||
49
|
||||
)
|
||||
expect(parsed.type).toBe('forwardBundle')
|
||||
if (parsed.type === 'forwardBundle') {
|
||||
expect(parsed.title).toBe('转发多条内容')
|
||||
expect(parsed.items.map((item) => item.text).join(' ')).toContain('脱敏内容')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses an explicit unknown type for unsupported messages', () => {
|
||||
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
|
||||
type: 'unknown',
|
||||
raw: 'opaque fixture payload',
|
||||
messageType: 999
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
import {
|
||||
buildMessageGroups,
|
||||
formatMessageTime
|
||||
} from '../../src/renderer/src/components/chat/messageGrouping'
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
parseSearchCacheKey,
|
||||
readSearchCache,
|
||||
writeSearchCache
|
||||
} from '../../src/renderer/src/components/search/searchUtils'
|
||||
|
||||
const message = (id: string, createTime: number, from = 'user'): Message => ({
|
||||
id,
|
||||
from,
|
||||
type: '文本',
|
||||
datetime: new Date(createTime * 1000).toISOString(),
|
||||
content: id,
|
||||
isSender: from === 'assistant',
|
||||
senderId: from,
|
||||
createTime
|
||||
})
|
||||
|
||||
describe('message grouping and dates', () => {
|
||||
it('groups adjacent messages but keeps system and distant messages separate', () => {
|
||||
const groups = buildMessageGroups([
|
||||
message('one', 1000),
|
||||
message('two', 1060),
|
||||
{ ...message('system', 1070, 'system'), type: '系统消息' },
|
||||
message('three', 2000)
|
||||
])
|
||||
expect(groups.map((group) => group.messages.map((item) => item.id))).toEqual([
|
||||
['one', 'two'],
|
||||
['system'],
|
||||
['three']
|
||||
])
|
||||
})
|
||||
|
||||
it('formats today and yesterday deterministically', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-01T12:00:00+08:00'))
|
||||
expect(
|
||||
formatMessageTime(
|
||||
message('today', Math.floor(Date.parse('2026-08-01T10:00:00+08:00') / 1000))
|
||||
)
|
||||
).toContain('今天')
|
||||
expect(
|
||||
formatMessageTime(
|
||||
message('yesterday', Math.floor(Date.parse('2026-07-31T10:00:00+08:00') / 1000))
|
||||
)
|
||||
).toContain('昨天')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search cache', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('normalizes the key and survives invalid persisted state', () => {
|
||||
const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ')
|
||||
expect(parseSearchCacheKey(key)).toMatchObject({ query: 'windows 性能', range: '7d' })
|
||||
localStorage.setItem('wxe_ai_search_cache_v1', '{broken')
|
||||
expect(readSearchCache(key)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes and reads an isolated cache record', () => {
|
||||
const key = buildSearchCacheKey('conversation', 'fixture-contact', 'today', '图片')
|
||||
const record = {
|
||||
version: 1 as const,
|
||||
key,
|
||||
query: '图片',
|
||||
answer: '固定假回答',
|
||||
evidence: [],
|
||||
createdAt: 1
|
||||
}
|
||||
writeSearchCache(record)
|
||||
expect(readSearchCache(key)).toMatchObject({ answer: '固定假回答' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { classifyStickerHttpFailure } from '../../src/shared/sticker'
|
||||
|
||||
const logs = mkdtempSync(join(tmpdir(), 'wxe-log-test-'))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => logs, isPackaged: true },
|
||||
shell: { showItemInFolder: vi.fn() }
|
||||
}))
|
||||
|
||||
import { AppLogger } from '../../src/main/app-logger'
|
||||
|
||||
describe('sensitive logging', () => {
|
||||
afterAll(() => rmSync(logs, { recursive: true, force: true }))
|
||||
|
||||
it('does not persist database keys, API keys or bearer tokens', () => {
|
||||
const databaseKey = 'a'.repeat(64)
|
||||
const logger = new AppLogger()
|
||||
logger.write({
|
||||
level: 'error',
|
||||
scope: 'fixture',
|
||||
message: `database open failed key=${databaseKey}`,
|
||||
details: {
|
||||
databaseKey,
|
||||
apiKey: 'sk-fixture-secret-value',
|
||||
authorization: 'Bearer fixture-token-value'
|
||||
}
|
||||
})
|
||||
const persisted = readFileSync(logger.logPath, 'utf8')
|
||||
expect(persisted).not.toContain(databaseKey)
|
||||
expect(persisted).not.toContain('sk-fixture-secret-value')
|
||||
expect(persisted).not.toContain('fixture-token-value')
|
||||
expect(persisted).toContain('***')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sticker HTTP failures', () => {
|
||||
it('distinguishes expired, unauthorized, removed and rate-limited resources', () => {
|
||||
expect(classifyStickerHttpFailure(403, 'https://fixture.invalid/a?expires=1', 2_000).code).toBe(
|
||||
'link_expired'
|
||||
)
|
||||
expect(classifyStickerHttpFailure(403, 'https://fixture.invalid/a').code).toBe('access_denied')
|
||||
expect(classifyStickerHttpFailure(401, 'https://fixture.invalid/a').code).toBe(
|
||||
'authentication_required'
|
||||
)
|
||||
expect(classifyStickerHttpFailure(410, 'https://fixture.invalid/a').code).toBe(
|
||||
'resource_removed'
|
||||
)
|
||||
expect(classifyStickerHttpFailure(429, 'https://fixture.invalid/a').code).toBe('rate_limited')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user