import { createServer } from 'vite' import { chromium } from 'playwright' const port = Number(process.env.VISUAL_CHECK_PORT ?? 4175) const server = await createServer({ root: process.cwd(), server: { host: '127.0.0.1', port, strictPort: true, }, }) await server.listen() const browser = await chromium.launch({ headless: true }) const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 }) const errors = [] const failures = [] const projectId = '019b0000-0000-7000-8000-000000000001' const taskSearchResultId = '019b0000-0000-7000-8000-000000000003' const inboxItemId = '019b0000-0000-7000-8000-000000000004' const inboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000005' const inboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000006' const inboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000007' const secondInboxItemId = '019b0000-0000-7000-8000-000000000008' const secondInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000009' const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010' const secondInboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000011' const unknownProjectId = '019b0000-0000-7000-8000-000000000099' const externalProjectId = '019b0000-0000-7000-8000-000000000088' const externalTaskId = '019b0000-0000-7000-8000-000000000089' const externalNoteId = '019b0000-0000-7000-8000-000000000090' const searchRequests = [] const workspaceRequests = [] const projectPatchRequests = [] const inboxAnalyzeRequests = [] const inboxConfirmRequests = [] let expectingProjectPatchError = false let expectedProjectPatchConsoleErrorCount = 0 let expectingInboxConfirmError = false let expectedInboxConfirmConsoleErrorCount = 0 let expectingInboxRefreshError = false let expectedInboxRefreshConsoleErrorCount = 0 let failNextInboxConfirm = true let failNextInboxWorkspaceRefresh = false page.on('console', (message) => { if (message.type() !== 'error') return if ( expectingProjectPatchError && expectedProjectPatchConsoleErrorCount === 0 && message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' ) { expectedProjectPatchConsoleErrorCount += 1 return } if ( expectingInboxConfirmError && expectedInboxConfirmConsoleErrorCount === 0 && message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' ) { expectedInboxConfirmConsoleErrorCount += 1 return } if ( expectingInboxRefreshError && expectedInboxRefreshConsoleErrorCount === 0 && message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' ) { expectedInboxRefreshConsoleErrorCount += 1 return } errors.push(message.text()) }) const visualCheckWorkspace = { project: { id: projectId, name: '森林项目', identifier: 'forest', icon: '森', background: '#165DFF', description: '移动导航视觉检查项目', initials: '森林', unreadCount: 2, }, channels: [ { id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, { id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 2, url: '', sortOrder: 1 }, { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 2 }, { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 3 }, { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 4 }, { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 5 }, ], tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }], recentSessions: [ { id: 'session-1', projectId, title: '移动导航会话', summary: '检查抽屉关闭行为', updatedAt: '2026-07-21T02:00:00Z', references: [] }, ], inbox: [ { id: inboxItemId, projectId, source: '手动收集', title: '整理客户访谈', summary: '把访谈结论整理为后续任务、会议纪要和背景资料。', status: 'open', tag: '待处理', time: '2026-07-21T02:30:00Z', }, { id: secondInboxItemId, projectId, source: '手动收集', title: '项目复盘记录', summary: '整理项目复盘中的行动项和背景信息。', status: 'open', tag: '待处理', time: '2026-07-21T02:20:00Z', }, ], tasks: [], aiSessions: [], notesSources: [], cronPlans: [], } await page.route('http://localhost:9150/api/v1/**', async (route) => { const url = new URL(route.request().url()) const method = route.request().method() if (url.pathname === '/api/v1/status') { await route.fulfill({ json: { timestamp: new Date().toISOString() } }) return } if (url.pathname === '/api/v1/auth/login') { await route.fulfill({ json: { token: 'visual-check-token' } }) return } if (url.pathname === '/api/v1/projects') { await route.fulfill({ json: [visualCheckWorkspace.project] }) return } if (url.pathname === `/api/v1/projects/${projectId}/workspace`) { workspaceRequests.push(url.pathname) if (failNextInboxWorkspaceRefresh) { failNextInboxWorkspaceRefresh = false await route.fulfill({ status: 500, json: { error: { code: 'internal_error', message: '工作区刷新失败,请稍后重试' } }, }) return } await route.fulfill({ json: visualCheckWorkspace }) return } if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') { inboxAnalyzeRequests.push(inboxItemId) if (inboxAnalyzeRequests.length === 1) await new Promise((resolve) => setTimeout(resolve, 250)) await route.fulfill({ json: { suggestions: [ { id: inboxTaskSuggestionId, kind: 'task', title: '跟进客户反馈', body: '联系客户确认下一步时间。' }, { id: inboxNoteSuggestionId, kind: 'note', title: '客户访谈纪要', body: '保存访谈中的关键结论。' }, { id: inboxSourceSuggestionId, kind: 'source', title: '访谈背景资料', body: '保存为文本资料,不伪造文件路径。' }, ], }, }) return } if (url.pathname === `/api/v1/inbox/${secondInboxItemId}/analyze` && method === 'POST') { inboxAnalyzeRequests.push(secondInboxItemId) await route.fulfill({ json: { suggestions: [ { id: secondInboxTaskSuggestionId, kind: 'task', title: '整理复盘任务', body: '安排下一次复盘。' }, { id: secondInboxNoteSuggestionId, kind: 'note', title: '复盘记录', body: '保存复盘结论。' }, { id: secondInboxSourceSuggestionId, kind: 'source', title: '复盘资料', body: '保存复盘背景。' }, ], }, }) return } if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') { inboxConfirmRequests.push(route.request().postDataJSON()) await new Promise((resolve) => setTimeout(resolve, 250)) if (failNextInboxConfirm) { failNextInboxConfirm = false await route.fulfill({ status: 500, json: { error: { code: 'internal_error', message: '确认创建失败,请稍后重试' } }, }) return } const confirmedItemId = url.pathname.split('/').at(-2) visualCheckWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed' visualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1 if (confirmedItemId === secondInboxItemId) failNextInboxWorkspaceRefresh = true await route.fulfill({ json: { createdCount: inboxConfirmRequests.at(-1).suggestionIds.length } }) return } if (url.pathname === '/api/v1/search') { const query = url.searchParams.get('q') ?? '' searchRequests.push(query) if (query === '慢请求') await new Promise((resolve) => setTimeout(resolve, 300)) const result = query === '外部任务' ? { id: externalTaskId, type: 'task', title: '获授权外部任务', projectId: externalProjectId, snippet: '仅限处理合同签署' } : query === '外部笔记' ? { id: externalNoteId, type: 'note', title: '获授权外部笔记', projectId: externalProjectId, snippet: '仅共享合同背景段落' } : query === '未知结果' ? { id: 'untrusted-result', type: 'source', title: '未知类型结果', projectId: unknownProjectId, snippet: '不得预览或导航' } : { id: taskSearchResultId, type: 'task', title: query === '慢请求' ? '慢请求任务' : query === '快请求' ? '快请求任务' : '回调任务', projectId, snippet: '检查签名', } await route.fulfill({ json: { items: [result], }, }) return } if (url.pathname === `/api/v1/projects/${projectId}` && method === 'PATCH') { const input = route.request().postDataJSON() projectPatchRequests.push(input) if (input.description === '触发失败') { await new Promise((resolve) => setTimeout(resolve, 250)) await route.fulfill({ status: 500, json: { error: { code: 'internal_error', message: '项目设置保存失败,请稍后重试' } }, }) return } Object.assign(visualCheckWorkspace.project, input) await route.fulfill({ json: visualCheckWorkspace.project }) return } await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } }) }) const collectLoginMetrics = async () => page.evaluate(() => { const loginCard = document.querySelector('.login-card') return { overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth, title: document.title, width: loginCard?.getBoundingClientRect().width ?? 0, columns: loginCard ? getComputedStyle(loginCard).gridTemplateColumns : 'none', } }) const getColumnCount = (columns) => columns === 'none' ? 0 : columns.split(' ').filter(Boolean).length const waitForDrawerOpen = async (selector) => page.waitForFunction((drawerSelector) => { const drawer = document.querySelector(drawerSelector) return drawer?.classList.contains('open') && Math.abs(drawer.getBoundingClientRect().left) < 1 }, selector) const collectMetrics = async () => page.evaluate(() => { const statusbar = document.querySelector('.statusbar') const statusUser = document.querySelector('.status-user') const statusName = document.querySelector('.status-name') const statusOnlineDot = document.querySelector('.status-online-dot') const search = document.querySelector('.global-search') const main = document.querySelector('.workbench-main') const workspacePage = document.querySelector('.workspace-page') const projectOverview = document.querySelector('.overview-page:not(.workspace-page)') const rail = document.querySelector('.project-rail') const railChildren = rail?.querySelector('.arco-layout-sider-children') const sidebar = document.querySelector('.channel-sidebar') const channelList = document.querySelector('.channel-list') const stage = document.querySelector('.stage') const inspector = document.querySelector('.inspector') const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')] .map((button) => button.textContent?.trim()) const dashboard = document.querySelector('.dashboard-button') const workspaceExplore = document.querySelector('.dashboard-button[title="探索"]') const project = document.querySelector('.project-button') const create = document.querySelector('.create-project') const firstBadge = document.querySelector('.project-badge .arco-badge-number') const railItems = [...document.querySelectorAll('.dashboard-button, .project-button, .create-project')] const rect = (node) => { const box = node?.getBoundingClientRect() return box ? { left: box.left, right: box.right, top: box.top, width: box.width, height: box.height } : null } const railBox = rail?.getBoundingClientRect() const itemRects = railItems.map(rect).filter(Boolean) const horizontalInsets = railBox && project ? { left: project.getBoundingClientRect().left - railBox.left, right: railBox.right - project.getBoundingClientRect().right, } : null const verticalGaps = itemRects.slice(1).map((item, index) => item.top - (itemRects[index].top + itemRects[index].height)) const overflowState = (node) => node ? { clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, clientHeight: node.clientHeight, scrollHeight: node.scrollHeight, overflowX: getComputedStyle(node).overflowX, overflowY: getComputedStyle(node).overflowY, scrollbarWidth: getComputedStyle(node).scrollbarWidth, } : null return { statusbarHeight: statusbar?.getBoundingClientRect().height, statusUserTag: statusUser?.tagName, nestedButtonCount: document.querySelectorAll('button button').length, statusName: rect(statusName), statusOnlineDot: rect(statusOnlineDot), searchHeight: search?.getBoundingClientRect().height, workbenchMain: rect(main), hasWorkspacePage: Boolean(workspacePage), hasProjectOverview: Boolean(projectOverview), projectRailWidth: rail?.getBoundingClientRect().width, projectRail: rect(rail), channelSidebar: rect(sidebar), stage: rect(stage), inspector: rect(inspector), workspaceActions, dashboardButton: rect(dashboard), dashboardButtonActive: dashboard?.classList.contains('active') ?? false, workspaceExploreButton: rect(workspaceExplore), workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false, projectButton: rect(project), projectButtonActive: project?.classList.contains('active') ?? false, createProjectButton: rect(create), firstProjectBadge: rect(firstBadge), horizontalInsets, verticalGaps, projectRailOverflow: overflowState(rail), projectRailChildrenOverflow: overflowState(railChildren), channelSidebarOverflow: overflowState(sidebar), channelListOverflow: overflowState(channelList), stageOverflow: overflowState(stage), inspectorOverflow: overflowState(inspector), overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth, viewportWidth: window.innerWidth, } }) await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' }) await page.screenshot({ path: 'test-results/login-react-acro.png', fullPage: true }) const desktopLoginMetrics = await collectLoginMetrics() const desktopColumnCount = getColumnCount(desktopLoginMetrics.columns) if (desktopLoginMetrics.overflowX) failures.push('login must not overflow horizontally') if (desktopLoginMetrics.title !== '森林AI') failures.push(`unexpected title ${desktopLoginMetrics.title}`) if (desktopColumnCount !== 2) failures.push(`desktop login must use two columns, got ${desktopLoginMetrics.columns}`) await page.setViewportSize({ width: 390, height: 844 }) await page.screenshot({ path: 'test-results/login-react-acro-mobile.png', fullPage: true }) const mobileLoginMetrics = await collectLoginMetrics() const mobileColumnCount = getColumnCount(mobileLoginMetrics.columns) if (mobileLoginMetrics.overflowX) failures.push('mobile login must not overflow horizontally') if (mobileLoginMetrics.width > 390) failures.push(`mobile login card must fit viewport, got ${mobileLoginMetrics.width}`) if (mobileColumnCount !== 1) failures.push(`mobile login must use one column, got ${mobileLoginMetrics.columns}`) if (failures.length) { throw new Error(failures.join('\n')) } if (process.env.VISUAL_CHECK_SCOPE === 'login') { console.log(JSON.stringify({ desktopLoginMetrics, mobileLoginMetrics, errors }, null, 2)) await browser.close() await server.close() if (failures.length) throw new Error(failures.join('\n')) process.exit(0) } await page.setViewportSize({ width: 1440, height: 1024 }) await page.locator('.login-form-panel .arco-btn-primary').click() await page.waitForSelector('.workbench-shell') await page.waitForTimeout(700) await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true }) const workspaceMetrics = await collectMetrics() await page.setViewportSize({ width: 390, height: 844 }) if (await page.getByRole('button', { name: '打开频道导航', exact: true }).count() !== 0) { failures.push('workspace mobile view must not offer channel navigation without a channel aside') } await page.setViewportSize({ width: 1440, height: 1024 }) await page.locator('.dashboard-button[title="探索"]').click() await page.waitForTimeout(500) await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true }) const workspaceExploreMetrics = await collectMetrics() await page.locator('.project-button').first().click() await page.waitForTimeout(500) await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true }) const projectMetrics = await collectMetrics() await page.setViewportSize({ width: 390, height: 844 }) const mobileProjectMetrics = await collectMetrics() const projectNavButton = page.getByRole('button', { name: '打开项目导航', exact: true }) if (await projectNavButton.count() === 0) { failures.push('missing mobile button 打开项目导航') } else { await projectNavButton.click() await waitForDrawerOpen('aside.project-rail') await page.screenshot({ path: 'test-results/project-navigation-mobile.png', fullPage: true }) if (!await page.locator('aside.project-rail.open').isVisible()) { failures.push('打开项目导航 must reveal the project rail aside') } await page.setViewportSize({ width: 1440, height: 1024 }) const resizedDesktopMetrics = await collectMetrics() const desktopBackdropDisplay = await page.locator('.nav-backdrop').evaluate((node) => getComputedStyle(node).display) if (desktopBackdropDisplay !== 'none') { failures.push(`navigation backdrop must be hidden after resizing to desktop, got display=${desktopBackdropDisplay}`) } if (resizedDesktopMetrics.overflowX) { failures.push('resized desktop project viewport must not overflow horizontally') } if (!resizedDesktopMetrics.projectRail || !resizedDesktopMetrics.channelSidebar || !resizedDesktopMetrics.stage) { failures.push('resized desktop must preserve project rail, channel sidebar, and stage') } else if ( Math.abs(resizedDesktopMetrics.channelSidebar.left - resizedDesktopMetrics.projectRail.right) > 1 || Math.abs(resizedDesktopMetrics.stage.left - resizedDesktopMetrics.channelSidebar.right) > 1 || Math.abs(resizedDesktopMetrics.stage.right - resizedDesktopMetrics.viewportWidth) > 1 ) { failures.push(`resized desktop layout must preserve the core grid, got rail=${JSON.stringify(resizedDesktopMetrics.projectRail)}, sidebar=${JSON.stringify(resizedDesktopMetrics.channelSidebar)}, stage=${JSON.stringify(resizedDesktopMetrics.stage)}`) } await page.setViewportSize({ width: 390, height: 844 }) await waitForDrawerOpen('aside.project-rail') await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 380, y: 20 } }) } const channelNavButton = page.getByRole('button', { name: '打开频道导航', exact: true }) if (await channelNavButton.count() === 0) { failures.push('missing mobile button 打开频道导航') } else { await channelNavButton.click() await waitForDrawerOpen('aside.channel-sidebar') await page.screenshot({ path: 'test-results/channel-navigation-mobile.png', fullPage: true }) if (!await page.locator('aside.channel-sidebar.open').isVisible()) { failures.push('打开频道导航 must reveal the channel sidebar aside') } await page.locator('.recent-sessions button').first().click() if (await page.locator('aside.channel-sidebar.open').count() !== 0) { failures.push('selecting a recent session must close the channel drawer') await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 380, y: 20 } }) } } await page.setViewportSize({ width: 1440, height: 1024 }) const searchInput = page.getByPlaceholder('搜索项目、任务和笔记') const searchButton = page.getByRole('button', { name: '搜索', exact: true }) if (await searchInput.count() === 0 || await searchButton.count() === 0) { failures.push('topbar must expose the connected project/task/note search controls') } else { await searchInput.fill('回调') await searchInput.press('Enter') await page.waitForTimeout(150) if (!searchRequests.includes('回调')) failures.push(`search Enter must request q=回调, got ${JSON.stringify(searchRequests)}`) const callbackResult = page.getByRole('button', { name: /回调任务/ }) if (await callbackResult.count() === 0) { failures.push('search results must render in a selectable dropdown list') } else { await callbackResult.click() const activeAfterSearch = await page.locator('.channel-button.active').textContent() if (!activeAfterSearch?.includes('工作计划')) { failures.push(`task search result must navigate to 工作计划, got ${JSON.stringify(activeAfterSearch)}`) } } const projectBeforeExternalPreview = await page.locator('.project-title h5').textContent() const externalWorkspacePath = `/api/v1/projects/${externalProjectId}/workspace` await searchInput.fill('外部任务') await searchButton.click() await page.waitForTimeout(150) const externalTaskResult = page.getByRole('button', { name: /获授权外部任务/ }) if (await externalTaskResult.count() === 0) { failures.push('authorized external task must render as a search result') } else { await externalTaskResult.click() const preview = page.getByRole('dialog', { name: '授权对象预览' }) if (await preview.count() === 0) { failures.push('authorized external task must open a restricted preview') } else { for (const text of ['仅显示被授权对象', '获授权外部任务', '仅限处理合同签署', externalProjectId]) { if (!await preview.getByText(text, { exact: false }).isVisible()) failures.push(`external task preview missing ${text}`) } await preview.getByRole('button', { name: '关闭预览', exact: true }).click() } } await searchInput.fill('外部笔记') await searchButton.click() await page.waitForTimeout(150) const externalNoteResult = page.getByRole('button', { name: /获授权外部笔记/ }) if (await externalNoteResult.count() === 0) { failures.push('authorized external note must render as a search result') } else { await externalNoteResult.click() const preview = page.getByRole('dialog', { name: '授权对象预览' }) if (await preview.count() === 0) { failures.push('authorized external note must open a restricted preview') } else { for (const text of ['仅显示被授权对象', '获授权外部笔记', '仅共享合同背景段落', externalProjectId]) { if (!await preview.getByText(text, { exact: false }).isVisible()) failures.push(`external note preview missing ${text}`) } await preview.getByRole('button', { name: '关闭预览', exact: true }).click() } } if (workspaceRequests.includes(externalWorkspacePath)) { failures.push('external previews must never load an unowned project workspace') } if (await page.locator('.project-title h5').textContent() !== projectBeforeExternalPreview) { failures.push('external previews must not switch the active owned project') } await searchInput.fill('未知结果') await searchButton.click() await page.waitForTimeout(150) const unknownResult = page.getByRole('button', { name: /未知类型结果/ }) if (await unknownResult.count() === 0) { failures.push('fixture must expose the unknown result for its safety assertion') } else { await unknownResult.click() if (await page.getByRole('dialog', { name: '授权对象预览' }).count() !== 0) { failures.push('unknown search result types must not open an authorized preview') } if (await page.locator('.project-title h5').textContent() !== projectBeforeExternalPreview) { failures.push('unknown search results must not switch or fabricate a project') } } await searchInput.fill('慢请求') await searchInput.press('Enter') await page.waitForTimeout(30) await searchInput.fill('快请求') await searchInput.press('Enter') await page.waitForTimeout(400) if (await page.getByRole('button', { name: /慢请求任务/ }).count() !== 0) { failures.push('a stale search response must not replace newer results') } if (await page.getByRole('button', { name: /快请求任务/ }).count() === 0) { failures.push('the newest search response must remain visible after requests resolve out of order') } await searchInput.fill('') } const settingsButton = page.getByRole('button', { name: '编辑项目设置', exact: true }) if (await settingsButton.count() === 0) { failures.push('project sidebar must expose an accessible project settings button') } else { await settingsButton.click() let settingsModal = page.getByRole('dialog', { name: '编辑项目' }) await settingsModal.getByLabel('名称').fill('森林项目已更新') await settingsModal.getByRole('button', { name: '确定', exact: true }).click() await settingsModal.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {}) if (projectPatchRequests[0]?.name !== '森林项目已更新') { failures.push(`project settings must PATCH the edited identity, got ${JSON.stringify(projectPatchRequests)}`) } if (await page.locator('.project-title h5').textContent() !== '森林项目已更新') { failures.push('successful project settings update must reload the same project identity') } if (await settingsModal.isVisible()) failures.push('successful project settings update must close the modal') await settingsButton.click() settingsModal = page.getByRole('dialog', { name: '编辑项目' }) await settingsModal.getByLabel('简介').fill('触发失败') expectingProjectPatchError = true await settingsModal.getByRole('button', { name: '确定', exact: true }).click() await page.waitForTimeout(50) const confirmButton = settingsModal.getByRole('button', { name: '确定', exact: true }) if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) { failures.push('project settings confirmation must show loading while PATCH is pending') } await page.waitForTimeout(300) if (!await settingsModal.isVisible()) failures.push('failed project settings update must keep the modal open') if (!await settingsModal.getByText('项目设置保存失败,请稍后重试', { exact: true }).isVisible()) { failures.push('failed project settings update must render the API Chinese error inside the modal') } expectingProjectPatchError = false if (expectedProjectPatchConsoleErrorCount !== 1) { failures.push(`expected one simulated PATCH console error, got ${expectedProjectPatchConsoleErrorCount}`) } await settingsModal.getByRole('button', { name: '取消', exact: true }).click() } await page.locator('.channel-sidebar').hover() const channelSidebarHoverMetrics = await collectMetrics() await page.locator('.stage').hover() const stageHoverMetrics = await collectMetrics() await page.locator('.channel-list').hover() const channelListHoverMetrics = await collectMetrics() const inboxChannelButton = page.locator('.channel-button', { hasText: 'Inbox 消息流' }) if (await inboxChannelButton.count() === 0) { failures.push('project sidebar must expose the Inbox channel from the workspace API') } else { await inboxChannelButton.click() const inboxPage = page.locator('.project-inbox-page') if (await inboxPage.count() === 0) { failures.push('Inbox channel must render the confirmation workflow page') } else { for (const content of ['整理客户访谈', '把访谈结论整理为后续任务、会议纪要和背景资料。']) { if (!await inboxPage.getByText(content, { exact: true }).first().isVisible()) failures.push(`Inbox content missing ${content}`) } if (await inboxPage.getByRole('button', { name: '确认创建', exact: true }).count() !== 1) { failures.push('Inbox page must render exactly one 确认创建 action') } const firstInboxRow = inboxPage.locator('.mail-item', { hasText: '整理客户访谈' }) const secondInboxRow = inboxPage.locator('.mail-item', { hasText: '项目复盘记录' }) await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() await secondInboxRow.click() await page.waitForTimeout(300) if (await inboxPage.getByRole('checkbox').count() !== 0) { failures.push('a stale analysis response must not attach the previous item drafts to the newly selected Inbox item') } await firstInboxRow.click() await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) if (inboxAnalyzeRequests.length !== 2 || inboxConfirmRequests.length !== 0) { failures.push('analysis must load drafts without invoking confirmation writes') } if (await inboxPage.getByRole('checkbox').count() !== 3) { failures.push('analysis must render one checkbox for each saved suggestion') } const sourceSuggestionCheckbox = inboxPage.locator('.arco-checkbox', { hasText: '访谈背景资料' }) await sourceSuggestionCheckbox.click() if (await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isChecked()) { failures.push('suggestion checkbox must allow the user to exclude a draft before confirmation') } const confirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true }) expectingInboxConfirmError = true await confirmButton.click() await page.waitForTimeout(50) if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) { failures.push('Inbox confirmation must show loading while the write is pending') } await page.waitForTimeout(300) if (!await inboxPage.getByText('确认创建失败,请稍后重试', { exact: true }).isVisible()) { failures.push('failed Inbox confirmation must show the API Chinese error') } expectingInboxConfirmError = false if (expectedInboxConfirmConsoleErrorCount !== 1) { failures.push(`expected one simulated Inbox confirm console error, got ${expectedInboxConfirmConsoleErrorCount}`) } const workspaceRequestCount = workspaceRequests.length await confirmButton.click() await inboxPage.getByText('已创建 2 个对象', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) if (!await inboxPage.getByText('已创建 2 个对象', { exact: true }).isVisible()) { failures.push('successful Inbox confirmation must show the created object count') } const submitted = inboxConfirmRequests.at(-1)?.suggestionIds ?? [] if (JSON.stringify(submitted) !== JSON.stringify([inboxTaskSuggestionId, inboxNoteSuggestionId])) { failures.push(`Inbox confirm must send only checked saved suggestion identities, got ${JSON.stringify(submitted)}`) } if (workspaceRequests.length <= workspaceRequestCount) { failures.push('successful Inbox confirmation must refresh the project workspace') } await page.screenshot({ path: 'test-results/project-inbox-confirmed.png', fullPage: true }) await page.setViewportSize({ width: 390, height: 844 }) await page.waitForTimeout(250) await page.screenshot({ path: 'test-results/project-inbox-confirmed-mobile.png', fullPage: true }) const inboxMobileLayout = await inboxPage.evaluate((node) => { const workflow = node.querySelector('.mail-layout') const detail = node.querySelector('.inbox-detail') const detailRight = detail?.getBoundingClientRect().right ?? node.getBoundingClientRect().right const clippedDetailLabels = [...node.querySelectorAll('.inbox-detail-head > div > .arco-typography-secondary, .inbox-draft-section .section-header > .arco-typography-secondary')] .filter((label) => label.getBoundingClientRect().right > detailRight + 1) .map((label) => label.textContent?.trim()) return { pageOverflow: node.scrollWidth > node.clientWidth, workflowOverflow: workflow ? workflow.scrollWidth > workflow.clientWidth : true, clippedDetailLabels, } }) if (inboxMobileLayout.pageOverflow || inboxMobileLayout.workflowOverflow || inboxMobileLayout.clippedDetailLabels.length) { failures.push(`Inbox confirmation page must stack without mobile horizontal overflow, got ${JSON.stringify(inboxMobileLayout)}`) } await page.setViewportSize({ width: 1440, height: 1024 }) await secondInboxRow.click() await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) expectingInboxRefreshError = true const secondConfirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true }) await secondConfirmButton.click() await inboxPage.getByText('已创建 3 个对象', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) if (!await inboxPage.getByText('已创建 3 个对象', { exact: true }).isVisible()) { failures.push('a completed confirmation must preserve its created count when workspace refresh fails') } if (!await inboxPage.getByText('对象已创建,但工作区刷新失败,请稍后重新进入项目', { exact: true }).isVisible()) { failures.push('workspace refresh failure after confirmation must be shown separately in Chinese') } if (!await secondConfirmButton.isDisabled()) { failures.push('a confirmed item must remain non-repeatable when its workspace refresh fails') } expectingInboxRefreshError = false if (expectedInboxRefreshConsoleErrorCount !== 1) { failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`) } await page.screenshot({ path: 'test-results/project-inbox-refresh-warning.png', fullPage: true }) } } const channelPageChecks = [] for (const channel of [ { label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' }, { label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: '选择专家,开始对话' }, { label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' }, { label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' }, { label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' }, ]) { await page.locator('.channel-button', { hasText: channel.label }).click() await page.waitForTimeout(250) channelPageChecks.push(await page.evaluate((expected) => { const pageNode = document.querySelector(`.${expected.pageClass}`) const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? '' const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? '' return { ...expected, foundPage: Boolean(pageNode), heading, activeChannel, } }, channel)) } await page.getByRole('button', { name: '切换深色模式', exact: true }).click() await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true }) await browser.close() await server.close() console.log(JSON.stringify({ workspaceMetrics, workspaceExploreMetrics, projectMetrics, mobileProjectMetrics, channelSidebarHoverMetrics, stageHoverMetrics, channelListHoverMetrics, channelPageChecks, errors, }, null, 2)) const metrics = workspaceMetrics if (errors.length) failures.push(`console errors: ${errors.join('; ')}`) if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`) if (metrics.statusUserTag !== 'DIV') failures.push(`status user must be a non-button DIV container, got ${metrics.statusUserTag}`) if (metrics.nestedButtonCount !== 0) failures.push(`expected no nested buttons, got ${metrics.nestedButtonCount}`) if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`) if (metrics.overflowX) failures.push('expected no horizontal overflow') if (!metrics.workbenchMain) failures.push('missing workbench main') if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page') if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login') if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail') if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login') if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page') if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`) if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`) if (metrics.workspaceActions.some((text) => text?.includes('团队视图') || text?.includes('进入重点项目'))) { failures.push(`expected workspace header actions to remove team view and focused project buttons, got ${JSON.stringify(metrics.workspaceActions)}`) } if (!metrics.projectRail || !metrics.stage) { failures.push(`missing workspace layout regions: rail=${JSON.stringify(metrics.projectRail)}, stage=${JSON.stringify(metrics.stage)}`) } else { if (Math.abs(metrics.stage.left - metrics.projectRail.right) > 1) { failures.push(`expected workspace stage to start after project rail, got stage.left=${metrics.stage.left}, rail.right=${metrics.projectRail.right}`) } if (Math.abs(metrics.stage.right - metrics.viewportWidth) > 1) { failures.push(`expected workspace stage to end at viewport right edge, got stage.right=${metrics.stage.right}, viewport=${metrics.viewportWidth}`) } } if (!metrics.projectRailWidth || metrics.projectRailWidth < 88) { failures.push(`expected project rail at least 88px wide, got ${metrics.projectRailWidth}`) } if (!metrics.projectButton) failures.push('missing project button') if (!metrics.horizontalInsets || Math.abs(metrics.horizontalInsets.left - metrics.horizontalInsets.right) > 1) { failures.push(`expected equal rail horizontal insets, got ${JSON.stringify(metrics.horizontalInsets)}`) } if (metrics.verticalGaps.some((gap) => gap !== 12)) { failures.push(`expected all project rail vertical gaps to be 12px, got ${JSON.stringify(metrics.verticalGaps)}`) } for (const [name, overflow] of [ ['project rail', metrics.projectRailOverflow], ['project rail children', metrics.projectRailChildrenOverflow], ]) { if (!overflow || overflow.overflowX !== 'hidden' || overflow.overflowY !== 'hidden') { failures.push(`expected ${name} scrollbars to be hidden with no overflow, got ${JSON.stringify(overflow)}`) } } if ( !metrics.firstProjectBadge || metrics.firstProjectBadge.right > metrics.projectRailWidth || metrics.firstProjectBadge.left < 0 ) { failures.push(`expected first project badge to stay inside the project rail, got ${JSON.stringify(metrics.firstProjectBadge)}`) } if ( !metrics.dashboardButton || metrics.dashboardButton.width !== metrics.projectButton?.width || metrics.dashboardButton.height !== metrics.projectButton?.height ) { failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`) } if ( !metrics.workspaceExploreButton || metrics.workspaceExploreButton.width !== metrics.projectButton?.width || metrics.workspaceExploreButton.height !== metrics.projectButton?.height ) { failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`) } if ( !metrics.createProjectButton || metrics.createProjectButton.width !== metrics.projectButton?.width || metrics.createProjectButton.height !== metrics.projectButton?.height ) { failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`) } if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected') if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page') if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`) if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`) if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) { failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`) } else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) { failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`) } if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page') if (projectMetrics.hasWorkspacePage) failures.push('expected project overview mode to leave workspace page') if (!projectMetrics.projectButtonActive) failures.push('expected first project button to be active in project mode') if (projectMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active in project mode') if (projectMetrics.inspector) { failures.push(`expected project page to remove right inspector until it becomes an on-demand panel, got ${JSON.stringify(projectMetrics.inspector)}`) } if (mobileProjectMetrics.overflowX) failures.push('mobile project viewport must not overflow horizontally') if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage) { failures.push( `missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`, ) } else { const sameTop = [projectMetrics.projectRail, projectMetrics.channelSidebar, projectMetrics.stage] .every((region) => Math.abs(region.top - projectMetrics.projectRail.top) <= 1) if (!sameTop) { failures.push( `expected project rail, channel sidebar, and stage to share the same top edge, got rail=${projectMetrics.projectRail.top}, sidebar=${projectMetrics.channelSidebar.top}, stage=${projectMetrics.stage.top}`, ) } if (Math.abs(projectMetrics.channelSidebar.left - projectMetrics.projectRail.right) > 1) { failures.push(`expected channel sidebar to sit after project rail, got sidebar.left=${projectMetrics.channelSidebar.left}, rail.right=${projectMetrics.projectRail.right}`) } if (Math.abs(projectMetrics.stage.left - projectMetrics.channelSidebar.right) > 1) { failures.push(`expected stage to sit after channel sidebar, got stage.left=${projectMetrics.stage.left}, sidebar.right=${projectMetrics.channelSidebar.right}`) } if (Math.abs(projectMetrics.stage.right - projectMetrics.viewportWidth) > 1) { failures.push(`expected project stage to end at viewport right edge, got stage.right=${projectMetrics.stage.right}, viewport=${projectMetrics.viewportWidth}`) } } for (const [name, overflow] of [ ['channel sidebar', projectMetrics.channelSidebarOverflow], ['channel list', projectMetrics.channelListOverflow], ['stage', projectMetrics.stageOverflow], ]) { if (!overflow || overflow.overflowX !== 'hidden' || overflow.overflowY !== 'auto') { failures.push(`expected ${name} to hide horizontal scrollbars and show vertical scrollbars only when needed, got ${JSON.stringify(overflow)}`) } if (overflow?.scrollbarWidth !== 'none') { failures.push(`expected ${name} scrollbar to be hidden until hover, got ${JSON.stringify(overflow)}`) } } for (const [name, overflow] of [ ['channel sidebar hover', channelSidebarHoverMetrics.channelSidebarOverflow], ['channel list hover', channelListHoverMetrics.channelListOverflow], ['stage hover', stageHoverMetrics.stageOverflow], ]) { if (overflow?.scrollbarWidth !== 'thin') { failures.push(`expected ${name} scrollbar to appear on hover, got ${JSON.stringify(overflow)}`) } } for (const check of channelPageChecks) { if (!check.foundPage) { failures.push(`expected ${check.label} to render .${check.pageClass}`) } if (!check.heading.includes(check.expectedHeading)) { failures.push(`expected ${check.label} heading to include ${check.expectedHeading}, got ${JSON.stringify(check.heading)}`) } if (!check.activeChannel.includes(check.label)) { failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`) } } if (failures.length) { throw new Error(failures.join('\n')) }