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
|
# 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
|
||||||
|
|||||||
@@ -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
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
resources/connectors/wechat/
|
resources/connectors/wechat/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
|
|||||||
+18
-3
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
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' })
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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