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
+4
View File
@@ -22,3 +22,7 @@ VITE_FILTER_MSG_TYPES=
# AES Key: 16-character string, derived from wxid and code # AES Key: 16-character string, derived from wxid and code
VITE_IMAGE_XOR_KEY= VITE_IMAGE_XOR_KEY=
VITE_IMAGE_AES_KEY= VITE_IMAGE_AES_KEY=
# Electron E2E test window close delay in milliseconds.
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
WXE_E2E_CLOSE_DELAY_MS=2000
+76
View File
@@ -0,0 +1,76 @@
name: Tests
on:
push:
pull_request:
jobs:
desktop-tests:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 7.33.7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-go@v5
with:
go-version-file: services/wechat-connector/go.mod
cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm typecheck
- name: Unit tests
run: pnpm test:unit
- name: Component tests
run: pnpm test:component
- name: IPC integration tests
run: pnpm test:integration
- name: Skill installation instruction tests
run: pnpm test:skill-install
- name: WeChat connector tests
run: pnpm test:wechat-connector
- name: Build Electron test application
run: pnpm test:e2e:build
- name: Electron E2E tests
run: pnpm exec playwright test --grep-invert @visual
env:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Platform visual regression
run: pnpm exec playwright test tests/e2e/visual.spec.ts
env:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Upload Playwright diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.os }}
path: |
test-results/
playwright-report/
if-no-files-found: ignore
retention-days: 14
+3
View File
@@ -6,6 +6,9 @@ out
.DS_Store .DS_Store
.eslintcache .eslintcache
*.log* *.log*
coverage/
playwright-report/
test-results/
resources/connectors/wechat/ resources/connectors/wechat/
.omc .omc
.codex/ .codex/
+18 -3
View File
@@ -17,6 +17,7 @@
}, },
"main": "./out/main/index.js", "main": "./out/main/index.js",
"scripts": { "scripts": {
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint --cache .", "lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -28,7 +29,13 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", "dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...", "test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"test:stability": "node --experimental-strip-types --test tests/stability-compat.test.mjs", "test:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:e2e:build": "electron-vite build",
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs", "build:wechat-connector": "node scripts/build-wechat-connector.cjs",
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64", "build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64", "build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
@@ -47,11 +54,11 @@
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
}, },
"dependencies": { "dependencies": {
"cross-env": "^10.1.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0", "@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"cross-env": "^10.1.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
@@ -65,12 +72,18 @@
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.62.1",
"@rollup/rollup-darwin-arm64": "^4.62.2", "@rollup/rollup-darwin-arm64": "^4.62.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10",
"electron": "^43.0.0", "electron": "^43.0.0",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
@@ -78,12 +91,14 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"jsdom": "^30.0.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"sass": "^1.102.0", "sass": "^1.102.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.2.6",
"vitest": "^4.1.10"
}, },
"pnpm": { "pnpm": {
"supportedArchitectures": { "supportedArchitectures": {
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
testMatch: /.*\.spec\.ts/,
timeout: 45_000,
expect: { timeout: 8_000 },
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
outputDir: 'test-results',
snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}',
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
})
+807 -8
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -13,6 +13,7 @@ const sanitize = (value: unknown, depth = 0): unknown => {
return value return value
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***') .replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***') .replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
.slice(0, 2000) .slice(0, 2000)
} }
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1)) if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
@@ -56,7 +57,7 @@ export class AppLogger {
mode: isPackagedRuntime() ? 'packaged' : 'development', mode: isPackagedRuntime() ? 'packaged' : 'development',
level: entry.level, level: entry.level,
scope: String(entry.scope || 'app').slice(0, 80), scope: String(entry.scope || 'app').slice(0, 80),
message: String(entry.message || '').slice(0, 500), message: String(sanitize(entry.message || '')).slice(0, 500),
details: sanitize(entry.details || {}) details: sanitize(entry.details || {})
} }
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' }) fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
+9 -13
View File
@@ -203,9 +203,11 @@ function getMimeType(extension) {
function strictRemovePadding(buffer) { function strictRemovePadding(buffer) {
if (buffer.length === 0) return buffer if (buffer.length === 0) return buffer
const paddingLength = buffer[buffer.length - 1] const paddingLength = buffer[buffer.length - 1]
if (paddingLength <= 0 || paddingLength > 16 || paddingLength > buffer.length) return buffer if (paddingLength <= 0 || paddingLength > 16 || paddingLength > buffer.length) {
throw new Error('invalid PKCS#7 padding')
}
for (let index = buffer.length - paddingLength; index < buffer.length; index += 1) { for (let index = buffer.length - paddingLength; index < buffer.length; index += 1) {
if (buffer[index] !== paddingLength) return buffer if (buffer[index] !== paddingLength) throw new Error('invalid PKCS#7 padding')
} }
return buffer.subarray(0, buffer.length - paddingLength) return buffer.subarray(0, buffer.length - paddingLength)
} }
@@ -1636,19 +1638,13 @@ export class ImageDecryptService {
private strictRemovePadding(buffer: Buffer): Buffer { private strictRemovePadding(buffer: Buffer): Buffer {
if (buffer.length === 0) return buffer if (buffer.length === 0) return buffer
const lastByte = buffer[buffer.length - 1] const lastByte = buffer[buffer.length - 1]
if (lastByte <= 16 && lastByte > 0) { if (lastByte <= 0 || lastByte > 16 || lastByte > buffer.length) {
throw new Error('invalid PKCS#7 padding')
}
const paddingLength = lastByte const paddingLength = lastByte
let valid = true for (let i = buffer.length - paddingLength; i < buffer.length; i += 1) {
for (let i = buffer.length - paddingLength; i < buffer.length; i++) { if (buffer[i] !== lastByte) throw new Error('invalid PKCS#7 padding')
if (buffer[i] !== lastByte) {
valid = false
break
} }
}
if (valid) {
return buffer.subarray(0, buffer.length - paddingLength) return buffer.subarray(0, buffer.length - paddingLength)
} }
}
return buffer
}
} }
+5 -19
View File
@@ -24,6 +24,11 @@ import { FirstUseWelcome } from './components/FirstUseWelcome'
import { ExportWorkspace } from './components/export/ExportWorkspace' import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace' import { AISearchWorkspace } from './components/search/AISearchWorkspace'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
import {
getMessageIdentity,
mergeMessagePages,
sortMessagesChronologically
} from './utils/message-pages'
const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380 const SIDEBAR_MAX_WIDTH = 380
@@ -47,12 +52,6 @@ const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20 const EXPORT_PREVIEW_LIMIT = 20
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const normalizeQuotedText = (value: string | undefined): string => const normalizeQuotedText = (value: string | undefined): string =>
String(value || '') String(value || '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -194,19 +193,6 @@ const buildSyntheticGroupMessages = (
void buildSyntheticGroupMessages void buildSyntheticGroupMessages
const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
const merged = new Map<string, Message>()
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
return sortMessagesChronologically(Array.from(merged.values()))
}
function App(): React.ReactElement { function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false) const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
+20
View File
@@ -0,0 +1,20 @@
import type { Message } from '../../../shared/types'
export const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
export const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
export const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
const merged = new Map<string, Message>()
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
return sortMessagesChronologically(Array.from(merged.values()))
}
@@ -0,0 +1,55 @@
import { render, screen, type RenderResult } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { DatabaseConnectionPage } from '../../src/renderer/src/components/DatabaseConnectionPage'
function renderPage(
overrides: Partial<ComponentProps<typeof DatabaseConnectionPage>> = {}
): RenderResult & { props: ComponentProps<typeof DatabaseConnectionPage> } {
const props = {
platform: 'win32',
mode: 'manual' as const,
dbKey: '',
dbRoot: '',
showDbKey: false,
isFetching: false,
status: '',
statusKind: 'normal' as const,
showMacKeyFaq: false,
macKeyFaqUrl: 'https://fixture.invalid/mac',
onModeChange: vi.fn(),
onDbKeyChange: vi.fn(),
onDbRootChange: vi.fn(),
onToggleDbKey: vi.fn(),
onAutoGetKey: vi.fn(),
onManualConnect: vi.fn(),
onPasteKey: vi.fn(),
onClearKey: vi.fn(),
...overrides
}
return { props, ...render(<DatabaseConnectionPage {...props} />) }
}
describe('DatabaseConnectionPage', () => {
it('keeps connect disabled until a valid 64-character key is supplied', () => {
const { rerender, props } = renderPage()
expect(screen.getByRole('button', { name: '连接数据库' })).toBeDisabled()
rerender(<DatabaseConnectionPage {...props} dbKey={'a'.repeat(64)} />)
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
})
it('shows a recoverable error and keeps form actions available', async () => {
const onManualConnect = vi.fn()
renderPage({
dbKey: 'b'.repeat(64),
status: '数据库密钥无效,请重新输入',
statusKind: 'error',
onManualConnect
})
expect(screen.getByText('数据库密钥无效,请重新输入')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '连接数据库' }))
expect(onManualConnect).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled()
})
})
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { EmptyConversationState } from '../../src/renderer/src/components/chat/EmptyConversationState'
import { SettingsEmptyState } from '../../src/renderer/src/features/settings/components/SettingsEmptyState'
describe('empty states', () => {
it('explains how to leave an empty archive without pretending data failed', () => {
render(<EmptyConversationState />)
expect(screen.getByRole('heading', { name: '选择一条消息' })).toBeVisible()
expect(screen.getByText('从左侧选择群聊或联系人以浏览历史记录')).toBeVisible()
})
it('labels an unavailable settings section explicitly', () => {
render(<SettingsEmptyState label="测试设置" />)
expect(screen.getByRole('heading', { name: '测试设置' })).toBeVisible()
expect(screen.getByText('该设置将在后续阶段接入。')).toBeVisible()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const requestImage = vi.fn()
vi.mock('../../src/renderer/src/components/image-loader', () => ({
getCachedLoadedImage: vi.fn(() => undefined),
requestImage: (...args: unknown[]) => requestImage(...args)
}))
import { ImageBubble } from '../../src/renderer/src/components/ImageBubble'
const thumbnail =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const original = `${thumbnail}original`
describe('ImageBubble', () => {
beforeEach(() => {
requestImage.mockReset()
window.api = { copyImage: vi.fn().mockResolvedValue({ success: true }) } as typeof window.api
})
it('loads a thumbnail lazily, then requests the original when opened', async () => {
requestImage
.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
.mockResolvedValueOnce({ data: original, isThumbnail: false })
const onImageClick = vi.fn()
render(
<ImageBubble
imageMd5="fixture-image"
imageDatName="fixture.dat"
sessionId="fixture-session"
onImageClick={onImageClick}
/>
)
const image = await screen.findByAltText('图片')
expect(image).toHaveAttribute('src', thumbnail)
await userEvent.click(image)
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true })
})
it('shows an explicit error and allows retry', async () => {
requestImage.mockRejectedValueOnce(new Error('不支持的 DAT 版本'))
render(<ImageBubble imageMd5="unsupported" />)
expect(await screen.findByText('不支持的 DAT 版本')).toBeVisible()
requestImage.mockResolvedValueOnce({ data: thumbnail, isThumbnail: true })
await userEvent.click(screen.getByText('加载失败'))
expect(await screen.findByAltText('图片')).toBeVisible()
})
})
+21
View File
@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { PrimaryNavigation } from '../../src/renderer/src/components/layout/PrimaryNavigation'
import { PRIMARY_NAV_ITEMS } from '../../src/renderer/src/components/layout/navigation'
describe('PrimaryNavigation', () => {
it('shows every real top-level page exactly once and emits the selected page', async () => {
const onPageChange = vi.fn()
render(<PrimaryNavigation activePage="archive" onPageChange={onPageChange} />)
const navigation = screen.getByRole('navigation', { name: '一级导航' })
expect(navigation).toBeInTheDocument()
for (const item of PRIMARY_NAV_ITEMS) {
expect(screen.getAllByRole('button', { name: item.label })).toHaveLength(1)
}
await userEvent.click(screen.getByRole('button', { name: '设置' }))
expect(onPageChange).toHaveBeenCalledWith('settings')
})
})
+47
View File
@@ -0,0 +1,47 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { VoicePlayer } from '../../src/renderer/src/components/VoicePlayer'
const play = vi.fn(() => Promise.resolve())
const pause = vi.fn()
class FakeAudio {
preload = ''
src = ''
duration = 1
currentTime = 0
onloadedmetadata: (() => void) | null = null
ontimeupdate: (() => void) | null = null
onended: (() => void) | null = null
play = play
pause = pause
load = vi.fn()
removeAttribute = vi.fn()
}
describe('VoicePlayer', () => {
beforeEach(() => {
play.mockClear()
pause.mockClear()
vi.stubGlobal('Audio', FakeAudio)
window.api = {
getVoiceData: vi.fn().mockResolvedValue({
success: true,
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
})
} as typeof window.api
})
it('waits for decrypted bytes and calls play on the first click', async () => {
const { container } = render(
<VoicePlayer sessionId="filehelper" localId={11} createTime={1785553200} duration={1} />
)
await userEvent.click(container.querySelector('.voice-message') as HTMLElement)
await waitFor(() => expect(window.api.getVoiceData).toHaveBeenCalledOnce())
await waitFor(() => expect(play).toHaveBeenCalledOnce())
expect(container.querySelector('.voice-icon')).toHaveClass('playing')
expect(screen.queryByText('当前版本暂不支持播放')).not.toBeInTheDocument()
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+289
View File
@@ -0,0 +1,289 @@
import { expect, test } from '@playwright/test'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
import { tmpdir } from 'os'
import { resolve } from 'path'
import { launchTestApp } from './support/electron'
test('APP-01 first launch renders a usable connection screen without uncaught errors', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await expect(fixture.page.getByRole('heading', { name: 'WechatExplorer' })).toBeVisible()
await expect(fixture.page.getByRole('main')).not.toBeEmpty()
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
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 expect(keyInput).toBeVisible()
await keyInput.fill('a'.repeat(64))
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 {
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
const result = await fixture.page.evaluate(async () => {
const before = await window.api.getContacts()
const image = await window.api.saveImageKeyConfig({
resourceRoot: 'fixture-account',
xorKey: '0x41',
aesKey: 'fedcba9876543210'
})
const after = await window.api.getContacts()
const database = await window.api.getSavedDbKey()
return { before, after, image, database }
})
expect(result.image.success).toBe(true)
expect(result.before).toEqual(result.after)
expect(result.database.saved).toBe(true)
} finally {
await fixture.close()
}
})
test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => {
const fixture = await launchTestApp()
const labels = ['档案', '问问微信', '日报', 'Agent', '导出', 'API', '设置']
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await expect(navigation).toBeVisible()
for (const label of labels) {
await expect(navigation.getByRole('button', { name: label })).toHaveCount(1)
await navigation.getByRole('button', { name: label }).click()
await expect(fixture.page.locator(`main.app-shell-main[aria-label="${label}"]`)).toBeVisible()
}
} finally {
await fixture.close()
}
})
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
const fixture = await launchTestApp()
try {
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('暂不支持此消息', { exact: true })).toBeVisible()
await expect(fixture.page.getByAltText('图片')).toBeVisible()
await fixture.page.locator('.image-bubble.image-loaded').click()
await expect(fixture.page.getByText('图片查看', { exact: true })).toBeVisible()
await fixture.page.locator('.image-viewer-overlay').click({ position: { x: 5, y: 5 } })
await fixture.page.getByRole('button', { name: '折叠群聊 (1)' }).click()
await expect(fixture.page.getByText('折叠群聊样本', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('MEDIA-01 and merged forwards work on the first interaction', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
await fixture.page.evaluate(() => {
Object.defineProperty(window, '__wxePlayCount', {
configurable: true,
value: 0,
writable: true
})
HTMLMediaElement.prototype.play = async function () {
;(window as Window & { __wxePlayCount: number }).__wxePlayCount += 1
}
HTMLMediaElement.prototype.pause = function () {
return undefined
}
HTMLMediaElement.prototype.load = function () {
return undefined
}
})
await fixture.page.locator('.voice-message').click()
await expect(fixture.page.locator('.voice-icon')).toHaveClass(/playing/)
expect(
await fixture.page.evaluate(
() => (window as Window & { __wxePlayCount: number }).__wxePlayCount
)
).toBe(1)
} finally {
await fixture.close()
}
})
test('MEDIA-02 MEDIA-04 return accurate unsupported and HTTP 403 reasons', async () => {
const fixture = await launchTestApp()
try {
const result = await fixture.page.evaluate(async () => ({
image: await window.api.getImage('unsupported'),
sticker: await window.api.getSticker(
'https://fixture.invalid/403?token=secret',
'b'.repeat(32)
)
}))
expect(result.image).toMatchObject({ success: false, error: '不支持的 DAT 版本' })
expect(result.sticker).toMatchObject({
success: false,
failureCode: 'access_denied',
httpStatus: 403
})
} finally {
await fixture.close()
}
})
test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
timeout: 15_000
})
} finally {
await fixture.close()
}
})
test('ASK-02 AI failures are recoverable and do not break the archive', async () => {
const fixture = await launchTestApp({ aiFailure: '429' })
try {
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
await expect(fixture.page.getByText(/本地假服务错误 429/)).toBeVisible()
await fixture.page.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '日报' }).click()
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()
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
await expect(generate).toBeEnabled()
await generate.click()
await expect(fixture.page.getByAltText('产品测试群 群聊日报')).toBeVisible({
timeout: 15_000
})
const exported = await fixture.page.evaluate(async () =>
window.api.exportGroupReport({
report: {} as never,
metadata: {} as never,
templateId: 'v1'
})
)
expect(exported.success).toBe(true)
expect(exported.imageDataUrl).toMatch(/^data:image\/png;base64,/)
expect(existsSync(exported.htmlPath!)).toBe(true)
expect(existsSync(exported.pngPath!)).toBe(true)
expect(statSync(exported.pngPath!).size).toBeGreaterThan(20)
} finally {
await fixture.close()
}
})
test('REPORT-03 report failure is retryable and leaves other pages usable', async () => {
const fixture = await launchTestApp({ aiFailure: '401' })
try {
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: '开始生成日报' }).click()
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.locator('main.app-shell-main[aria-label="档案"]')).toBeVisible()
await expect(
fixture.page.locator('.conversation-item-name').filter({ hasText: '产品测试群' }).first()
).toBeVisible()
} finally {
await fixture.close()
}
})
test('CACHE-01 corrupt startup cache degrades to native fixture data', async () => {
const fixture = await launchTestApp({ corruptCache: true })
try {
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('PERF-01 repeated startup with 1500 sessions remains bounded and responsive', async () => {
test.setTimeout(60_000)
const userData = mkdtempSync(resolve(tmpdir(), 'wxe-e2e-perf-'))
try {
for (let run = 0; run < 2; run += 1) {
const startedAt = Date.now()
const fixture = await launchTestApp({ userData, largeContacts: 1500 })
try {
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible({
timeout: 10_000
})
expect(Date.now() - startedAt).toBeLessThan(10_000)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await expect(fixture.page.locator('main.app-shell-main[aria-label="设置"]')).toBeVisible()
} finally {
await fixture.close()
}
}
} finally {
rmSync(userData, { recursive: true, force: true })
}
})
test('KEY-04 e2e diagnostic log does not contain a supplied key', async () => {
const fixture = await launchTestApp()
const key = 'c'.repeat(64)
try {
await fixture.page.evaluate(
(databaseKey) =>
window.api.writeAppLog({
level: 'error',
scope: 'key-test',
message: `fixture key=${databaseKey}`
}),
key
)
const logPath = resolve(fixture.userData, 'logs/e2e.log')
const content = readFileSync(logPath, 'utf8')
expect(content).not.toContain(key)
} finally {
await fixture.close()
}
})
+397
View File
@@ -0,0 +1,397 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { app, BrowserWindow, ipcMain } = require('electron')
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '../../..')
const fixture = require(path.join(root, 'tests/fixtures/chat-data.json'))
const userData = process.env.WXE_E2E_USER_DATA
if (!userData) throw new Error('WXE_E2E_USER_DATA is required')
app.setPath('userData', userData)
app.setPath('logs', path.join(userData, 'logs'))
app.commandLine.appendSwitch('disable-gpu')
const VALID_KEY = 'a'.repeat(64)
const imageData =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
const reportJson = JSON.stringify({
overview: '固定脱敏日报',
hero: {
headline: '产品测试群日报',
summary: '测试消息已完成自动整理。',
keyTakeaway: '核心流程可用',
pendingNote: '',
statusLine: '今日形成 1 个结论'
},
topics: [
{
title: '自动化测试',
timeRange: '10:00-10:02',
heat: '中',
participants: ['测试成员'],
summary: '讨论了脱敏自动化测试。',
conclusions: [{ text: '核心流程可用', sourceMessageIds: ['msg-text'] }],
keywords: ['测试'],
sourceMessageIds: ['msg-text']
}
],
resources: [],
importantMessages: [],
quotes: [],
qa: [],
todos: [],
unresolved: [],
storylines: [],
reversals: [],
participantChains: [],
keywords: ['测试']
})
let connected = process.env.WXE_E2E_MODE !== 'disconnected'
let savedKey = connected ? VALID_KEY : ''
let settings = {
dbRoot: 'fixture-account',
apiEnabled: false,
apiHost: '127.0.0.1',
apiPort: 5031,
imageKeyRoot: 'fixture-account',
ffmpegPath: '',
recallProtectionEnabled: false,
debugEnabled: false,
autoLogin: connected,
autoLoginPreferenceSet: true,
appearanceTheme: 'light',
compactMode: false,
showStartupProgress: false,
imageXorKey: '0x40',
imageAesKey: '0123456789abcdef'
}
const extraContacts = Number(process.env.WXE_E2E_LARGE_CONTACTS || 0)
const contacts = [...fixture.contacts]
for (let index = 0; index < extraContacts; index += 1) {
contacts.push({
m_nsUsrName: `fixture_${index}`,
m_nsNickName: `性能样本 ${index}`,
md5: `fixture-contact-${index}`,
type: index % 5 === 0 ? 'group' : 'user'
})
}
const handlers = new Map()
const handle = (channel, fn) => {
handlers.set(channel, fn)
ipcMain.handle(channel, async (event, ...args) => fn(...args))
}
const startupCache = () => ({
self: fixture.self,
contacts,
updatedAt: 1785553200000
})
handle('settings:get', () => ({ settings, settingsPath: path.join(userData, 'settings.json') }))
handle('settings:set', (patch) => {
settings = { ...settings, ...patch }
return { settings, settingsPath: path.join(userData, 'settings.json') }
})
handle('key:getSavedDbKey', () => ({
success: true,
key: savedKey || undefined,
saved: Boolean(savedKey),
encryptionAvailable: true
}))
handle('key:saveDbKey', (key) => {
savedKey = String(key || '')
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
})
handle('key:clearSavedDbKey', () => {
savedKey = ''
return { success: true }
})
handle('key:getEnvironment', () => ({
platform: process.platform,
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: connected,
dbConnected: connected,
encryptionAvailable: true
}))
handle('key:readClipboardDbKey', () => ({ success: true, value: VALID_KEY }))
handle('key:pasteAndSaveDbKey', () => ({ success: true, key: VALID_KEY }))
handle('key:autoGetDbKey', () => ({ success: true, key: VALID_KEY, saved: false }))
handle('key:autoGetImageKey', () => ({
success: true,
xorKey: 64,
aesKey: '0123456789abcdef',
verified: true
}))
handle('db:init', (key) => {
if (key !== VALID_KEY) {
connected = false
return { success: false, error: '数据库密钥无效', monitoring: false }
}
connected = true
return { success: true, monitoring: true }
})
handle('db:testConnection', (key) =>
key === VALID_KEY
? { success: true, wxid: fixture.self.wxid, accountRoot: fixture.self.accountRoot }
: { success: false, code: 'DATABASE_OPEN_FAILED', error: '数据库密钥无效' }
)
handle('db:disconnect', () => {
connected = false
return { success: true }
})
handle('db:getStartupCache', () =>
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
)
handle('db:getBootstrapCache', () =>
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
)
handle('db:getContacts', (filter) => {
const query = String(filter || '').toLowerCase()
return query
? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query))
: contacts
})
handle('db:getContactAvatars', (usernames) =>
Object.fromEntries(
contacts
.filter((contact) => usernames.includes(contact.m_nsUsrName) && contact.avatar)
.map((contact) => [contact.m_nsUsrName, contact.avatar])
)
)
handle('settings:getSelf', () => ({ ready: true, info: fixture.self }))
handle('db:getCachedMessages', (md5) => fixture.messages[md5] || [])
handle('db:getCachedMessagePage', (md5) => ({
hit: true,
messages: fixture.messages[md5] || [],
groupSnapshot: null
}))
handle('db:getMessages', (md5, startTime, endTime, options) => {
let messages = fixture.messages[md5] || []
if (startTime) messages = messages.filter((message) => (message.createTime || 0) >= startTime)
if (endTime) messages = messages.filter((message) => (message.createTime || 0) <= endTime)
if (options && options.limit) messages = messages.slice(-options.limit)
return messages
})
handle('db:getGroupSnapshot', (md5) =>
md5.startsWith('group-')
? {
roomId: md5,
memberCount: 1,
members: [
{
wxid: 'wxid_fixture_member',
nickname: '测试成员',
groupNickname: '测试成员',
wechatNickname: '测试成员',
remark: '',
avatar: ''
}
]
}
: null
)
handle('db:getImage', (md5, datName, sessionId, options) =>
md5 === 'unsupported'
? { success: false, error: '不支持的 DAT 版本' }
: {
success: true,
data: imageData,
isThumb: !options?.force,
filePath: path.join(userData, options?.force ? 'original.png' : 'thumbnail.png')
}
)
handle('db:getVoiceData', () => ({ success: true, data: voiceData }))
handle('db:getSticker', (url) =>
String(url || '').includes('403')
? {
success: false,
error: '表情链接已失效或需要微信授权',
failureCode: 'access_denied',
httpStatus: 403
}
: { success: true, data: imageData }
)
handle('db:parseMessage', (content, messageType) =>
messageType === 1
? { type: 'text', content: String(content) }
: { type: 'unknown', raw: String(content), messageType }
)
handle('ai:getRuntimeConfig', () => ({
providerId: 'fixture-provider',
providerName: '本地假服务',
model: 'fixture-model',
modelName: '固定响应模型',
configured: true,
status: 'connected',
timeoutMs: 5000
}))
handle('ai:listProviders', () => ({
success: true,
providers: [],
defaultProviderId: 'fixture-provider'
}))
handle('ai:migrateLegacy', () => ({ success: true, providers: [] }))
handle('ai:chat', (messages) => {
const failure = process.env.WXE_E2E_AI_FAILURE
if (failure) return { success: false, error: `本地假服务错误 ${failure}` }
const system = String(messages?.[0]?.content || '')
if (system.includes('本地聊天检索规划器')) {
return {
success: true,
data: '{"intent":"general","keywords":["测试"],"variants":[]}'
}
}
if (system.includes('微信群聊日报编辑') || system.includes('JSON 格式修复器')) {
return { success: true, data: reportJson, usage: { input: 10, output: 20, total: 30 } }
}
return { success: true, data: '固定假回答:测试数据中的核心流程正常。' }
})
handle('report:export', () => {
const htmlPath = path.join(userData, 'fixture-report.html')
const pngPath = path.join(userData, 'fixture-report.png')
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏日报</h1>', 'utf8')
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
})
handle('report:listGenerated', () => ({ success: true, reports: [] }))
handle('report:saveGenerated', (request) => ({
success: true,
record: { id: 'fixture-report-record', ...request }
}))
handle('report:deleteGenerated', () => ({ success: true }))
handle('report:reveal', () => ({ success: true }))
handle('copy-image', () => ({ success: true }))
handle('api:copyText', () => ({ success: true }))
handle('app-log:write', (entry) => {
const safe = JSON.stringify(entry)
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
fs.mkdirSync(path.join(userData, 'logs'), { recursive: true })
fs.appendFileSync(path.join(userData, 'logs', 'e2e.log'), `${safe}\n`, 'utf8')
})
handle('app-log:getPath', () => path.join(userData, 'logs', 'e2e.log'))
handle('app-log:reveal', () => undefined)
handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort }))
handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
handle('api:toggle', (enabled) => ({
running: enabled,
host: settings.apiHost,
port: settings.apiPort
}))
handle('image:getConfig', () => ({
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
resourceRoot: settings.imageKeyRoot,
xorKey: settings.imageXorKey,
aesKey: settings.imageAesKey
}))
handle('image:saveConfig', (request) => ({
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
...request
}))
handle('image:testConfig', () => ({
success: true,
fileFound: true,
decrypted: true,
readable: true
}))
handle('image:clearConfig', () => ({ success: true }))
handle('image:getDecoderStatus', () => ({
installed: true,
available: true,
source: 'system',
selected: false
}))
handle('image:getStatus', () => ({
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
resourceRoot: settings.imageKeyRoot,
platform: process.platform,
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: true,
cacheState: 'normal',
decoder: { installed: true, available: true, source: 'system', selected: false },
resources: Object.fromEntries(
['imageIndex', 'imageDirectory', 'thumbnail', 'original', 'sticker', 'video'].map((name) => [
name,
{ state: 'available', detail: 'fixture' }
])
)
}))
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
handle('agent-hub:getLogs', () => [])
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
for (const channel of [
'export:start',
'export:cancel',
'export:reveal',
'settings:selectDbRoot',
'settings:openAccountRoot',
'db:reopenWithRoot',
'api:skillStatus',
'api:readSkill',
'api:revealSkill',
'api:openSkillGithub',
'api:testLocalRequest',
'image:selectDecoder',
'image:openDecoderDownload',
'app-update:check',
'app-update:download',
'app-update:install',
'agent-hub:clearLogs',
'agent-hub:startLogin',
'agent-hub:cancelLogin',
'agent-hub:reconnect',
'agent-hub:disconnect',
'agent-hub:selectTestImage',
'image:listCandidates',
'image:analyze',
'image:getInsight',
'image:listInsights',
'db:search',
'db:getVideo'
]) {
if (!handlers.has(channel))
handle(channel, () => ({ success: true, candidates: [], insights: [] }))
}
app.whenReady().then(() => {
const window = new BrowserWindow({
width: 1440,
height: 960,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
preload: path.join(root, 'out/preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
window.once('ready-to-show', () => window.show())
window.loadFile(path.join(root, 'out/renderer/index.html'))
})
app.on('window-all-closed', () => app.quit())
+57
View File
@@ -0,0 +1,57 @@
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { resolve } from 'path'
import { loadEnv } from 'vite'
const DEFAULT_WINDOW_CLOSE_DELAY_MS = 2000
export interface TestApplication {
app: ElectronApplication
page: Page
userData: string
close: () => Promise<void>
}
export async function launchTestApp(
options: {
mode?: 'connected' | 'disconnected'
userData?: string
largeContacts?: number
corruptCache?: boolean
aiFailure?: string
} = {}
): Promise<TestApplication> {
const ownsDirectory = !options.userData
const userData = options.userData || mkdtempSync(resolve(tmpdir(), 'wxe-e2e-'))
const localTestEnv = loadEnv('test', process.cwd(), 'WXE_E2E_')
const configuredCloseDelay = Number(
process.env.WXE_E2E_CLOSE_DELAY_MS ?? localTestEnv.WXE_E2E_CLOSE_DELAY_MS
)
const closeDelayMs = Number.isFinite(configuredCloseDelay)
? Math.max(0, configuredCloseDelay)
: DEFAULT_WINDOW_CLOSE_DELAY_MS
const app = await electron.launch({
args: [resolve('tests/e2e/support/electron-main.cjs')],
env: {
...process.env,
WXE_E2E_USER_DATA: userData,
WXE_E2E_MODE: options.mode || 'connected',
WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0),
WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0',
WXE_E2E_AI_FAILURE: options.aiFailure || ''
}
})
const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
return {
app,
page,
userData,
close: async () => {
if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs)
await app.close().catch(() => undefined)
if (ownsDirectory) rmSync(userData, { recursive: true, force: true })
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test'
import { existsSync } from 'fs'
import { resolve } from 'path'
import { launchTestApp } from './support/electron'
const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`)
test.skip(
!existsSync(baselineDirectory) && process.env.WXE_UPDATE_VISUAL_BASELINES !== '1',
`No reviewed ${process.platform} visual baseline is committed yet`
)
test('NAV-01 login page visual @visual', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
await expect(fixture.page.getByRole('heading', { name: 'WechatExplorer' })).toBeVisible()
await expect(fixture.page).toHaveScreenshot('login-page.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('ARCH-01 archive page visual @visual', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await expect(fixture.page).toHaveScreenshot('archive-page.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
+97
View File
@@ -0,0 +1,97 @@
{
"self": {
"wxid": "wxid_fixture_self",
"nickname": "测试账号",
"accountRoot": "fixture-account"
},
"contacts": [
{
"m_nsUsrName": "group_regular@chatroom",
"m_nsNickName": "产品测试群",
"md5": "group-regular-md5",
"type": "group",
"avatar": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII="
},
{
"m_nsUsrName": "group_folded@chatroom",
"m_nsNickName": "折叠群聊样本",
"md5": "group-folded-md5",
"type": "group",
"isFolded": true
},
{
"m_nsUsrName": "filehelper",
"m_nsNickName": "文件传输助手",
"md5": "file-helper-md5",
"type": "user"
}
],
"messages": {
"group-regular-md5": [
{
"id": "msg-text",
"from": "user",
"type": "普通文本",
"datetime": "2026-08-01 10:00:00",
"content": "这是一条脱敏测试消息",
"isSender": false,
"name": "测试成员",
"senderId": "wxid_fixture_member",
"createTime": 1785549600,
"contentData": { "type": "text", "content": "这是一条脱敏测试消息" }
},
{
"id": "msg-image",
"from": "user",
"type": "图片",
"datetime": "2026-08-01 10:01:00",
"content": "[图片]",
"isSender": false,
"localId": 2,
"createTime": 1785549660,
"sessionId": "group_regular@chatroom",
"contentData": { "type": "image", "md5": "fixture-image-md5", "datName": "fixture.dat" }
},
{
"id": "msg-unknown",
"from": "user",
"type": "不支持的消息",
"datetime": "2026-08-01 10:02:00",
"content": "[未知消息]",
"isSender": false,
"createTime": 1785549720,
"contentData": { "type": "unknown", "raw": "fixture-unknown" }
}
],
"file-helper-md5": [
{
"id": "msg-voice",
"from": "user",
"type": "语音",
"datetime": "2026-08-01 11:00:00",
"content": "[语音]",
"isSender": false,
"localId": 11,
"createTime": 1785553200,
"sessionId": "filehelper",
"contentData": { "type": "voice", "duration": 1 }
},
{
"id": "msg-forward",
"from": "user",
"type": "合并转发",
"datetime": "2026-08-01 11:01:00",
"content": "[合并转发]",
"isSender": false,
"createTime": 1785553260,
"contentData": {
"type": "forwardBundle",
"title": "转发多条内容",
"items": [
{ "messageType": 1, "sender": "测试成员", "sentAt": "11:00", "text": "脱敏转发内容" }
]
}
}
]
}
}
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const invoke = vi.fn()
const on = vi.fn()
const removeListener = vi.fn()
const exposeInMainWorld = vi.fn()
vi.mock('electron', () => ({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on, removeListener }
}))
vi.mock('@electron-toolkit/preload', () => ({ electronAPI: { fixture: true } }))
async function loadApi(): Promise<typeof window.api> {
vi.resetModules()
exposeInMainWorld.mockClear()
Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true })
await import('../../src/preload/index')
const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')
if (!exposed) throw new Error('preload did not expose api')
return exposed[1] as typeof window.api
}
describe('preload IPC contract', () => {
beforeEach(() => {
invoke.mockReset()
on.mockReset()
removeListener.mockReset()
})
it('forwards message and media parameters to the exact main channels', async () => {
const api = await loadApi()
invoke.mockResolvedValue({ success: true })
await api.getMessages('fixture-user', 10, 20, { limit: 50 })
expect(invoke).toHaveBeenLastCalledWith('db:getMessages', 'fixture-user', 10, 20, {
limit: 50
})
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
force: true,
priority: 0
})
expect(invoke).toHaveBeenLastCalledWith(
'db:getImage',
'fixture-md5',
'fixture.dat',
'fixture-session',
{ force: true, priority: 0 }
)
})
it('preserves key API return values without exposing ipcRenderer', async () => {
const api = await loadApi()
invoke.mockResolvedValueOnce({ success: false, code: 'DATABASE_OPEN_FAILED' })
await expect(api.testConnection('b'.repeat(64), 'fixture-root')).resolves.toEqual({
success: false,
code: 'DATABASE_OPEN_FAILED'
})
expect(invoke).toHaveBeenCalledWith('db:testConnection', 'b'.repeat(64), 'fixture-root')
expect(api).not.toHaveProperty('ipcRenderer')
expect(api).not.toHaveProperty('send')
})
it('unsubscribes the same listener registered for native database changes', async () => {
const api = await loadApi()
const callback = vi.fn()
const unsubscribe = api.onWcdbChange(callback)
expect(on).toHaveBeenCalledWith('wcdb-change', expect.any(Function))
const listener = on.mock.calls.at(-1)?.[1]
listener({}, { type: 'insert', json: '{"fixture":true}' })
expect(callback).toHaveBeenCalledWith({ type: 'insert', json: '{"fixture":true}' })
unsubscribe()
expect(removeListener).toHaveBeenCalledWith('wcdb-change', listener)
})
})
+15
View File
@@ -0,0 +1,15 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => cleanup())
Object.defineProperty(globalThis.URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:wxe-test-audio')
})
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn()
})
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const enabled = process.env.WXE_REAL_DATA_SMOKE === '1'
test(
'native WCDB opens a disposable fixture account on this machine',
{ skip: enabled ? false : 'set WXE_REAL_DATA_SMOKE=1 and WXE_SMOKE_DB_ROOT to opt in' },
() => {
const root = process.env.WXE_SMOKE_DB_ROOT || ''
assert.ok(root, 'WXE_SMOKE_DB_ROOT is required')
assert.ok(fs.existsSync(root), 'WXE_SMOKE_DB_ROOT must exist')
}
)
test('system permission prompts are verified manually on a clean OS account', {
skip: 'manual smoke checklist: docs/testing.md'
})
test('signed installer install, upgrade and uninstall are verified manually', {
skip: 'manual smoke checklist: docs/testing.md'
})
-64
View File
@@ -1,64 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseMessageContent } from '../src/main/message-parser.ts'
import { classifyStickerHttpFailure } from '../src/shared/sticker.ts'
test('merged forwarding messages expose expandable record items', () => {
const content = `
<msg><appmsg><title>项目讨论记录</title><type>19</type>
<recorditem><![CDATA[
<recordinfo>
<dataitem datatype="1">
<sourcename><![CDATA[张三]]></sourcename>
<sourcetime>2026-08-01 10:00</sourcetime>
<datadesc><![CDATA[第一条消息]]></datadesc>
</dataitem>
<dataitem datatype="3">
<sourcename><![CDATA[李四]]></sourcename>
<sourcetime>2026-08-01 10:01</sourcetime>
</dataitem>
</recordinfo>
]]></recorditem>
</appmsg></msg>`
const parsed = parseMessageContent(content, 49)
assert.equal(parsed.type, 'forwardBundle')
assert.equal(parsed.title, '项目讨论记录')
assert.deepEqual(
parsed.items.map((item) => [item.sender, item.text]),
[
['张三', '第一条消息'],
['李四', '[图片]']
]
)
})
test('unknown message types are not misclassified as text', () => {
const parsed = parseMessageContent('<unsupported><payload>1</payload></unsupported>', 9999)
assert.equal(parsed.type, 'unknown')
assert.equal(parsed.messageType, 9999)
})
test('sticker 403 with expired timestamp is classified as an expired link', () => {
const result = classifyStickerHttpFailure(
403,
'https://example.invalid/sticker?expire=1700000000',
1_800_000_000_000
)
assert.equal(result.code, 'link_expired')
})
test('sticker authorization and removal failures remain distinct', () => {
assert.equal(
classifyStickerHttpFailure(401, 'https://example.invalid/sticker').code,
'authentication_required'
)
assert.equal(
classifyStickerHttpFailure(403, 'https://example.invalid/sticker').code,
'access_denied'
)
assert.equal(
classifyStickerHttpFailure(404, 'https://example.invalid/sticker').code,
'resource_removed'
)
})
+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')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { resolve } from 'path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@renderer': resolve(__dirname, 'src/renderer/src')
}
},
test: {
name: 'component',
environment: 'jsdom',
include: ['tests/component/**/*.test.tsx'],
setupFiles: ['tests/setup/component.ts'],
clearMocks: true,
restoreMocks: true
}
})
+17
View File
@@ -0,0 +1,17 @@
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@renderer': resolve(__dirname, 'src/renderer/src')
}
},
test: {
name: 'integration',
environment: 'node',
include: ['tests/integration/**/*.test.ts'],
clearMocks: true,
restoreMocks: true
}
})
+17
View File
@@ -0,0 +1,17 @@
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@renderer': resolve(__dirname, 'src/renderer/src')
}
},
test: {
name: 'unit',
environment: 'node',
include: ['tests/unit/**/*.test.ts'],
clearMocks: true,
restoreMocks: true
}
})