mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
test: 建立桌面端自动化回归测试体系并完善跨平台 CI
(cherry picked from commit 74267ae2f63f8256c3da84b04f5b330e6e9d4c67)
This commit is contained in:
@@ -22,3 +22,7 @@ VITE_FILTER_MSG_TYPES=
|
||||
# AES Key: 16-character string, derived from wxid and code
|
||||
VITE_IMAGE_XOR_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
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,9 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
|
||||
+18
-3
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"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 .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
@@ -28,7 +29,13 @@
|
||||
"start": "electron-vite preview",
|
||||
"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: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: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",
|
||||
@@ -47,11 +54,11 @@
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
@@ -65,12 +72,18 @@
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@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/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -78,12 +91,14 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jsdom": "^30.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
Generated
+807
-8
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ const sanitize = (value: unknown, depth = 0): unknown => {
|
||||
return value
|
||||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
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',
|
||||
level: entry.level,
|
||||
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 || {})
|
||||
}
|
||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||
|
||||
@@ -203,9 +203,11 @@ function getMimeType(extension) {
|
||||
function strictRemovePadding(buffer) {
|
||||
if (buffer.length === 0) return buffer
|
||||
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) {
|
||||
if (buffer[index] !== paddingLength) return buffer
|
||||
if (buffer[index] !== paddingLength) throw new Error('invalid PKCS#7 padding')
|
||||
}
|
||||
return buffer.subarray(0, buffer.length - paddingLength)
|
||||
}
|
||||
@@ -1636,19 +1638,13 @@ export class ImageDecryptService {
|
||||
private strictRemovePadding(buffer: Buffer): Buffer {
|
||||
if (buffer.length === 0) return buffer
|
||||
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
|
||||
let valid = true
|
||||
for (let i = buffer.length - paddingLength; i < buffer.length; i++) {
|
||||
if (buffer[i] !== lastByte) {
|
||||
valid = false
|
||||
break
|
||||
for (let i = buffer.length - paddingLength; i < buffer.length; i += 1) {
|
||||
if (buffer[i] !== lastByte) throw new Error('invalid PKCS#7 padding')
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
return buffer.subarray(0, buffer.length - paddingLength)
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ import { FirstUseWelcome } from './components/FirstUseWelcome'
|
||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||
import {
|
||||
getMessageIdentity,
|
||||
mergeMessagePages,
|
||||
sortMessagesChronologically
|
||||
} from './utils/message-pages'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -47,12 +52,6 @@ const INITIAL_MESSAGE_COUNT = 20
|
||||
const MESSAGE_PAGE_SIZE = 100
|
||||
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||
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 =>
|
||||
String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -194,19 +193,6 @@ const 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 {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 |
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Vendored
+97
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user