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 projectId = '019b0000-0000-7000-8000-000000000001' page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) }) const visualCheckWorkspace = { project: { id: projectId, name: '森林项目', identifier: 'forest', icon: '森', background: '#165DFF', description: '前后端 API 对齐视觉检查项目', initials: '森林', unreadCount: 2, }, channels: [ { id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 0, url: '', sortOrder: 1 }, { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 2 }, { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 }, { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 }, ], tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }], recentSessions: [], inbox: [ { id: '019b0000-0000-7000-8000-000000000003', projectId, source: '需求文档', title: '森林项目体验优化需求', summary: '整理工作台信息层级,并把关键需求沉淀为可跟进的项目计划。', status: 'open', tag: '需求', time: '2026-07-22T08:00:00Z', }, { id: '019b0000-0000-7000-8000-000000000004', projectId, source: '架构讨论', title: '项目资料组织方案', summary: '围绕项目上下文统一组织任务、笔记、资料和 AI 会话。', status: 'open', tag: '架构', time: '2026-07-22T07:00:00Z', }, ], tasks: [], aiSessions: [], notesSources: [], cronPlans: [], } await page.route('http://localhost:9150/api/v1/**', async (route) => { const url = new URL(route.request().url()) 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`) { await route.fulfill({ json: visualCheckWorkspace }) return } if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) { await route.fulfill({ json: [] }) return } await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } }) }) const collectMetrics = async () => page.evaluate(() => { const statusbar = document.querySelector('.statusbar') 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, statusName: rect(statusName), statusOnlineDot: rect(statusOnlineDot), searchHeight: search?.getBoundingClientRect().height, workbenchMain: rect(main), hasWorkspacePage: Boolean(workspacePage), hasProjectOverview: Boolean(projectOverview), exploreSourceCardCount: document.querySelectorAll('.explore-source-card').length, hasExploreReader: Boolean(document.querySelector('.explore-reader-layout')), 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 }) 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.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.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 channelPageChecks = [] for (const channel of [ { label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' }, { label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: 'AI 助手' }, { 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('h4, h5')?.textContent ?? '' const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? '' return { ...expected, foundPage: Boolean(pageNode), heading, activeChannel, } }, channel)) } await page.locator('.topbar-actions .arco-btn').first().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, channelSidebarHoverMetrics, stageHoverMetrics, channelListHoverMetrics, channelPageChecks, errors, }, null, 2)) const failures = [] 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.statusName) failures.push('expected authenticated status label') 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.exploreSourceCardCount !== 4) failures.push(`expected four legacy explore source cards, got ${workspaceExploreMetrics.exploreSourceCardCount}`) if (!workspaceExploreMetrics.hasExploreReader) failures.push('expected legacy explore article reader to render') 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 (!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')) }