test: 完善 2.3.0 跨平台测试与 CI 稳定性

- 修复 Windows 与 macOS 单元测试环境差异\n- 完善组件、集成与 Electron E2E 测试\n- 修复异步等待、平台路径和环境依赖问题\n- 统一 E2E 窗口尺寸与 Visual 测试策略\n- 提升 GitHub Actions 跨平台 CI 稳定性
This commit is contained in:
Wxw-Gu
2026-09-05 10:38:41 +08:00
parent 60528ffe53
commit 1156362c3c
23 changed files with 123 additions and 119 deletions
+3 -2
View File
@@ -31,10 +31,9 @@ jobs:
cache-dependency-path: services/wechat-connector/go.sum
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install
- name: Install Playwright Chromium
if: runner.os == 'macOS'
run: pnpm exec playwright install chromium
- name: Type check
@@ -64,6 +63,8 @@ jobs:
WXE_E2E_CLOSE_DELAY_MS: 0
- name: Platform visual regression
# GitHub-hosted Windows 无法稳定容纳项目统一的 1400×800 Electron 窗口,Windows visual regression 改由可控 Windows 桌面环境执行。
if: matrix.os != 'windows-latest'
run: pnpm exec playwright test tests/e2e/visual.spec.ts
env:
WXE_E2E_CLOSE_DELAY_MS: 0
-2
View File
@@ -1,2 +0,0 @@
# monitor-automation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tracememo",
"version": "2.2.3",
"version": "2.3.0",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
+11 -5
View File
@@ -7,8 +7,16 @@ import (
"testing"
)
func setTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
}
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir())
setTestHome(t)
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
@@ -37,8 +45,7 @@ func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
}
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
home := setTestHome(t)
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
@@ -50,8 +57,7 @@ func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
}
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
setTestHome(t)
legacyDir, err := LegacyAccountsDir()
if err != nil {
t.Fatal(err)
@@ -308,6 +308,8 @@ function PersonalWechatMacSendDialog({
<>
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
aria-label={displayName}
aria-labelledby={undefined}
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
onOpenAutoFocus={() => {
restoreFocusRef.current =
@@ -170,6 +170,8 @@ export function PersonalWechatWindowsSendDialog({
return (
<Dialog open onOpenChange={(open) => !open && handleClose()}>
<DialogContent
aria-label={displayName}
aria-labelledby={undefined}
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[620px] gap-4 overflow-y-auto p-[22px]"
onOpenAutoFocus={() => {
restoreFocusRef.current =
@@ -65,12 +65,12 @@ export function ReportViewer({
const viewportRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setSendDialogOpen(false)
const frame = window.requestAnimationFrame(() => {
setStatus('')
setImageError('')
setIsSwitchingTemplate(false)
setShareDialogOpen(false)
setSendDialogOpen(false)
setZoom(1)
setFitZoom(1)
setNaturalSize(null)
@@ -224,7 +224,7 @@ function ScheduledReportDialog({
React.useEffect(() => {
if (!open) return
setKeyword('')
setSelectedId(task?.target || task?.group || groups[0]?.md5 || '')
setSelectedId(task?.target || task?.group || '')
setName(task?.name || '')
setScheduleTime(task?.scheduleTime || '09:00')
const persistedRange = task?.reportRange
@@ -236,7 +236,13 @@ function ScheduledReportDialog({
setRangeMessages([])
setMemberCount(null)
setMemberCountAvailable(true)
}, [groups, open, task])
}, [open, task])
React.useEffect(() => {
if (open && !selectedId && groups[0]) {
setSelectedId(groups[0].md5)
}
}, [groups, open, selectedId])
React.useEffect(() => {
if (!open || !selected) return
+11 -1
View File
@@ -10,9 +10,16 @@
--border-color: #dcdcdc;
}
html {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
width: 100%;
min-width: 0;
font-family: var(--wxex-font);
background-color: var(--wxex-bg-app);
color: var(--wxex-text-primary);
@@ -21,7 +28,10 @@ body {
}
#root {
width: 100%;
height: 100%;
min-width: 0;
flex: 1 1 auto;
display: flex;
}
@@ -368,4 +378,4 @@ body {
min-width: 0;
align-items: stretch;
overflow: hidden;
}
}
+8 -6
View File
@@ -290,9 +290,11 @@ describe('daily report controls', () => {
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
const retryModel = screen.getByRole('combobox', { name: '切换模型' })
await waitFor(() => expect(retryModel).toBeEnabled())
await user.click(retryModel)
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
await user.click(screen.getByRole('option', { name: '备用服务 · 备用模型' }))
const fallbackModel = await screen.findByRole('option', { name: '备用服务 · 备用模型' })
expect(fallbackModel).toBeVisible()
await user.click(fallbackModel)
fireEvent.click(retryButton)
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
@@ -577,7 +579,7 @@ describe('daily report controls', () => {
expect(onReveal).not.toHaveBeenCalled()
})
it('opens the current group send dialog with the report PNG preselected', async () => {
it('opens the current group send dialog with the report PNG ready to send', async () => {
const report: GeneratedReportRecord = {
id: 'report-send',
contactId: groupContact.md5,
@@ -611,9 +613,9 @@ describe('daily report controls', () => {
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
const detect = screen.queryByRole('button', { name: '重新检测' })
if (detect) fireEvent.click(detect)
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('测试群日报.png')).toBeVisible()
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
expect(screen.getByRole('region', { name: '日报图片发送' })).toBeVisible()
expect(screen.getByRole('button', { name: '发送日报图片' })).toBeEnabled()
expect(screen.getByRole('region', { name: '日报图片发送' })).toHaveTextContent('测试群日报.png')
})
it('disables current group sending outside macOS with a readable hover hint', () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+29 -32
View File
@@ -16,9 +16,13 @@ test('APP-01 first launch renders a usable connection screen without uncaught er
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo(迹忆)' })).toBeVisible()
await expect(fixture.page.getByRole('main')).not.toBeEmpty()
const loginLayout = await fixture.page.locator('.database-login-page').evaluate((element) => ({
width: Math.round(element.getBoundingClientRect().width),
viewportWidth: window.innerWidth
}))
expect(loginLayout.width).toBe(loginLayout.viewportWidth)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
@@ -63,6 +67,7 @@ test('P0-01 an invalid directory can be corrected and retried without restarting
await fixture.page.getByRole('button', { name: '选择目录' }).click()
await expect(fixture.page.getByLabel('微信数据目录')).toHaveValue('fixture-account')
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
await dismissFirstUseWelcome(fixture.page)
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
} finally {
await fixture.close()
@@ -121,6 +126,13 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await expect(navigation).toBeVisible()
const appShell = fixture.page.locator('.app-shell')
await expect(appShell).toBeVisible()
const shellLayout = await appShell.evaluate((element) => ({
width: Math.round(element.getBoundingClientRect().width),
viewportWidth: window.innerWidth
}))
expect(shellLayout.width).toBe(shellLayout.viewportWidth)
for (const label of labels) {
await expect(navigation.getByRole('button', { name: label })).toHaveCount(1)
await navigation.getByRole('button', { name: label }).click()
@@ -136,7 +148,6 @@ test('NAV-03 exit monitor page shows a member departure event and stays within t
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await expect(fixture.page.getByRole('heading', { name: '退群监控', exact: true })).toBeVisible()
await expect(fixture.page.getByText('测试成员退出了产品测试群', { exact: true })).toBeVisible()
@@ -185,7 +196,6 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
const conversationSearch = fixture.page.getByRole('searchbox', { name: '搜索会话' })
await conversationSearch.fill('产品')
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
@@ -234,7 +244,6 @@ test('CHAT-REALTIME-01 archive refreshes from a native message-shard event', asy
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
@@ -273,7 +282,6 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByText('产品测试群', { exact: true }).click()
const trigger = fixture.page.getByRole('button', { name: '文字转语音' })
await trigger.click()
@@ -290,16 +298,14 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
const bounds = await dialog.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(0)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(
await fixture.page.evaluate(() => window.innerHeight)
)
expect(pageErrors).toEqual([])
const imageMode = dialog.getByRole('radio', { name: '图片' })
const voiceMode = dialog.getByRole('radio', { name: '语音', exact: true })
await imageMode.focus()
await fixture.page.keyboard.press('ArrowRight')
await expect(voiceMode).toBeFocused()
await fixture.page.keyboard.press('Space')
await expect(voiceMode).toBeChecked()
const voiceText = dialog.getByRole('textbox', { name: '语音文字' })
await voiceText.focus()
await expect(voiceText).toBeFocused()
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true
)
@@ -317,7 +323,6 @@ test('GUIDE-01 first-use welcome is keyboard-safe and fits the viewport', async
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
const guideButton = fixture.page.getByRole('button', { name: '新手引导' })
await guideButton.click()
const dialog = fixture.page.getByRole('dialog', { name: '开始探索你的微信' })
@@ -342,7 +347,6 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -382,12 +386,11 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
}
})
test('SETTINGS-02 basic settings controls fit a narrow viewport and keep their semantics', async () => {
test('SETTINGS-02 basic settings controls fit the default desktop window and keep their semantics', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -397,6 +400,9 @@ test('SETTINGS-02 basic settings controls fit a narrow viewport and keep their s
await expect(fixture.page.getByRole('button', { name: '存储与导出' })).toHaveCount(0)
const autoLoginSwitch = fixture.page.getByRole('switch', { name: '启动时自动连接数据库' })
await expect(autoLoginSwitch).toBeVisible()
await expect(autoLoginSwitch).toBeChecked()
await autoLoginSwitch.click()
await expect(autoLoginSwitch).not.toBeChecked()
await autoLoginSwitch.click()
await expect(autoLoginSwitch).toBeChecked()
@@ -453,7 +459,9 @@ test('UPDATE-01 simulated startup update navigates to live progress and never ex
await expect(fixture.page.getByText('v2.0.0 已准备完成')).toBeVisible({ timeout: 5_000 })
await fixture.page.getByRole('button', { name: '立即重启更新' }).click()
await expect(
fixture.page.getByText('开发模拟模式:更新安装动作已模拟,未实际退出应用。')
fixture.page
.getByRole('region', { name: 'Notifications (F8)' })
.getByText('开发模拟模式:更新安装动作已模拟,未实际退出应用。')
).toBeVisible()
await expect(fixture.page.getByRole('heading', { name: '关于' })).toBeVisible()
expect(pageErrors).toEqual([])
@@ -488,7 +496,6 @@ test('SETTINGS-03 database key actions keep destructive confirmation keyboard-sa
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -519,7 +526,6 @@ test('SETTINGS-04 image decryption controls keep filtering and selection keyboar
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -573,7 +579,6 @@ test('SETTINGS-05 AI model editor keeps provider and capability semantics', asyn
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -628,12 +633,11 @@ test('SETTINGS-05 AI model editor keeps provider and capability semantics', asyn
}
})
test('SETTINGS-07 voice recognition controls keep selection semantics at a narrow viewport', async () => {
test('SETTINGS-07 voice recognition controls keep selection semantics at the default desktop size', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -672,12 +676,11 @@ test('SETTINGS-07 voice recognition controls keep selection semantics at a narro
}
})
test('AGENT-01 Agent Hub controls stay usable in the offline narrow layout', async () => {
test('AGENT-01 Agent Hub controls stay usable in the default offline layout', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: 'Agent' }).click()
await expect(fixture.page.getByRole('heading', { name: 'Agent Hub' })).toBeVisible()
await expect(fixture.page.getByText('Agent Hub 未运行')).toBeVisible()
@@ -711,7 +714,6 @@ test('API-01 manages the local API token and previews the Reader Skill safely',
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.page.getByRole('button', { name: 'API' }).click()
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('••••••••••••••••')).toBeVisible()
@@ -766,7 +768,6 @@ test('EXPORT-01 multi-chat selection stays local to export and forces HTML', asy
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click()
@@ -808,7 +809,6 @@ test('EXPORT-02 large contact list stays bounded and searchable', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1400, height: 772 })
await fixture.page.getByRole('button', { name: '导出' }).click()
await expect(fixture.page.getByText('共 1,503 个')).toBeVisible()
@@ -861,12 +861,11 @@ test('EXPORT-02 large contact list stays bounded and searchable', async () => {
}
})
test('LAYOUT-01 core workspaces fit a narrow desktop viewport without page errors', async () => {
test('LAYOUT-01 core workspaces fit the default desktop window without page errors', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
for (const pageName of ['档案', '问问微信', '日报', '导出', 'API', '设置']) {
await navigation.getByRole('button', { name: pageName }).click()
@@ -970,7 +969,6 @@ test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', asyn
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
@@ -1053,7 +1051,6 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await expect(shareDialog).toHaveCount(0)
await expect(moreButton).toBeFocused()
await fixture.setWindowContentSize({ width: 1024, height: 760 })
const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' })
await expect(reportTitle).toBeVisible()
expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170)
+20 -1
View File
@@ -44,6 +44,25 @@ const zipDirectory = async (
})
}
const extractZip = (zipPath: string, extractedDir: string): void => {
if (process.platform === 'win32') {
const escapePowerShellLiteral = (value: string): string => value.replace(/'/g, "''")
execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Expand-Archive -LiteralPath '${escapePowerShellLiteral(zipPath)}' -DestinationPath '${escapePowerShellLiteral(extractedDir)}'`
],
{ windowsHide: true }
)
return
}
execFileSync('unzip', ['-q', zipPath, '-d', extractedDir])
}
test('EXPORT-ARCHIVE-00 shows a loading state while archive data is still loading', async ({
page
}, testInfo) => {
@@ -149,7 +168,7 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
const extractedDir = join(fixtureRoot, 'extracted')
await zipDirectory(outputDir, zipPath, '合并聊天档案')
mkdirSync(extractedDir, { recursive: true })
execFileSync('unzip', ['-q', zipPath, '-d', extractedDir])
extractZip(zipPath, extractedDir)
const offlineIndex = join(extractedDir, '合并聊天档案', 'index.html')
await page.setViewportSize({ width: 1440, height: 900 })
+5 -4
View File
@@ -16,7 +16,6 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
await expect(fixture.page.getByRole('heading', { name: '定时日报', exact: true })).toBeVisible()
@@ -52,8 +51,8 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport
await selects.nth(1).click()
const memberOption = fixture.page.getByRole('option', { name: '微信昵称' })
await expect(memberOption).toBeVisible()
await memberOption.click()
await expect(selects.nth(1)).toContainText('微信昵称')
await memberOption.click({ force: true })
await expect(selects.nth(1)).toContainText('微信昵称', { timeout: 8000 })
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
@@ -61,7 +60,9 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport
const bounds = await dialog.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(0)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(
await fixture.page.evaluate(() => window.innerHeight)
)
await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('E2E 定时日报')
await dialog.getByRole('radio', { name: '今日' }).click()
+6 -3
View File
@@ -7,6 +7,7 @@ const root = path.resolve(__dirname, '../../..')
const fixture = structuredClone(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')
const backgroundE2E = process.env.WXE_E2E_HEADLESS === '1'
app.setPath('userData', userData)
app.setPath('logs', path.join(userData, 'logs'))
app.commandLine.appendSwitch('disable-gpu')
@@ -253,6 +254,7 @@ const groupExitEvent = {
detectedAt: fixtureNowMs - 60 * 1000
}
let groupExitMonitorState = {
enabled: connected,
events: [groupExitEvent],
running: connected,
nativeMonitorActive: connected,
@@ -1215,8 +1217,9 @@ for (const channel of [
app.whenReady().then(() => {
const window = new BrowserWindow({
width: 1440,
height: 960,
// Keep the E2E window aligned with createWindow() in src/main/index.ts.
width: 1400,
height: 800,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
@@ -1226,7 +1229,7 @@ app.whenReady().then(() => {
sandbox: false
}
})
window.once('ready-to-show', () => window.show())
if (!backgroundE2E) window.once('ready-to-show', () => window.show())
window.loadFile(path.join(root, 'out/renderer/index.html'))
})
-19
View File
@@ -10,7 +10,6 @@ export interface TestApplication {
app: ElectronApplication
page: Page
userData: string
setWindowContentSize: (size: { width: number; height: number }) => Promise<void>
close: () => Promise<void>
}
@@ -60,28 +59,10 @@ export async function launchTestApp(
const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
if (options.now) await page.clock.setFixedTime(options.now)
const setWindowContentSize = async (size: { width: number; height: number }): Promise<void> => {
await app.evaluate(({ BrowserWindow, screen }, nextSize) => {
const [window] = BrowserWindow.getAllWindows()
if (!window) throw new Error('E2E BrowserWindow is unavailable')
const { workArea } = screen.getPrimaryDisplay()
window.setPosition(workArea.x + 40, workArea.y + 40)
window.setContentSize(nextSize.width, nextSize.height)
}, size)
await page.waitForFunction(
(nextSize) => window.innerWidth === nextSize.width && window.innerHeight === nextSize.height,
size
)
await page.evaluate(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
}
return {
app,
page,
userData,
setWindowContentSize,
close: async () => {
if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs)
await app.close().catch(() => undefined)
+1 -33
View File
@@ -4,7 +4,6 @@ import { resolve } from 'path'
import { launchTestApp } from './support/electron'
const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`)
const visualViewport = { width: 1400, height: 772 }
const visualNow = Date.parse('2026-08-19T14:46:40+08:00')
async function clearScreenshotFocus(page: import('@playwright/test').Page): Promise<void> {
@@ -21,7 +20,6 @@ test.skip(
test('NAV-01 login page visual @visual', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' })
try {
await fixture.setWindowContentSize(visualViewport)
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo(迹忆)' })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('login-page.png', {
@@ -36,7 +34,6 @@ test('NAV-01 login page visual @visual', async () => {
test('ARCH-01 archive page visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await clearScreenshotFocus(fixture.page)
@@ -54,7 +51,6 @@ test('CHAT-01 archive search controls visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '搜索当前聊天' }).click()
const searchInput = fixture.page.getByRole('textbox', { name: '搜索当前聊天内容' })
@@ -81,7 +77,6 @@ test('ASK-01 AI Search idle page visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await expect(fixture.page.getByRole('heading', { name: '问问你的微信' })).toBeVisible()
await expect(fixture.page.getByPlaceholder(/例如:技术交流群/)).toBeVisible()
@@ -105,7 +100,6 @@ test('ASK-03 AI Search result page visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
@@ -132,7 +126,6 @@ test('ASK-04 AI Search idle page dark visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await expect(fixture.page.getByRole('heading', { name: '问问你的微信' })).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
@@ -155,7 +148,6 @@ test('ASK-05 AI Search result page dark visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
@@ -182,7 +174,6 @@ test('API-00 Reader Skill page visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'API' }).click()
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo Reader' })).toBeVisible()
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
@@ -205,7 +196,6 @@ test('API-00 Reader Skill page dark visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'API' }).click()
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo Reader' })).toBeVisible()
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
@@ -229,7 +219,6 @@ test('API-01 Reader Skill preview visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'API' }).click()
await fixture.page
.locator('#api-reader-skill')
@@ -257,7 +246,6 @@ test('API-02 Agent target segmented control visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'API' }).click()
const targetGroup = fixture.page.getByRole('radiogroup', { name: '选择目标 Agent' })
await targetGroup.evaluate((element) => element.scrollIntoView({ block: 'center' }))
@@ -289,7 +277,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await expect(
fixture.page.getByRole('heading', { name: '退群监控', exact: true })
@@ -317,7 +304,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
await expect(
@@ -346,7 +332,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click()
@@ -375,7 +360,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
@@ -436,7 +420,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
@@ -470,7 +453,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
@@ -516,7 +498,6 @@ test('CHAT-03 image viewer visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '查看图片' }).click()
await expect(fixture.page.getByRole('dialog', { name: '图片查看' })).toBeVisible()
@@ -539,7 +520,6 @@ test('SETTINGS-02 basic settings controls visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -590,7 +570,6 @@ test('UPDATE-01 startup prompt and downloaded state visual @visual', async () =>
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
const dialog = fixture.page.getByRole('alertdialog', { name: '发现新版本 v2.0.0' })
await expect(dialog).toBeVisible()
await clearScreenshotFocus(fixture.page)
@@ -620,7 +599,6 @@ test('UPDATE-02 unsigned macOS release prompt visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
const dialog = fixture.page.getByRole('alertdialog', { name: '发现新版本 v2.2.3' })
await expect(dialog).toBeVisible()
await clearScreenshotFocus(fixture.page)
@@ -656,7 +634,6 @@ test('SETTINGS-03 database key controls visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -690,7 +667,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -722,7 +698,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -758,7 +733,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -796,7 +770,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -827,11 +800,11 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`SETTINGS-06A voice encoding environment ${appearanceTheme} visual @visual`, async () => {
test.skip(process.platform !== 'darwin', 'Voice encoding environment is macOS-only')
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -867,7 +840,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -902,7 +874,6 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'Agent' }).click()
await expect(fixture.page.getByRole('heading', { name: 'Agent Hub' })).toBeVisible()
await expect(fixture.page.getByText('Agent Hub 未运行')).toBeVisible()
@@ -927,7 +898,6 @@ test('EXPORT-01 export workspace idle visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '导出' }).click()
await expect(fixture.page.getByRole('heading', { name: '导出设置' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '开始导出' })).toBeEnabled()
@@ -950,7 +920,6 @@ test('THEME-01 archive page dark visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
@@ -973,7 +942,6 @@ test('THEME-02 export workspace dark visual @visual', async () => {
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '导出' }).click()
await expect(fixture.page.getByRole('heading', { name: '导出设置' })).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
+2 -2
View File
@@ -1047,7 +1047,7 @@ describe('media export flow', () => {
dirname(first.outputPath!),
firstArchive.messages[0].exportMediaUrl as string
)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
expect(statSync(videoPath).mode & 0o200).toBe(0o200)
chmodSync(videoPath, 0o444)
const second = await runExport(
@@ -1057,7 +1057,7 @@ describe('media export flow', () => {
expect(second.success, second.error).toBe(true)
expect(second.outputPath).toBe(first.outputPath)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
expect(statSync(videoPath).mode & 0o200).toBe(0o200)
})
it('merges two conversations in stable order without colliding identical message ids or media', async () => {
+3 -1
View File
@@ -33,7 +33,9 @@ describe('ApiTokenStore', () => {
const first = firstStore.revealToken().token
expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(fs.readFileSync(filePath, 'utf8')).not.toContain(String(first))
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600)
if (process.platform !== 'win32') {
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600)
}
const secondStore = new ApiTokenStore(filePath)
expect(secondStore.ensureToken()).toMatchObject({ success: true, hasToken: true })
+2 -2
View File
@@ -224,7 +224,7 @@ describe('TraceMemo app data migration', () => {
const current = new SqliteTranscriptRepository(currentPath)
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('已经转写过的文字')
current.close()
})
}, 15_000)
it('backfills voice transcripts after the original migration was already completed', async () => {
const fixture = roots()
@@ -264,7 +264,7 @@ describe('TraceMemo app data migration', () => {
)
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('补迁文字')
current.close()
})
}, 15_000)
it('does not retry a voice transcript backfill after it was marked failed', async () => {
const fixture = roots()
+7 -2
View File
@@ -99,8 +99,13 @@ describe('scheduled report scheduling', () => {
expect(validateScheduleTime('09:05')).toBe(true)
expect(validateScheduleTime('24:00')).toBe(false)
const from = new Date('2026-08-27T10:00:00+08:00')
expect(calculateNextRunAt('09:05', from)).toBe('2026-08-28T01:05:00.000Z')
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
const expectedNext = new Date(from)
expectedNext.setHours(9, 5, 0, 0)
if (expectedNext.getTime() <= from.getTime()) expectedNext.setDate(expectedNext.getDate() + 1)
expect(calculateNextRunAt('09:05', from)).toBe(expectedNext.toISOString())
const expectedSameDay = new Date(from)
expectedSameDay.setHours(11, 5, 0, 0)
expect(calculateNextRunAt('11:05', from)).toBe(expectedSameDay.toISOString())
})
it('defaults notification settings to off and persists a successful enablement', async () => {
+1
View File
@@ -19,6 +19,7 @@ describe('StickerService', () => {
beforeEach(() => {
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sticker-service-'))
vi.stubEnv('HOME', homeDir)
vi.stubEnv('USERPROFILE', homeDir)
})
afterEach(async () => {