test: 建立桌面端自动化回归测试体系并完善跨平台 CI

(cherry picked from commit 74267ae2f63f8256c3da84b04f5b330e6e9d4c67)
This commit is contained in:
电摇小子
2026-08-03 10:08:28 +08:00
committed by Wxw-Gu
parent b4f909a597
commit 3e57a8432d
36 changed files with 2598 additions and 110 deletions
+81
View File
@@ -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()
})
})
+56
View File
@@ -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()
})
})
+59
View File
@@ -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)
})
})
+23
View File
@@ -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'])
})
})
+38
View File
@@ -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
})
})
})
+81
View File
@@ -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: '固定假回答' })
})
})
+53
View File
@@ -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')
})
})