mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
feat: 完成 UI 基础层与核心页面迁移
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AISearchComposer,
|
||||
type AISearchComposerProps
|
||||
} from '../../src/renderer/src/components/search/AISearchComposer'
|
||||
|
||||
const renderComposer = (
|
||||
overrides: Partial<AISearchComposerProps> = {}
|
||||
): { props: AISearchComposerProps } => {
|
||||
const props: AISearchComposerProps = {
|
||||
query: '当前问题',
|
||||
sourceLabel: '所有聊天记录',
|
||||
rangeLabel: '近 30 天',
|
||||
history: ['历史问题一'],
|
||||
historyOpen: false,
|
||||
loading: false,
|
||||
knowledgeSyncing: false,
|
||||
onQueryChange: vi.fn(),
|
||||
onHistoryOpenChange: vi.fn(),
|
||||
onRestoreHistory: vi.fn(),
|
||||
onRemoveHistory: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
render(<AISearchComposer {...props} />)
|
||||
return { props }
|
||||
}
|
||||
|
||||
describe('AISearchComposer', () => {
|
||||
it('submits with the form or Enter while preserving Shift+Enter and IME composition', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderComposer()
|
||||
const textbox = screen.getByRole('textbox')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /开始分析/ }))
|
||||
expect(props.onSubmit).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.keyDown(textbox, { key: 'Enter' })
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: true })
|
||||
fireEvent.keyDown(textbox, { key: 'Enter', isComposing: true })
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('updates the controlled query and routes loading cancellation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderComposer({ loading: true })
|
||||
|
||||
await user.type(screen.getByRole('textbox'), '新')
|
||||
expect(props.onQueryChange).toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: /取消分析/ }))
|
||||
expect(props.onCancel).toHaveBeenCalledOnce()
|
||||
expect(props.onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes history restore, delete, and close interactions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderComposer({ historyOpen: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '历史问题一' }))
|
||||
expect(props.onRestoreHistory).toHaveBeenCalledWith('历史问题一')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '删除历史问题:历史问题一' }))
|
||||
expect(props.onRemoveHistory).toHaveBeenCalledWith('历史问题一')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭历史提问' }))
|
||||
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('disables submission while Knowledge is syncing', () => {
|
||||
renderComposer({ knowledgeSyncing: true })
|
||||
expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
|
||||
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
|
||||
import { makeCacheRecord, makePipelineEvidence } from './support/ai-search-fixtures'
|
||||
|
||||
const evidence = makeCacheRecord({
|
||||
query: '证据测试',
|
||||
evidence: [makePipelineEvidence(1)]
|
||||
}).evidence as EvidenceItem[]
|
||||
|
||||
const renderPanel = (
|
||||
overrides: Partial<React.ComponentProps<typeof AISearchEvidencePanel>> = {}
|
||||
): React.ComponentProps<typeof AISearchEvidencePanel> => {
|
||||
const props: React.ComponentProps<typeof AISearchEvidencePanel> = {
|
||||
evidence,
|
||||
collectionCount: 2,
|
||||
selectedEvidence: 0,
|
||||
evidenceFlash: { index: -1, nonce: 0 },
|
||||
senderNames: { 'sender-1': '发送者 1' },
|
||||
hasMoreEvidence: true,
|
||||
onFocusEvidence: vi.fn(),
|
||||
onJumpToEvidence: vi.fn(),
|
||||
onLoadMoreEvidence: vi.fn(),
|
||||
setEvidenceCardRef: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
render(<AISearchEvidencePanel {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('AISearchEvidencePanel', () => {
|
||||
it('renders stable Evidence labels and routes focus, jump, and load-more callbacks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const props = renderPanel()
|
||||
|
||||
expect(screen.getByText('1/2 条样本')).toBeVisible()
|
||||
expect(screen.getByText(/E1 · 发送者 1/)).toBeVisible()
|
||||
expect(screen.getByText('证据 1')).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '选择证据 E1,发送者 1' }))
|
||||
expect(props.onFocusEvidence).toHaveBeenCalledWith(0)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' }))
|
||||
expect(props.onJumpToEvidence).toHaveBeenCalledWith(0)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '加载更多证据' }))
|
||||
expect(props.onLoadMoreEvidence).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('supports keyboard focus selection and the focus-flash state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const props = renderPanel({ evidenceFlash: { index: 0, nonce: 1 } })
|
||||
const card = screen.getByText(/E1 · 发送者 1/).closest('article')
|
||||
const selection = screen.getByRole('button', { name: '选择证据 E1,发送者 1' })
|
||||
|
||||
expect(card).toHaveClass('ai-search-evidence-focus-flash')
|
||||
selection.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(props.onFocusEvidence).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('uses the shared EmptyState when no Evidence is visible', () => {
|
||||
renderPanel({ evidence: [], collectionCount: 0, hasMoreEvidence: false })
|
||||
|
||||
expect(screen.getByText('等待检索结果')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '加载更多证据' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -132,4 +132,26 @@ describe('useExternalProviderConsent', () => {
|
||||
expect(resolved).toHaveBeenCalledOnce()
|
||||
expect(resolved).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'settles the previous pending request when a new request replaces it, then resolves %s',
|
||||
async (secondDecision) => {
|
||||
const { result } = renderHook(() => useExternalProviderConsent())
|
||||
let first!: Promise<boolean>
|
||||
let second!: Promise<boolean>
|
||||
act(() => {
|
||||
first = result.current.requestExternalProviderConsent('First', 'first-recipient')
|
||||
second = result.current.requestExternalProviderConsent('Second', 'second-recipient')
|
||||
})
|
||||
|
||||
await expect(first).resolves.toBe(false)
|
||||
expect(result.current.externalProviderConsent).toEqual({
|
||||
providerName: 'Second',
|
||||
recipient: 'second-recipient'
|
||||
})
|
||||
|
||||
act(() => result.current.settleExternalProviderConsent(secondDecision))
|
||||
await expect(second).resolves.toBe(secondDecision)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,8 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace'
|
||||
import {
|
||||
SEARCH_CACHE_KEY,
|
||||
SEARCH_HISTORY_KEY,
|
||||
buildSearchCacheKey
|
||||
SEARCH_HISTORY_KEY
|
||||
} from '../../src/renderer/src/components/search/searchUtils'
|
||||
import {
|
||||
aiSearchContact,
|
||||
@@ -45,12 +44,11 @@ const readyKnowledgeStatus = {
|
||||
shmBytes: 32
|
||||
}
|
||||
|
||||
let knowledgeListener: ((status: typeof readyKnowledgeStatus) => void) | undefined
|
||||
let progressListener: ((progress: Record<string, unknown>) => void) | undefined
|
||||
let knowledgeUnsubscribe: ReturnType<typeof vi.fn>
|
||||
let progressUnsubscribe: ReturnType<typeof vi.fn>
|
||||
|
||||
const makeProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
const makeProps = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
contacts: [aiSearchContact, aiSearchGroup],
|
||||
selectedContact: aiSearchContact,
|
||||
dbReady: true,
|
||||
@@ -68,17 +66,17 @@ const makeProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
const renderWorkspace = (overrides: Record<string, unknown> = {}) =>
|
||||
const renderWorkspace = (overrides: Record<string, unknown> = {}): ReturnType<typeof render> =>
|
||||
render(<AISearchWorkspace {...(makeProps(overrides) as never)} />)
|
||||
|
||||
const submitQuery = async (query = '测试搜索问题') => {
|
||||
const submitQuery = async (query = '测试搜索问题'): Promise<ReturnType<typeof userEvent.setup>> => {
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByRole('textbox'), query)
|
||||
await user.click(screen.getByRole('button', { name: /开始分析/ }))
|
||||
return user
|
||||
}
|
||||
|
||||
const emitProgress = async (progress: Record<string, unknown>) => {
|
||||
const emitProgress = async (progress: Record<string, unknown>): Promise<void> => {
|
||||
await act(async () => {
|
||||
progressListener?.(progress)
|
||||
})
|
||||
@@ -88,7 +86,6 @@ beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
knowledgeListener = undefined
|
||||
progressListener = undefined
|
||||
knowledgeUnsubscribe = vi.fn()
|
||||
progressUnsubscribe = vi.fn()
|
||||
@@ -97,10 +94,7 @@ beforeEach(() => {
|
||||
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
|
||||
api.getAppLogPath.mockResolvedValue('')
|
||||
api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus)
|
||||
api.onKnowledgeStatus.mockImplementation((listener: typeof knowledgeListener) => {
|
||||
knowledgeListener = listener
|
||||
return knowledgeUnsubscribe
|
||||
})
|
||||
api.onKnowledgeStatus.mockImplementation(() => knowledgeUnsubscribe)
|
||||
api.onAiSearchProgress.mockImplementation((listener: typeof progressListener) => {
|
||||
progressListener = listener
|
||||
return progressUnsubscribe
|
||||
@@ -193,6 +187,55 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
expect(screen.queryByText('测试搜索答案')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no_evidence', '近 30 天内没有找到与问题相关的聊天消息。'],
|
||||
['retrieval_incomplete', '证据已就绪'],
|
||||
['failed', '本地检索失败'],
|
||||
['ai_failed', '证据已找到,但 AI 暂时无法生成回答']
|
||||
] as const)('does not persist History or Cache for %s', async (status, expectedText) => {
|
||||
api.runAiSearch.mockResolvedValue(
|
||||
makeSearchResult({
|
||||
status,
|
||||
error: status === 'no_evidence' ? '应被忽略' : expectedText,
|
||||
evidence: [makePipelineEvidence(1)]
|
||||
})
|
||||
)
|
||||
renderWorkspace()
|
||||
await submitQuery(`${status} 问题`)
|
||||
|
||||
expect(await screen.findByText(expectedText)).toBeInTheDocument()
|
||||
expect(localStorage.getItem(SEARCH_HISTORY_KEY)).toBeNull()
|
||||
expect(localStorage.getItem(SEARCH_CACHE_KEY)).toBeNull()
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('applies common Evidence state before rejecting a completed result without an answer', async () => {
|
||||
api.runAiSearch.mockResolvedValue({
|
||||
...makeSearchResult({ evidence: [makePipelineEvidence(1)] }),
|
||||
answer: undefined
|
||||
})
|
||||
renderWorkspace()
|
||||
await submitQuery('缺少回答')
|
||||
|
||||
expect(await screen.findByText('搜索任务未返回回答')).toBeInTheDocument()
|
||||
expect(screen.getByText('E1 · 发送者 1')).toBeInTheDocument()
|
||||
expect(localStorage.getItem(SEARCH_HISTORY_KEY)).toBeNull()
|
||||
expect(localStorage.getItem(SEARCH_CACHE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an explicitly empty Evidence Collection separate from Final Evidence', async () => {
|
||||
api.runAiSearch.mockResolvedValue(
|
||||
makeSearchResult({ evidence: [makePipelineEvidence(1)], evidenceCollection: [] })
|
||||
)
|
||||
renderWorkspace()
|
||||
await submitQuery('空浏览集合')
|
||||
|
||||
expect(await screen.findByText('测试搜索答案')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'E1' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('等待检索结果')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles an IPC rejection from the Search Worker as an insufficient result', async () => {
|
||||
api.runAiSearch.mockRejectedValue(new Error('Worker IPC 连接断开'))
|
||||
renderWorkspace()
|
||||
@@ -218,7 +261,9 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
})
|
||||
|
||||
it('loads more Evidence from the current collection without calling runAiSearch again', async () => {
|
||||
const evidenceCollection = Array.from({ length: 9 }, (_, index) => makePipelineEvidence(index + 1))
|
||||
const evidenceCollection = Array.from({ length: 9 }, (_, index) =>
|
||||
makePipelineEvidence(index + 1)
|
||||
)
|
||||
api.runAiSearch.mockResolvedValue(
|
||||
makeSearchResult({ evidence: evidenceCollection.slice(0, 8), evidenceCollection })
|
||||
)
|
||||
@@ -263,7 +308,12 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
|
||||
await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledTimes(2))
|
||||
expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument()
|
||||
resolveSecond?.(makeSearchResult({ requestId: api.runAiSearch.mock.calls[1][0].requestId, evidence: [secondEvidence] }))
|
||||
resolveSecond?.(
|
||||
makeSearchResult({
|
||||
requestId: api.runAiSearch.mock.calls[1][0].requestId,
|
||||
evidence: [secondEvidence]
|
||||
})
|
||||
)
|
||||
expect(await screen.findByText('E2 · 发送者 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -352,6 +402,32 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('settles duplicate consent submissions without starting duplicate provider flows', async () => {
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({
|
||||
configured: true,
|
||||
requiresConsent: true,
|
||||
providerId: 'remote-provider',
|
||||
providerName: 'Remote Provider',
|
||||
recipient: 'remote@example.test'
|
||||
})
|
||||
api.runAiSearch.mockResolvedValue(makeSearchResult())
|
||||
renderWorkspace()
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByRole('textbox'), '重复授权问题')
|
||||
const form = screen.getByRole('textbox').closest('form') as HTMLFormElement
|
||||
|
||||
fireEvent.submit(form)
|
||||
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
|
||||
fireEvent.submit(form)
|
||||
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalledTimes(2))
|
||||
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '继续并发送' }))
|
||||
await screen.findByText('测试搜索答案')
|
||||
expect(api.authorizeAiSearchExternalProvider).toHaveBeenCalledOnce()
|
||||
expect(api.runAiSearch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not start Search when the user rejects consent', async () => {
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({
|
||||
configured: true,
|
||||
@@ -429,9 +505,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
const query = '没有证据的缓存'
|
||||
localStorage.setItem(
|
||||
SEARCH_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] })
|
||||
])
|
||||
JSON.stringify([makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] })])
|
||||
)
|
||||
renderWorkspace()
|
||||
await submitQuery(query)
|
||||
@@ -443,8 +517,12 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
|
||||
it('does not reuse the previous successful result for a new query', async () => {
|
||||
api.runAiSearch
|
||||
.mockResolvedValueOnce(makeSearchResult({ answer: '第一轮答案', evidence: [makePipelineEvidence(1)] }))
|
||||
.mockResolvedValueOnce(makeSearchResult({ answer: '第二轮答案', evidence: [makePipelineEvidence(2)] }))
|
||||
.mockResolvedValueOnce(
|
||||
makeSearchResult({ answer: '第一轮答案', evidence: [makePipelineEvidence(1)] })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeSearchResult({ answer: '第二轮答案', evidence: [makePipelineEvidence(2)] })
|
||||
)
|
||||
renderWorkspace()
|
||||
const user = await submitQuery('第一轮问题')
|
||||
await screen.findByText('第一轮答案')
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ChatImageViewer } from '../../src/renderer/src/components/chat/ChatImageViewer'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
|
||||
const imageUrl =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
|
||||
|
||||
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
查看图片
|
||||
</button>
|
||||
{open && (
|
||||
<ChatImageViewer
|
||||
imageUrl={imageUrl}
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ChatImageViewer', () => {
|
||||
it('zooms, rotates, drags, and resets the image', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
await user.click(screen.getByRole('button', { name: '查看图片' }))
|
||||
const image = screen.getByAltText('图片预览')
|
||||
const stage = screen.getByLabelText('图片查看区域')
|
||||
|
||||
expect(screen.getByText('100%')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: '放大' }))
|
||||
expect(screen.getByText('110%')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: '右旋转' }))
|
||||
expect(image).toHaveStyle({ transform: 'translate(0px, 0px) scale(1.1) rotate(90deg)' })
|
||||
|
||||
fireEvent.mouseDown(stage, { clientX: 10, clientY: 15 })
|
||||
fireEvent.mouseMove(stage, { clientX: 35, clientY: 45 })
|
||||
fireEvent.mouseUp(stage)
|
||||
expect(image).toHaveStyle({ transform: 'translate(25px, 30px) scale(1.1) rotate(90deg)' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重置图片' }))
|
||||
expect(screen.getByText('100%')).toBeVisible()
|
||||
expect(image).toHaveStyle({ transform: 'translate(0px, 0px) scale(1) rotate(0deg)' })
|
||||
})
|
||||
|
||||
it('closes with Escape or the overlay and restores focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<Harness onClose={onClose} />)
|
||||
const opener = screen.getByRole('button', { name: '查看图片' })
|
||||
|
||||
await user.click(opener)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '图片查看' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
|
||||
await user.click(opener)
|
||||
const dialog = screen.getByRole('dialog', { name: '图片查看' })
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(screen.queryByRole('dialog', { name: '图片查看' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
|
||||
import { ExportMenu } from '../../src/renderer/src/components/chat/ExportMenu'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
|
||||
const contact: Contact = {
|
||||
md5: 'group-md5',
|
||||
m_nsUsrName: 'group@chatroom',
|
||||
m_nsNickName: '测试群',
|
||||
type: 'group'
|
||||
}
|
||||
|
||||
describe('chat menus', () => {
|
||||
it('runs the selected export range and closes the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onExport = vi.fn()
|
||||
render(<ExportMenu disabled={false} onExport={onExport} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '导出' })
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('menuitem', { name: '导出近 7 天' }))
|
||||
|
||||
expect(onExport).toHaveBeenCalledWith(7)
|
||||
expect(screen.queryByRole('menuitem', { name: '导出近 7 天' })).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes the chat More menu with Escape and invokes refresh data once', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRefreshData = vi.fn()
|
||||
render(
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
loadedCount={3}
|
||||
filteredCount={3}
|
||||
contentFilter=""
|
||||
isAiLoading={false}
|
||||
onContentFilterChange={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
onRefreshData={onRefreshData}
|
||||
onTestSend={vi.fn()}
|
||||
onOpenAiSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '更多' })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('menuitem', { name: '刷新数据' })).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('menuitem', { name: '刷新数据' }))
|
||||
expect(onRefreshData).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -69,7 +69,9 @@ describe('export progress panel', () => {
|
||||
|
||||
const progressbar = screen.getByRole('progressbar', { name: '导出进度' })
|
||||
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
|
||||
expect(progressbar.querySelector('span')).toHaveStyle({ width: '31%' })
|
||||
expect(progressbar.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
|
||||
transform: 'translateX(-69%)'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the current conversation and overall position for all export', () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('ExportWorkspace multi-chat selection', () => {
|
||||
onExportProgress: vi.fn(() => vi.fn()),
|
||||
getVoiceModelStatus: vi.fn().mockRejectedValue(new Error('fixture model unavailable')),
|
||||
getGroupSnapshot: vi.fn(async () => ({ members: [] })),
|
||||
selectExportDirectory: vi.fn(async () => ({ canceled: false, path: '/fixture/export' })),
|
||||
cancelExport: vi.fn(async () => ({ success: true })),
|
||||
revealExport: vi.fn(async () => ({ success: true }))
|
||||
}
|
||||
@@ -197,6 +198,25 @@ describe('ExportWorkspace multi-chat selection', () => {
|
||||
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
|
||||
expect(loadPreviewMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps custom dates and output directory selection in the workspace request', async () => {
|
||||
const onStartExport = vi.fn(async () => ({ success: false }))
|
||||
renderWorkspace(onStartExport)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '自定义时间' }))
|
||||
await userEvent.type(screen.getByLabelText('开始时间'), '2026-08-01T09:30')
|
||||
await userEvent.type(screen.getByLabelText('结束时间'), '2026-08-02T18:45')
|
||||
await userEvent.click(screen.getByRole('button', { name: '选择位置' }))
|
||||
|
||||
expect(await screen.findByText('/fixture/export/聊天 A_聊天档案.csv')).toBeVisible()
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
|
||||
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
|
||||
expect(onStartExport.mock.calls[0][0]).toMatchObject({
|
||||
outputDirectory: '/fixture/export',
|
||||
startTime: Math.floor(new Date('2026-08-01T09:30').getTime() / 1000),
|
||||
endTime: Math.floor(new Date('2026-08-02T18:45').getTime() / 1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExportTaskCenter details', () => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ExternalProviderConsentDialog,
|
||||
type ExternalProviderConsent
|
||||
} from '../../src/renderer/src/components/search/ExternalProviderConsentDialog'
|
||||
|
||||
const consent: ExternalProviderConsent = {
|
||||
providerName: 'Remote Provider',
|
||||
recipient: 'remote@example.test'
|
||||
}
|
||||
|
||||
describe('ExternalProviderConsentDialog', () => {
|
||||
it('renders provider details and confirms the controlled action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(
|
||||
<ExternalProviderConsentDialog consent={consent} onConfirm={onConfirm} onCancel={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '确认发送本次搜索资料' })).toBeVisible()
|
||||
expect(screen.getByText('Remote Provider')).toBeVisible()
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('remote@example.test')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '继续并发送' }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('treats the cancel button, Escape, and outside interaction as cancellation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
const { rerender } = render(
|
||||
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(
|
||||
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(onCancel).toHaveBeenCalledTimes(2)
|
||||
|
||||
rerender(
|
||||
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
|
||||
)
|
||||
const overlay = screen.getByRole('dialog').previousElementSibling as HTMLElement
|
||||
await user.click(overlay)
|
||||
expect(onCancel).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('restores focus to the opener after the dialog is cancelled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
打开授权确认
|
||||
</button>
|
||||
<ExternalProviderConsentDialog
|
||||
consent={open ? consent : null}
|
||||
onConfirm={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
const opener = screen.getByRole('button', { name: '打开授权确认' })
|
||||
await user.click(opener)
|
||||
expect(screen.getByRole('dialog', { name: '确认发送本次搜索资料' })).toBeVisible()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument()
|
||||
expect(opener).toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FirstUseWelcome } from '../../src/renderer/src/components/FirstUseWelcome'
|
||||
|
||||
describe('FirstUseWelcome', () => {
|
||||
it('closes with Escape or the overlay and restores focus to the opener', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
打开新手引导
|
||||
</button>
|
||||
{open && (
|
||||
<FirstUseWelcome
|
||||
onDismiss={() => setOpen(false)}
|
||||
onOpenSearch={vi.fn()}
|
||||
onOpenReport={vi.fn()}
|
||||
onOpenAISettings={vi.fn()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
const opener = screen.getByRole('button', { name: '打开新手引导' })
|
||||
await user.click(opener)
|
||||
expect(screen.getByRole('dialog', { name: '开始探索你的微信' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '开始探索你的微信' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
|
||||
await user.click(opener)
|
||||
const dialog = screen.getByRole('dialog', { name: '开始探索你的微信' })
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(screen.queryByRole('dialog', { name: '开始探索你的微信' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
})
|
||||
|
||||
it('keeps the feature callbacks and guide link intact', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenSearch = vi.fn()
|
||||
const onOpenReport = vi.fn()
|
||||
const onOpenAISettings = vi.fn()
|
||||
render(
|
||||
<FirstUseWelcome
|
||||
onDismiss={vi.fn()}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onOpenReport={onOpenReport}
|
||||
onOpenAISettings={onOpenAISettings}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /试试 AI 群聊日报/ }))
|
||||
expect(onOpenReport).toHaveBeenCalledOnce()
|
||||
await user.click(screen.getByRole('button', { name: '问问你的微信' }))
|
||||
expect(onOpenSearch).toHaveBeenCalledOnce()
|
||||
await user.click(screen.getByRole('button', { name: '配置 AI 模型' }))
|
||||
expect(onOpenAISettings).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,13 @@ describe('ImageBubble', () => {
|
||||
await userEvent.click(image)
|
||||
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
|
||||
expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true })
|
||||
|
||||
onImageClick.mockClear()
|
||||
requestImage.mockResolvedValueOnce({ data: original, isThumbnail: false })
|
||||
const trigger = screen.getByRole('button', { name: '查看图片' })
|
||||
trigger.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
|
||||
})
|
||||
|
||||
it('shows an explicit error and allows retry', async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
||||
|
||||
@@ -47,6 +49,27 @@ const readyStatus = {
|
||||
message: '个人微信已绑定'
|
||||
}
|
||||
|
||||
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
打开测试发送
|
||||
</button>
|
||||
{open && (
|
||||
<PersonalWechatSendDialog
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PersonalWechatSendDialog', () => {
|
||||
beforeEach(() => {
|
||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||
@@ -208,4 +231,43 @@ describe('PersonalWechatSendDialog', () => {
|
||||
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('closes with Escape or the overlay and restores focus to the opener', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<Harness onClose={onClose} />)
|
||||
const opener = screen.getByRole('button', { name: '打开测试发送' })
|
||||
|
||||
await user.click(opener)
|
||||
await screen.findByText('技术交流群')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
|
||||
await user.click(opener)
|
||||
const dialog = await screen.findByRole('dialog', { name: '个人微信测试发送' })
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the dialog open while a message is being sent', async () => {
|
||||
const user = userEvent.setup()
|
||||
sendMessage.mockImplementation(() => new Promise(() => undefined))
|
||||
render(<Harness />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开测试发送' }))
|
||||
await screen.findByText('技术交流群')
|
||||
await user.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
await user.click(
|
||||
screen.getByRole('dialog', { name: '个人微信测试发送' }).previousElementSibling as HTMLElement
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
|
||||
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
|
||||
@@ -6,6 +7,7 @@ import { ReportTemplateSelector } from '../../src/renderer/src/components/report
|
||||
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
|
||||
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
|
||||
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
|
||||
import { ReportHistorySidebar } from '../../src/renderer/src/components/reports/ReportHistorySidebar'
|
||||
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
@@ -434,7 +436,8 @@ describe('daily report controls', () => {
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('keeps the file action inside More and labels both AI model roles', () => {
|
||||
it('keeps the file action inside More and labels both AI model roles', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<>
|
||||
<ReportToolbar
|
||||
@@ -469,13 +472,53 @@ describe('daily report controls', () => {
|
||||
</>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多' }))
|
||||
expect(screen.getByRole('button', { name: '打开文件夹' })).toBeVisible()
|
||||
expect(screen.queryByRole('menuitem', { name: '生成微信卡片' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '更多' }))
|
||||
expect(screen.getByRole('menuitem', { name: '生成微信卡片' })).toBeVisible()
|
||||
expect(screen.getByRole('menuitem', { name: '打开文件夹' })).toBeVisible()
|
||||
expect(screen.getByText('文字模型')).toBeVisible()
|
||||
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
|
||||
expect(screen.getByText('图片模型')).toBeVisible()
|
||||
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('menuitem', { name: '生成微信卡片' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '更多' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps unavailable toolbar actions disabled inside More', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onReveal = vi.fn()
|
||||
const onShare = vi.fn()
|
||||
|
||||
render(
|
||||
<ReportToolbar
|
||||
canCopyImage={false}
|
||||
canReveal={false}
|
||||
canShare={false}
|
||||
canSwitchTemplate={false}
|
||||
isSwitchingTemplate={false}
|
||||
onSwitchTemplate={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn()}
|
||||
onReveal={onReveal}
|
||||
onShare={onShare}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '复制图片' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '打开报告' })).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '更多' }))
|
||||
const shareItem = screen.getByRole('menuitem', { name: '生成微信卡片' })
|
||||
const revealItem = screen.getByRole('menuitem', { name: '打开文件夹' })
|
||||
expect(shareItem).toHaveAttribute('data-disabled')
|
||||
expect(revealItem).toHaveAttribute('data-disabled')
|
||||
await user.click(shareItem)
|
||||
await user.click(revealItem)
|
||||
expect(onShare).not.toHaveBeenCalled()
|
||||
expect(onReveal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the current group send dialog with the report PNG preselected', async () => {
|
||||
@@ -550,6 +593,7 @@ describe('daily report controls', () => {
|
||||
})
|
||||
|
||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-switch',
|
||||
@@ -579,10 +623,10 @@ describe('daily report controls', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换模板' }))
|
||||
await user.click(screen.getByRole('button', { name: '切换模板' }))
|
||||
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
|
||||
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
|
||||
await user.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
|
||||
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
|
||||
|
||||
rerender(
|
||||
@@ -640,6 +684,96 @@ describe('daily report controls', () => {
|
||||
expect(screen.getByText('wxid-one')).toBeVisible()
|
||||
})
|
||||
|
||||
it('cancels report deletion and restores focus to the delete trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-delete-cancel',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-17',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready'
|
||||
}
|
||||
|
||||
render(
|
||||
<ReportHistorySidebar
|
||||
reports={[report]}
|
||||
selectedReportId={report.id}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onSelectReport={vi.fn()}
|
||||
onCreateReport={vi.fn()}
|
||||
onDeleteReport={vi.fn(async () => ({ success: true }))}
|
||||
onOpenSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const deleteTrigger = screen.getByRole('button', { name: '删除日报' })
|
||||
await user.click(deleteTrigger)
|
||||
expect(screen.getByRole('alertdialog', { name: '删除日报?' })).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('alertdialog', { name: '删除日报?' })).not.toBeInTheDocument()
|
||||
expect(deleteTrigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('prevents duplicate report deletion, keeps failures open, and closes after success', async () => {
|
||||
const user = userEvent.setup()
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-delete-result',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-17',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready'
|
||||
}
|
||||
let settleDelete: ((result: { success: boolean; error?: string }) => void) | undefined
|
||||
const onDeleteReport = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
settleDelete = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
|
||||
render(
|
||||
<ReportHistorySidebar
|
||||
reports={[report]}
|
||||
selectedReportId={report.id}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onSelectReport={vi.fn()}
|
||||
onCreateReport={vi.fn()}
|
||||
onDeleteReport={onDeleteReport}
|
||||
onOpenSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '删除日报' }))
|
||||
await user.click(screen.getByRole('button', { name: '删除', exact: true }))
|
||||
const pendingButton = screen.getByRole('button', { name: '删除中…' })
|
||||
expect(pendingButton).toBeDisabled()
|
||||
await user.click(pendingButton)
|
||||
expect(onDeleteReport).toHaveBeenCalledTimes(1)
|
||||
|
||||
settleDelete?.({ success: false, error: '文件正在使用' })
|
||||
expect(await screen.findByText('文件正在使用')).toBeVisible()
|
||||
expect(screen.getByRole('alertdialog', { name: '删除日报?' })).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '删除', exact: true }))
|
||||
await waitFor(() => expect(onDeleteReport).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('alertdialog', { name: '删除日报?' })).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('offers the classic default plus three mobile and two desktop report templates', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SkillPreviewDialog } from '../../src/renderer/src/features/api-center/components/SkillPreviewDialog'
|
||||
|
||||
const content = `# TraceMemo Reader
|
||||
|
||||
## 能力
|
||||
- 读取本地聊天记录
|
||||
|
||||
仅在用户授权后访问。`
|
||||
|
||||
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
预览 Skill
|
||||
</button>
|
||||
{open && (
|
||||
<SkillPreviewDialog
|
||||
content={content}
|
||||
version="v1.2"
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkillPreviewDialog', () => {
|
||||
it('renders the line-based preview and switches between rendered and raw content', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '预览 Skill' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
|
||||
expect(dialog).toBeVisible()
|
||||
expect(screen.getByText('v1.2')).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: '能力' })).toBeVisible()
|
||||
expect(screen.getByText('读取本地聊天记录')).toBeVisible()
|
||||
expect(screen.queryByText(content)).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '原始文本' }))
|
||||
expect(dialog.querySelector('pre')?.textContent).toBe(content)
|
||||
await user.click(screen.getByRole('button', { name: '渲染预览' }))
|
||||
expect(screen.getByRole('heading', { name: '能力' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('closes with Escape or the overlay and restores focus to the opener', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<Harness onClose={onClose} />)
|
||||
const opener = screen.getByRole('button', { name: '预览 Skill' })
|
||||
|
||||
await user.click(opener)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
|
||||
).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
|
||||
await user.click(opener)
|
||||
const dialog = screen.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
|
||||
).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,15 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
|
||||
|
||||
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
|
||||
isMac: true,
|
||||
isWindows: false,
|
||||
runtimePlatform: 'darwin',
|
||||
supportsPersonalWechatSend: true
|
||||
}))
|
||||
|
||||
const getSettings = vi.fn()
|
||||
const saveSettings = vi.fn()
|
||||
const listVoices = vi.fn()
|
||||
@@ -45,6 +53,7 @@ const response = {
|
||||
|
||||
describe('TextToSpeechPage', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
getSettings.mockReset().mockResolvedValue(response)
|
||||
listVoices.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
@@ -126,4 +135,40 @@ describe('TextToSpeechPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
|
||||
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('opens supported versions from both triggers and restores focus after closing', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
const guideTrigger = await screen.findByRole('button', { name: '查看支持版本' })
|
||||
await user.click(guideTrigger)
|
||||
expect(screen.getByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
|
||||
expect(screen.getByText('请安装下列完整版本之一。')).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: /下载微信历史版本/ })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/zsbai/wechat-versions/releases'
|
||||
)
|
||||
expect(screen.getByText('4.1.6.12')).toBeVisible()
|
||||
expect(screen.getByText('4.1.11.53')).toBeVisible()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
|
||||
expect(guideTrigger).toHaveFocus()
|
||||
|
||||
const runtimeTrigger = screen.getByRole('button', { name: '支持版本' })
|
||||
await user.click(runtimeTrigger)
|
||||
const dialog = screen.getByRole('dialog', { name: '支持的微信版本' })
|
||||
expect(dialog).toBeVisible()
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
|
||||
expect(runtimeTrigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the session handoff that opens supported versions automatically', async () => {
|
||||
sessionStorage.setItem('wxe:show-supported-wechat-versions', '1')
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
|
||||
expect(sessionStorage.getItem('wxe:show-supported-wechat-versions')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WechatShareCardDialog } from '../../src/renderer/src/components/reports/WechatShareCardDialog'
|
||||
|
||||
const getConfig = vi.fn()
|
||||
const saveConfig = vi.fn()
|
||||
const publishCard = vi.fn()
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
打开分享卡片
|
||||
</button>
|
||||
{open && (
|
||||
<WechatShareCardDialog
|
||||
pngPath="/tmp/report.png"
|
||||
initialTitle="测试群日报"
|
||||
initialDescription="日报摘要"
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WechatShareCardDialog', () => {
|
||||
beforeEach(() => {
|
||||
getConfig.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
configured: true,
|
||||
serviceUrl: 'https://share.example.test'
|
||||
})
|
||||
saveConfig.mockReset().mockResolvedValue({ success: true })
|
||||
publishCard.mockReset()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getWechatShareConfig: getConfig,
|
||||
saveWechatShareConfig: saveConfig,
|
||||
publishWechatShareCard: publishCard
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('closes with Escape or the overlay and restores focus to the opener', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
const opener = screen.getByRole('button', { name: '打开分享卡片' })
|
||||
|
||||
await user.click(opener)
|
||||
expect(screen.getByRole('dialog', { name: '生成微信分享卡片' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '生成微信分享卡片' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
|
||||
await user.click(opener)
|
||||
const dialog = screen.getByRole('dialog', { name: '生成微信分享卡片' })
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(screen.queryByRole('dialog', { name: '生成微信分享卡片' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
})
|
||||
|
||||
it('keeps the dialog open when Escape is pressed while publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
publishCard.mockImplementation(() => new Promise(() => undefined))
|
||||
render(<Harness />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开分享卡片' }))
|
||||
await waitFor(() => expect(getConfig).toHaveBeenCalledOnce())
|
||||
await user.click(screen.getByRole('button', { name: '生成二维码' }))
|
||||
expect(screen.getByRole('button', { name: '正在生成卡片…' })).toBeDisabled()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('dialog', { name: '生成微信分享卡片' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('saves first-time configuration, publishes the card, and copies its link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText')
|
||||
getConfig.mockResolvedValue({ success: true, configured: false })
|
||||
publishCard.mockResolvedValue({
|
||||
success: true,
|
||||
shareUrl: 'https://share.example.test/card-1',
|
||||
qrCodeDataUrl: 'data:image/png;base64,fixture',
|
||||
expiresAt: '2026-08-26T08:00:00.000Z'
|
||||
})
|
||||
render(<Harness />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开分享卡片' }))
|
||||
const uploadToken = await screen.findByPlaceholderText('Cloudflare Worker 的 UPLOAD_TOKEN')
|
||||
await user.clear(uploadToken)
|
||||
await user.type(uploadToken, 'a'.repeat(24))
|
||||
await user.click(screen.getByRole('button', { name: '生成二维码' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveConfig).toHaveBeenCalledWith({
|
||||
serviceUrl: 'https://share.example.com',
|
||||
uploadToken: 'a'.repeat(24)
|
||||
})
|
||||
)
|
||||
expect(publishCard).toHaveBeenCalledWith({
|
||||
pngPath: '/tmp/report.png',
|
||||
title: '测试群日报',
|
||||
description: '日报摘要',
|
||||
expiresInDays: 7
|
||||
})
|
||||
expect(await screen.findByAltText('微信分享二维码')).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '复制分享链接' }))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('https://share.example.test/card-1'))
|
||||
expect(await screen.findByRole('button', { name: '链接已复制' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
+215
-10
@@ -4,6 +4,13 @@ import { tmpdir } from 'os'
|
||||
import { resolve } from 'path'
|
||||
import { launchTestApp } from './support/electron'
|
||||
|
||||
async function dismissFirstUseWelcome(page: import('@playwright/test').Page): Promise<void> {
|
||||
const welcome = page.getByRole('dialog', { name: '开始探索你的微信' })
|
||||
await expect(welcome).toBeVisible()
|
||||
await welcome.getByRole('button', { name: '关闭' }).click()
|
||||
await expect(welcome).toHaveCount(0)
|
||||
}
|
||||
|
||||
test('APP-01 first launch renders a usable connection screen without uncaught errors', async () => {
|
||||
const fixture = await launchTestApp({ mode: 'disconnected' })
|
||||
const pageErrors: Error[] = []
|
||||
@@ -29,6 +36,7 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app
|
||||
await expect(keyInput).toBeVisible()
|
||||
await keyInput.fill('a'.repeat(64))
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
await dismissFirstUseWelcome(fixture.page)
|
||||
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
@@ -67,8 +75,13 @@ test('P2-01 P2-02 guided connection exposes safe diagnostics and completes all s
|
||||
await fixture.page.getByRole('button', { name: '检查完成,继续' }).click()
|
||||
await fixture.page.getByRole('button', { name: '我已准备好' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '微信已登录,验证连接' })).toBeEnabled()
|
||||
await fixture.page.getByRole('button', { name: '微信已登录,验证连接' }).click()
|
||||
const verifyConnection = fixture.page.getByRole('button', {
|
||||
name: '验证连接',
|
||||
exact: true
|
||||
})
|
||||
await expect(verifyConnection).toBeEnabled()
|
||||
await verifyConnection.click()
|
||||
await dismissFirstUseWelcome(fixture.page)
|
||||
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
@@ -114,9 +127,130 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('API-01 masks, reveals, and confirms rotation of the local API token', async () => {
|
||||
test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 1000, height: 650 })
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
const moreButton = fixture.page.getByRole('button', { name: '更多' })
|
||||
await moreButton.click()
|
||||
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toHaveCount(0)
|
||||
await expect(moreButton).toBeFocused()
|
||||
|
||||
await moreButton.click()
|
||||
await fixture.page.getByRole('menuitem', { name: '刷新数据' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '产品测试群' })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport', async () => {
|
||||
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 1000, height: 650 })
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
|
||||
await trigger.click()
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.locator('.personal-wechat-send-status strong').filter({ hasText: '个人微信已绑定' })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
|
||||
true
|
||||
)
|
||||
const bounds = await dialog.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
expect(bounds!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(650)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('GUIDE-01 first-use welcome is keyboard-safe and fits the viewport', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
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: '开始探索你的微信' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: /试试 AI 群聊日报/ })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(guideButton).toBeFocused()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the viewport', async () => {
|
||||
test.skip(process.platform !== 'darwin', 'The personal WeChat runtime is currently macOS-only')
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 1000, height: 650 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
|
||||
|
||||
const trigger = fixture.page.getByRole('button', { name: '查看支持版本' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('4.1.6.12')).toBeVisible()
|
||||
await expect(dialog.getByText('4.1.11.53')).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('API-01 manages the local API token and previews the Reader Skill safely', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
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()
|
||||
@@ -128,6 +262,30 @@ test('API-01 masks, reveals, and confirms rotation of the local API token', asyn
|
||||
fixture.page.once('dialog', (dialog) => dialog.accept())
|
||||
await fixture.page.getByRole('button', { name: '重新生成 Token' }).click()
|
||||
await expect(fixture.page.getByText('Token 已生成')).toBeVisible()
|
||||
|
||||
const previewTrigger = fixture.page
|
||||
.locator('#api-reader-skill')
|
||||
.getByRole('button', { name: '预览 Skill' })
|
||||
await expect(previewTrigger).toBeEnabled()
|
||||
await previewTrigger.click()
|
||||
const previewDialog = fixture.page.getByRole('dialog', {
|
||||
name: 'TraceMemo Reader Skill 预览'
|
||||
})
|
||||
await expect(previewDialog).toBeVisible()
|
||||
await expect(previewDialog.getByRole('heading', { name: '能力' })).toBeVisible()
|
||||
await previewDialog.getByRole('button', { name: '原始文本' }).click()
|
||||
await expect(previewDialog.getByText(/# TraceMemo Reader/)).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await previewDialog.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(previewDialog).toHaveCount(0)
|
||||
await expect(previewTrigger).toBeFocused()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
@@ -135,7 +293,10 @@ test('API-01 masks, reveals, and confirms rotation of the local API token', asyn
|
||||
|
||||
test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
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()
|
||||
@@ -157,6 +318,10 @@ test('EXPORT-01 multi-chat selection stays local to export and forces HTML', asy
|
||||
await expect(
|
||||
fixture.page.locator('.export-preview-bubble').filter({ hasText: '这是一条脱敏测试消息' })
|
||||
).toHaveCount(1)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
await navigation.getByRole('button', { name: '档案' }).click()
|
||||
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
|
||||
@@ -165,6 +330,26 @@ test('EXPORT-01 multi-chat selection stays local to export and forces HTML', asy
|
||||
}
|
||||
})
|
||||
|
||||
test('LAYOUT-01 core workspaces fit a narrow desktop viewport 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 ['档案', '问问微信', '日报', '导出', '设置']) {
|
||||
await navigation.getByRole('button', { name: pageName }).click()
|
||||
await expect(fixture.page.locator('main.app-shell-main')).not.toBeEmpty()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
}
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
try {
|
||||
@@ -173,9 +358,19 @@ test('ARCH-01 ARCH-02 folded chats and supported message types are represented e
|
||||
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 } })
|
||||
const imageTrigger = fixture.page.getByRole('button', { name: '查看图片' })
|
||||
await imageTrigger.click()
|
||||
const imageDialog = fixture.page.getByRole('dialog', { name: '图片查看' })
|
||||
await expect(imageDialog).toBeVisible()
|
||||
await imageDialog.getByRole('button', { name: '放大' }).click()
|
||||
await expect(imageDialog.getByText('110%')).toBeVisible()
|
||||
await imageDialog.getByRole('button', { name: '右旋转' }).click()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(imageDialog).toHaveCount(0)
|
||||
await expect(imageTrigger).toBeFocused()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '折叠群聊 (1)' }).click()
|
||||
await expect(fixture.page.getByText('折叠群聊样本', { exact: true })).toBeVisible()
|
||||
@@ -291,11 +486,21 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
||||
await expect(fixture.page.getByText('固定响应模型')).toBeVisible()
|
||||
await expect(fixture.page.getByText('图片模型')).toBeVisible()
|
||||
await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toHaveCount(0)
|
||||
await fixture.page.getByRole('button', { name: '更多' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('menuitem', { name: '生成微信卡片' })).toHaveCount(0)
|
||||
const moreButton = fixture.page.getByRole('button', { name: '更多' })
|
||||
await moreButton.click()
|
||||
await fixture.page.getByRole('menuitem', { name: '生成微信卡片' }).click()
|
||||
const shareDialog = fixture.page.getByRole('dialog', { name: '生成微信分享卡片' })
|
||||
await expect(shareDialog).toBeVisible()
|
||||
await expect(shareDialog.locator('input').first()).toHaveValue(/产品测试群日报/)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(shareDialog).toHaveCount(0)
|
||||
await expect(moreButton).toBeFocused()
|
||||
|
||||
await fixture.page.setViewportSize({ width: 1024, height: 760 })
|
||||
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)
|
||||
|
||||
@@ -12,9 +12,11 @@ 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 imageData = `data:image/png;base64,${fs.readFileSync(path.join(root, 'resources/icon.png')).toString('base64')}`
|
||||
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||
const configuredNow = Number(process.env.WXE_E2E_NOW_MS)
|
||||
const fixtureNowMs =
|
||||
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
||||
|
||||
const formatFixtureDateTime = (timestampSeconds) => {
|
||||
const date = new Date(timestampSeconds * 1000)
|
||||
@@ -24,7 +26,7 @@ const formatFixtureDateTime = (timestampSeconds) => {
|
||||
|
||||
const allFixtureMessages = Object.values(fixture.messages).flat()
|
||||
const latestFixtureTime = Math.max(...allFixtureMessages.map((message) => message.createTime || 0))
|
||||
const fixtureTimeOffset = Math.floor(Date.now() / 1000) - 3600 - latestFixtureTime
|
||||
const fixtureTimeOffset = Math.floor(fixtureNowMs / 1000) - 3600 - latestFixtureTime
|
||||
for (const message of allFixtureMessages) {
|
||||
message.createTime = (message.createTime || latestFixtureTime) + fixtureTimeOffset
|
||||
message.datetime = formatFixtureDateTime(message.createTime)
|
||||
@@ -206,7 +208,7 @@ let settings = {
|
||||
debugEnabled: false,
|
||||
autoLogin: connected,
|
||||
autoLoginPreferenceSet: true,
|
||||
appearanceTheme: 'light',
|
||||
appearanceTheme: process.env.WXE_E2E_APPEARANCE_THEME === 'dark' ? 'dark' : 'light',
|
||||
compactMode: false,
|
||||
showStartupProgress: false,
|
||||
imageXorKey: '0x40',
|
||||
@@ -241,6 +243,65 @@ handle('settings:set', (patch) => {
|
||||
settings = { ...settings, ...patch }
|
||||
return { settings, settingsPath: path.join(userData, 'settings.json') }
|
||||
})
|
||||
handle('tts:getSettings', () => ({
|
||||
success: true,
|
||||
settings: {
|
||||
provider: 'fish-audio',
|
||||
hasApiKey: false,
|
||||
hasStoredApiKey: false,
|
||||
hasEnvironmentApiKey: false,
|
||||
keySource: 'missing',
|
||||
encryptionAvailable: true,
|
||||
selectedVoiceId: '',
|
||||
outputFormat: 'mp3',
|
||||
model: 's2.1-pro-free',
|
||||
phase: 'ready'
|
||||
},
|
||||
voices: []
|
||||
}))
|
||||
handle('wechat-personal:getRuntimeStatus', () => ({
|
||||
version: 'v0.0.18',
|
||||
state: 'ready',
|
||||
downloadedBytes: 100,
|
||||
totalBytes: 100,
|
||||
progress: 1,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true
|
||||
}))
|
||||
handle('wechat-personal:getStatus', () => ({
|
||||
state: 'online',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
wechatPid: 4668,
|
||||
boundWechatPid: 4668,
|
||||
oneBotPid: 5401,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: true,
|
||||
wechatVersion: '4.1.11.53',
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
baseAddress: '0x114ef8000',
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
canSend: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
canSendVoice: true,
|
||||
message: '个人微信已绑定'
|
||||
}))
|
||||
handle('wechat-share:getConfig', () => ({
|
||||
success: true,
|
||||
configured: true,
|
||||
serviceUrl: 'https://share.example.test'
|
||||
}))
|
||||
handle('key:getSavedDbKey', () => ({
|
||||
success: true,
|
||||
key: savedKey || undefined,
|
||||
@@ -579,6 +640,19 @@ handle('api:rotateToken', () => ({
|
||||
maskedToken: '••••••••••••••••'
|
||||
}))
|
||||
handle('api:copyCurl', () => ({ success: true }))
|
||||
handle('api:skillStatus', () => ({
|
||||
available: true,
|
||||
version: 'v1.2',
|
||||
filePath: '/fixture/tracememo-reader/SKILL.md',
|
||||
directoryPath: '/fixture/tracememo-reader',
|
||||
source: 'development',
|
||||
githubUrl: 'https://example.test/tracememo-reader'
|
||||
}))
|
||||
handle('api:readSkill', () => ({
|
||||
success: true,
|
||||
content:
|
||||
'# TraceMemo Reader\n\n## 能力\n- 读取本地聊天记录\n- 导出群聊日报\n\n仅在用户授权后访问。'
|
||||
}))
|
||||
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) => ({
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TestApplication {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
userData: string
|
||||
setWindowContentSize: (size: { width: number; height: number }) => Promise<void>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -20,6 +21,8 @@ export async function launchTestApp(
|
||||
largeContacts?: number
|
||||
corruptCache?: boolean
|
||||
aiFailure?: string
|
||||
now?: number
|
||||
appearanceTheme?: 'light' | 'dark'
|
||||
} = {}
|
||||
): Promise<TestApplication> {
|
||||
const ownsDirectory = !options.userData
|
||||
@@ -39,15 +42,29 @@ export async function launchTestApp(
|
||||
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 || ''
|
||||
WXE_E2E_AI_FAILURE: options.aiFailure || '',
|
||||
WXE_E2E_NOW_MS: options.now ? String(options.now) : '',
|
||||
WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light'
|
||||
}
|
||||
})
|
||||
const page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const setWindowContentSize = async (size: { width: number; height: number }): Promise<void> => {
|
||||
await app.evaluate(({ BrowserWindow }, nextSize) => {
|
||||
const [window] = BrowserWindow.getAllWindows()
|
||||
if (!window) throw new Error('E2E BrowserWindow is unavailable')
|
||||
window.setContentSize(nextSize.width, nextSize.height)
|
||||
}, size)
|
||||
await page.waitForFunction(
|
||||
(nextSize) => window.innerWidth === nextSize.width && window.innerHeight === nextSize.height,
|
||||
size
|
||||
)
|
||||
}
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
userData,
|
||||
setWindowContentSize,
|
||||
close: async () => {
|
||||
if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs)
|
||||
await app.close().catch(() => undefined)
|
||||
|
||||
+208
-3
@@ -5,6 +5,14 @@ import { launchTestApp } from './support/electron'
|
||||
|
||||
const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`)
|
||||
const visualViewport = { width: 1000, height: 650 }
|
||||
const visualNow = Date.parse('2026-08-19T14:46:40+08:00')
|
||||
|
||||
async function clearScreenshotFocus(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
})
|
||||
}
|
||||
|
||||
test.skip(
|
||||
!existsSync(baselineDirectory) && process.env.WXE_UPDATE_VISUAL_BASELINES !== '1',
|
||||
`No reviewed ${process.platform} visual baseline is committed yet`
|
||||
@@ -13,8 +21,9 @@ test.skip(
|
||||
test('NAV-01 login page visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ mode: 'disconnected' })
|
||||
try {
|
||||
await fixture.page.setViewportSize(visualViewport)
|
||||
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', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
@@ -25,11 +34,12 @@ test('NAV-01 login page visual @visual', async () => {
|
||||
})
|
||||
|
||||
test('ARCH-01 archive page visual @visual', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
try {
|
||||
await fixture.page.setViewportSize(visualViewport)
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('archive-page.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
@@ -38,3 +48,198 @@ test('ARCH-01 archive page visual @visual', async () => {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('ASK-01 AI Search idle page visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
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()
|
||||
await expect(fixture.page.getByRole('main', { name: '问问微信' })).not.toBeEmpty()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('ai-search-idle-page.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('ASK-03 AI Search result page visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
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()
|
||||
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
|
||||
timeout: 15_000
|
||||
})
|
||||
await expect(fixture.page.getByRole('button', { name: /选择证据 E1/ })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('ai-search-result-page.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('API-01 Reader Skill preview visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
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')
|
||||
.getByRole('button', { name: '预览 Skill' })
|
||||
.click()
|
||||
await expect(
|
||||
fixture.page.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('api-skill-preview.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('CHAT-02 personal WeChat send dialog visual @visual', async () => {
|
||||
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
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()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('personal-wechat-send-dialog.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('CHAT-03 image viewer visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
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()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('chat-image-viewer.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('EXPORT-01 export workspace idle visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
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()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('export-workspace-idle.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('THEME-01 archive page dark visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' })
|
||||
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')
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('archive-page-dark.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('THEME-02 export workspace dark visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' })
|
||||
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')
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('export-workspace-dark.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ensureAiSearchDataConsent } from '../../src/renderer/src/components/search/services/aiSearchProviderConsent'
|
||||
|
||||
const makeApi = (): {
|
||||
getAiSearchProviderStatus: ReturnType<typeof vi.fn>
|
||||
authorizeAiSearchExternalProvider: ReturnType<typeof vi.fn>
|
||||
} => ({
|
||||
getAiSearchProviderStatus: vi.fn(),
|
||||
authorizeAiSearchExternalProvider: vi.fn()
|
||||
})
|
||||
|
||||
describe('ensureAiSearchDataConsent', () => {
|
||||
it('bypasses consent when the configured provider does not require it', async () => {
|
||||
const api = makeApi()
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
|
||||
const requestConsent = vi.fn()
|
||||
|
||||
await expect(
|
||||
ensureAiSearchDataConsent({
|
||||
requestId: 'req-1',
|
||||
api,
|
||||
requestExternalProviderConsent: requestConsent
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(requestConsent).not.toHaveBeenCalled()
|
||||
expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves provider fields through consent and authorization', async () => {
|
||||
const api = makeApi()
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({
|
||||
configured: true,
|
||||
requiresConsent: true,
|
||||
providerId: 'provider-1',
|
||||
providerName: 'Remote Provider',
|
||||
recipient: 'remote@example.test'
|
||||
})
|
||||
api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: true })
|
||||
const requestConsent = vi.fn().mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
ensureAiSearchDataConsent({
|
||||
requestId: 'req-1',
|
||||
api,
|
||||
requestExternalProviderConsent: requestConsent
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(requestConsent).toHaveBeenCalledWith('Remote Provider', 'remote@example.test')
|
||||
expect(api.authorizeAiSearchExternalProvider).toHaveBeenCalledWith({
|
||||
requestId: 'req-1',
|
||||
providerId: 'provider-1',
|
||||
recipient: 'remote@example.test'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not authorize when the user rejects consent', async () => {
|
||||
const api = makeApi()
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({
|
||||
configured: true,
|
||||
requiresConsent: true,
|
||||
providerId: 'provider-1',
|
||||
recipient: 'remote@example.test'
|
||||
})
|
||||
const requestConsent = vi.fn().mockResolvedValue(false)
|
||||
|
||||
await expect(
|
||||
ensureAiSearchDataConsent({
|
||||
requestId: 'req-1',
|
||||
api,
|
||||
requestExternalProviderConsent: requestConsent
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves incomplete-status and authorization errors', async () => {
|
||||
const api = makeApi()
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: true })
|
||||
await expect(
|
||||
ensureAiSearchDataConsent({
|
||||
requestId: 'req-1',
|
||||
api,
|
||||
requestExternalProviderConsent: vi.fn()
|
||||
})
|
||||
).rejects.toThrow('当前 AI 服务信息不完整')
|
||||
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({
|
||||
configured: true,
|
||||
requiresConsent: true,
|
||||
providerId: 'provider-1',
|
||||
recipient: 'remote@example.test'
|
||||
})
|
||||
api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: false, error: '授权失败' })
|
||||
await expect(
|
||||
ensureAiSearchDataConsent({
|
||||
requestId: 'req-1',
|
||||
api,
|
||||
requestExternalProviderConsent: vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
).rejects.toThrow('授权失败')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../src/shared/ai-search'
|
||||
import type {
|
||||
AiSearchFinalEvidence,
|
||||
AiSearchPipelineResult,
|
||||
AiSearchTimeRange
|
||||
} from '../../src/shared/ai-search'
|
||||
import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import {
|
||||
@@ -17,9 +21,14 @@ import {
|
||||
mapEvidenceSenderNames,
|
||||
mapPipelineEvidence,
|
||||
mapPipelineEvidenceItem,
|
||||
mapPipelineResultToRendererResult,
|
||||
mapSearchResultToTrace
|
||||
} from '../../src/renderer/src/components/search/searchMappers'
|
||||
import { createSearchResultResetState } from '../../src/renderer/src/components/search/searchState'
|
||||
import {
|
||||
createSearchResultResetState,
|
||||
resolveSearchResultViewTransition
|
||||
} from '../../src/renderer/src/components/search/searchState'
|
||||
import { createSearchRequestContext } from '../../src/renderer/src/components/search/searchUtils'
|
||||
import type {
|
||||
AISearchCacheRecord,
|
||||
EvidenceItem,
|
||||
@@ -89,6 +98,113 @@ const makeTrace = (overrides: Partial<SearchTrace> = {}): SearchTrace => ({
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('AI Search request context', () => {
|
||||
it('trims only the submitted query while keeping cache query normalization unchanged', () => {
|
||||
const context = createSearchRequestContext({
|
||||
query: ' Mixed Case 问题 ',
|
||||
scope: 'global',
|
||||
range: '30d'
|
||||
})
|
||||
|
||||
expect(context.normalizedQuery).toBe('Mixed Case 问题')
|
||||
expect(context.cacheKey).toBe(JSON.stringify(['global', '', '30d', 'mixed case 问题']))
|
||||
})
|
||||
|
||||
it.each(['global', 'groups', 'contacts'] as const)(
|
||||
'does not attach a conversation to %s scope',
|
||||
(scope) => {
|
||||
const context = createSearchRequestContext({
|
||||
query: '范围问题',
|
||||
scope,
|
||||
range: '7d',
|
||||
activeContactMd5: 'ignored-contact'
|
||||
})
|
||||
|
||||
expect(context.conversationId).toBeUndefined()
|
||||
expect(context.cacheKey).toBe(JSON.stringify([scope, '', '7d', '范围问题']))
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the active contact for conversation request and cache identity', () => {
|
||||
const context = createSearchRequestContext({
|
||||
query: '会话问题',
|
||||
scope: 'conversation',
|
||||
range: 'today',
|
||||
activeContactMd5: aiSearchContact.md5
|
||||
})
|
||||
|
||||
expect(context.conversationId).toBe(aiSearchContact.md5)
|
||||
expect(context.cacheKey).toBe(
|
||||
JSON.stringify(['conversation', aiSearchContact.md5, 'today', '会话问题'])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a missing conversation undefined while using the empty cache slot', () => {
|
||||
const context = createSearchRequestContext({
|
||||
query: '未选择会话',
|
||||
scope: 'conversation',
|
||||
range: 'all'
|
||||
})
|
||||
|
||||
expect(context.conversationId).toBeUndefined()
|
||||
expect(context.cacheKey).toBe(JSON.stringify(['conversation', '', 'all', '未选择会话']))
|
||||
})
|
||||
|
||||
it('uses retry range and retry time override when both are provided', () => {
|
||||
const currentOverride: AiSearchTimeRange = {
|
||||
label: '近 30 天',
|
||||
reason: '当前选择',
|
||||
source: 'user_selected'
|
||||
}
|
||||
const retryOverride: AiSearchTimeRange = {
|
||||
label: '全部历史',
|
||||
reason: '用户主动扩大到全部历史',
|
||||
source: 'user_retry'
|
||||
}
|
||||
const context = createSearchRequestContext({
|
||||
query: '重试问题',
|
||||
scope: 'global',
|
||||
range: '30d',
|
||||
timeRangeOverride: currentOverride,
|
||||
retry: { range: 'all', timeRangeOverride: retryOverride }
|
||||
})
|
||||
|
||||
expect(context.effectiveRange).toBe('all')
|
||||
expect(context.effectiveTimeRangeOverride).toBe(retryOverride)
|
||||
expect(context.cacheKey).toBe(JSON.stringify(['global', '', 'all', '重试问题']))
|
||||
})
|
||||
|
||||
it('falls back to the current time override when retry does not provide one', () => {
|
||||
const currentOverride: AiSearchTimeRange = {
|
||||
startTime: 123,
|
||||
label: '近 30 天',
|
||||
reason: '用户在界面选择的时间范围',
|
||||
source: 'user_selected'
|
||||
}
|
||||
const context = createSearchRequestContext({
|
||||
query: '保留当前时间范围',
|
||||
scope: 'global',
|
||||
range: '7d',
|
||||
timeRangeOverride: currentOverride,
|
||||
retry: { range: '30d', timeRangeOverride: undefined }
|
||||
})
|
||||
|
||||
expect(context.effectiveRange).toBe('30d')
|
||||
expect(context.effectiveTimeRangeOverride).toBe(currentOverride)
|
||||
})
|
||||
|
||||
it('keeps the current range and undefined override when no retry exists', () => {
|
||||
const context = createSearchRequestContext({
|
||||
query: '普通问题',
|
||||
scope: 'global',
|
||||
range: '7d'
|
||||
})
|
||||
|
||||
expect(context.effectiveRange).toBe('7d')
|
||||
expect(context.effectiveTimeRangeOverride).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Search workspace pure formatters', () => {
|
||||
it('formats byte counts exactly as the workspace did', () => {
|
||||
expect(formatBytes(0)).toBe('0 B')
|
||||
@@ -211,6 +327,47 @@ describe('AI Search pipeline evidence mapping', () => {
|
||||
})
|
||||
|
||||
describe('AI Search trace and cache mapping', () => {
|
||||
it('maps a pipeline result into the common Renderer state without mixing collection senders', () => {
|
||||
const finalEvidence = makeFinalEvidence(1, { senderId: 'final-sender', sender: '总结发送者' })
|
||||
const collectionEvidence = makeFinalEvidence(2, {
|
||||
senderId: 'collection-sender',
|
||||
sender: '浏览发送者'
|
||||
})
|
||||
const result = makeSearchResult({
|
||||
evidence: [finalEvidence],
|
||||
evidenceCollection: [finalEvidence, collectionEvidence]
|
||||
})
|
||||
|
||||
const mapped = mapPipelineResultToRendererResult(result, [aiSearchContact])
|
||||
|
||||
expect(mapped.evidence.map((item) => item.evidenceId)).toEqual(['E1'])
|
||||
expect(mapped.evidenceCollection.map((item) => item.evidenceId)).toEqual(['E1', 'E2'])
|
||||
expect(mapped.senderNames).toEqual({ 'final-sender': '总结发送者' })
|
||||
expect(mapped.messageCount).toBe(result.knowledge.totalMessages)
|
||||
expect(mapped.searchTrace).toEqual(mapSearchResultToTrace(result, 1))
|
||||
})
|
||||
|
||||
it('falls back to Final Evidence only when the runtime collection is missing', () => {
|
||||
const evidence = [makeFinalEvidence(1)]
|
||||
const result = makeSearchResult({ evidence })
|
||||
;(
|
||||
result as AiSearchPipelineResult & { evidenceCollection?: AiSearchFinalEvidence[] }
|
||||
).evidenceCollection = undefined
|
||||
|
||||
const mapped = mapPipelineResultToRendererResult(result, [aiSearchContact])
|
||||
|
||||
expect(mapped.evidenceCollection).toEqual(mapped.evidence)
|
||||
})
|
||||
|
||||
it('preserves an explicitly empty Evidence Collection without falling back', () => {
|
||||
const result = makeSearchResult({ evidence: [makeFinalEvidence(1)], evidenceCollection: [] })
|
||||
|
||||
const mapped = mapPipelineResultToRendererResult(result, [aiSearchContact])
|
||||
|
||||
expect(mapped.evidence).toHaveLength(1)
|
||||
expect(mapped.evidenceCollection).toEqual([])
|
||||
})
|
||||
|
||||
it('maps pipeline trace fields and preserves the original default values', () => {
|
||||
const result: AiSearchPipelineResult = makeSearchResult()
|
||||
|
||||
@@ -392,6 +549,64 @@ describe('AI Search trace and cache mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Search result view transition', () => {
|
||||
it('maps no Evidence to the range-specific insufficient message and ignores pipeline error', () => {
|
||||
const result = makeSearchResult({ status: 'no_evidence', error: '不应使用的错误' })
|
||||
|
||||
expect(resolveSearchResultViewTransition(result, '7d')).toEqual({
|
||||
stage: 'insufficient',
|
||||
analysisError: '近 7 天内没有找到与问题相关的聊天消息。'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['retrieval_incomplete', 'partial', '当前检索未完整覆盖聊天记录,未生成总结。'],
|
||||
['failed', 'insufficient', '本地搜索暂时无法完成'],
|
||||
['ai_failed', 'partial', '证据已找到,但 AI 暂时无法生成回答']
|
||||
] as const)('maps %s to its existing fallback presentation', (status, stage, analysisError) => {
|
||||
expect(resolveSearchResultViewTransition(makeSearchResult({ status }), '30d')).toEqual({
|
||||
stage,
|
||||
analysisError
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['retrieval_incomplete', 'partial'],
|
||||
['failed', 'insufficient'],
|
||||
['ai_failed', 'partial']
|
||||
] as const)('preserves the pipeline error for %s', (status, stage) => {
|
||||
expect(
|
||||
resolveSearchResultViewTransition(
|
||||
makeSearchResult({ status, error: '主进程返回的错误' }),
|
||||
'30d'
|
||||
)
|
||||
).toEqual({
|
||||
stage,
|
||||
analysisError: '主进程返回的错误'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a completed answer for the Workspace success path', () => {
|
||||
expect(
|
||||
resolveSearchResultViewTransition(makeSearchResult({ answer: '完整回答' }), '30d')
|
||||
).toEqual({
|
||||
stage: 'result',
|
||||
analysisError: '',
|
||||
answer: '完整回答'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a missing completed answer for the existing Workspace guard', () => {
|
||||
const result = { ...makeSearchResult(), answer: undefined }
|
||||
|
||||
expect(resolveSearchResultViewTransition(result, '30d')).toEqual({
|
||||
stage: 'result',
|
||||
analysisError: '',
|
||||
answer: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Search result reset state', () => {
|
||||
it('returns every existing search result reset value', () => {
|
||||
expect(createSearchResultResetState()).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user