Files
agent/apps/web_v1/scripts/visual-check.mjs

727 lines
36 KiB
JavaScript

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'
const visualExperts = [
{
id: '019b0000-0000-7000-8000-000000000020', slug: 'product-manager', category: 'product', categoryName: '产品',
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
},
{
id: '019b0000-0000-7000-8000-000000000021', slug: 'ui-designer', category: 'design', categoryName: '设计',
name: 'UI 设计师', description: '负责界面设计、组件规范和视觉一致性。', emoji: '🎨', color: '#722ED1',
},
{
id: '019b0000-0000-7000-8000-000000000022', slug: 'frontend-developer', category: 'engineering', categoryName: '工程',
name: '前端开发者', description: '负责现代 Web 应用实现与性能优化。', emoji: '💻', color: '#0FC6C2',
},
]
let visualDatasetSources = [
{
id: '019b0000-0000-7000-8000-000000000030', name: '手动收集', kind: 'manual', url: '', iconUrl: '',
description: '手动收集的文章与线索', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1,
createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000031', name: '需求文档', kind: 'link', url: 'https://example.com/requirements', iconUrl: '',
description: '产品需求与业务文档', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1,
createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000032', name: '架构讨论', kind: 'link', url: 'https://example.com/architecture', iconUrl: '',
description: '架构方案与系统设计讨论', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 0,
createdAt: '2026-07-22T06:00:00Z', updatedAt: '2026-07-22T06:00:00Z',
},
]
let visualDatasetItems = [
{
id: '019b0000-0000-7000-8000-000000000040', sourceId: visualDatasetSources[0].id,
title: '森林项目体验优化需求', summary: '整理工作台信息层级,并沉淀为可跟进计划。', content: '探索数据条目的正文内容。',
url: 'https://example.com/article-1',
imageUrl: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22152%22 height=%22116%22%3E%3Crect width=%22152%22 height=%22116%22 fill=%22%23165DFF%22/%3E%3C/svg%3E',
status: 'unread', starred: false, publishedAt: '2026-07-22T08:00:00Z',
createdAt: '2026-07-22T08:00:00Z', updatedAt: '2026-07-22T08:00:00Z',
},
{
id: '019b0000-0000-7000-8000-000000000041', sourceId: visualDatasetSources[1].id,
title: '项目资料组织方案', summary: '围绕项目上下文统一组织任务、笔记、资料和 AI 会话。', content: '资料组织方案正文。',
url: 'https://example.com/article-2', imageUrl: '', status: 'read', starred: true, publishedAt: '2026-07-22T07:00:00Z',
createdAt: '2026-07-22T07:00:00Z', updatedAt: '2026-07-22T07:00:00Z',
},
]
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: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'email', count: 2, url: '', sortOrder: 1 },
{ 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: '产品' },
{ id: '019b0000-0000-7000-8000-000000000005', 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: [
{
id: '019b0000-0000-7000-8000-000000000006',
projectId,
title: '梳理产品需求',
summary: '确认首版工作台的功能边界。',
completed: false,
owner: '森林AI',
createdAt: '2026-07-22',
completedAt: '',
duration: '',
progress: '40%',
tagId: '019b0000-0000-7000-8000-000000000002',
tag: '产品',
},
{
id: '019b0000-0000-7000-8000-000000000007',
projectId,
title: '优化会话界面',
summary: '统一 AI 会话的阅读与输入体验。',
completed: false,
owner: '森林AI',
createdAt: '2026-07-22',
completedAt: '',
duration: '',
progress: '60%',
tagId: '019b0000-0000-7000-8000-000000000005',
tag: '设计',
},
{
id: '019b0000-0000-7000-8000-000000000008',
projectId,
title: '确认项目定位',
summary: '完成项目功能与亮点说明。',
completed: true,
owner: '森林AI',
createdAt: '2026-07-21',
completedAt: '2026-07-22',
duration: '1 天',
progress: '100%',
tagId: '019b0000-0000-7000-8000-000000000002',
tag: '产品',
},
],
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`) {
if (route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const expert = visualExperts.find((item) => item.id === input.expertId)
await route.fulfill({
json: {
id: '019b0000-0000-7000-8000-000000000010',
projectId,
title: input.title,
context: input.context,
status: 'ready',
expert,
createdAt: '2026-07-22T09:00:00Z',
updatedAt: '2026-07-22T09:00:00Z',
},
})
return
}
await route.fulfill({
json: [{
id: '019b0000-0000-7000-8000-000000000009',
projectId,
title: '报价分析',
context: '分析当前方案的报价结构。',
status: 'ready',
expert: visualExperts[1],
createdAt: '2026-07-22T08:30:00Z',
updatedAt: '2026-07-22T08:30:00Z',
}],
})
return
}
if (url.pathname === '/api/v1/ai-experts') {
await route.fulfill({ json: visualExperts })
return
}
if (url.pathname === '/api/v1/dataset-sources' && route.request().method() === 'GET') {
await route.fulfill({ json: visualDatasetSources })
return
}
if (url.pathname === '/api/v1/dataset-sources' && route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const created = {
id: `019b0000-0000-7000-8000-${String(50 + visualDatasetSources.length).padStart(12, '0')}`,
...input,
builtIn: false,
lastSyncedAt: null,
itemCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
visualDatasetSources = [...visualDatasetSources, created]
await route.fulfill({ status: 201, json: created })
return
}
if (url.pathname.startsWith('/api/v1/dataset-sources/') && route.request().method() === 'PATCH') {
const id = url.pathname.split('/').at(-1)
const input = route.request().postDataJSON()
const current = visualDatasetSources.find((source) => source.id === id)
const updated = { ...current, ...input, updatedAt: new Date().toISOString() }
visualDatasetSources = visualDatasetSources.map((source) => source.id === id ? updated : source)
await route.fulfill({ json: updated })
return
}
if (url.pathname.startsWith('/api/v1/dataset-sources/') && route.request().method() === 'DELETE') {
const id = url.pathname.split('/').at(-1)
visualDatasetSources = visualDatasetSources.filter((source) => source.id !== id)
visualDatasetItems = visualDatasetItems.filter((item) => item.sourceId !== id)
await route.fulfill({ status: 204, body: '' })
return
}
if (url.pathname === '/api/v1/dataset-items' && route.request().method() === 'GET') {
const sourceId = url.searchParams.get('sourceId')
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 50)
const matchingItems = sourceId
? visualDatasetItems.filter((item) => item.sourceId === sourceId)
: visualDatasetItems
await route.fulfill({
json: {
items: matchingItems.slice(offset, offset + limit),
total: matchingItems.length,
offset,
limit,
},
})
return
}
if (url.pathname.endsWith('/deposit') && route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: { noteId: '019b0000-0000-7000-8000-000000000099', projectId } })
return
}
if (url.pathname.startsWith('/api/v1/dataset-items/') && route.request().method() === 'PATCH') {
const id = url.pathname.split('/').at(-1)
const input = route.request().postDataJSON()
const current = visualDatasetItems.find((item) => item.id === id)
const updated = { ...current, ...input, updatedAt: new Date().toISOString() }
visualDatasetItems = visualDatasetItems.map((item) => item.id === id ? updated : item)
await route.fulfill({ json: updated })
return
}
if (url.pathname === '/api/v1/dataset-sources/sync') {
await route.fulfill({
status: 200,
json: visualDatasetSources.map((source) => ({
sourceId: source.id,
status: 'completed',
result: 'format=rss fetched=2 inserted=0',
})),
})
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 statusUpgrade = document.querySelector('.status-upgrade')
const statusSystem = document.querySelector('.status-system')
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),
statusUpgrade: rect(statusUpgrade),
statusSystemText: statusSystem?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
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')),
hasInboxChannel: [...document.querySelectorAll('.channel-button')].some((button) => button.textContent?.includes('Inbox')),
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,
dashboardUsesPanelIcon: Boolean(dashboard?.querySelector('.arco-icon-dashboard')),
dashboardUsesBrandImage: Boolean(dashboard?.querySelector('img')),
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.locator('.login-form input').first().fill('localhost:9150')
await page.waitForSelector('.connection-card.online')
const loginConnectionStatus = await page.locator('.connection-card').textContent()
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.getByRole('button', { name: '添加数据源' }).click()
await page.waitForSelector('.explore-source-modal')
await page.waitForTimeout(250)
await page.screenshot({ path: 'test-results/explore-add-source-modal.png', fullPage: true })
const addSourceModalCheck = await page.evaluate(() => ({
title: document.querySelector('.explore-source-modal .arco-modal-title')?.textContent ?? '',
inputCount: document.querySelectorAll('.explore-source-modal input, .explore-source-modal textarea').length,
}))
await page.locator('.explore-source-modal input').first().fill('行业资讯')
await page.locator('.explore-source-modal input[placeholder="https://example.com/feed"]').fill('https://example.com/feed')
await page.locator('.explore-source-modal .arco-modal-footer .arco-btn-primary').click()
const addedSourceVisible = await page.locator('.explore-source-card', { hasText: '行业资讯' }).count() === 1
await page.locator('.explore-source-card').nth(1).hover()
await page.getByRole('button', { name: '编辑手动收集' }).click()
await page.waitForSelector('.explore-source-modal')
await page.waitForTimeout(250)
await page.screenshot({ path: 'test-results/explore-edit-source-modal.png', fullPage: true })
const editSourceModalCheck = await page.evaluate(() => ({
title: document.querySelector('.explore-source-modal .arco-modal-title')?.textContent ?? '',
name: document.querySelector('.explore-source-modal input')?.value ?? '',
}))
await page.locator('.explore-source-modal input').first().fill('手动整理')
await page.locator('.explore-source-modal .arco-modal-footer .arco-btn-primary').click()
const editedSourceCard = page.locator('.explore-source-card', { hasText: '手动整理' })
await editedSourceCard.waitFor({ state: 'visible' })
const editedSourceVisible = await editedSourceCard.count() === 1
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 = []
let taskTagFilterCheck = null
let aiSessionCheck = null
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)
const pageCheck = 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,
inputCount: pageNode?.querySelectorAll('input, textarea').length ?? 0,
hasSaveChannel: [...(pageNode?.querySelectorAll('button') ?? [])].some((button) => button.textContent?.includes('保存频道')),
hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')),
hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')),
hasComposer: Boolean(pageNode?.querySelector('.agent-composer')),
hasExpertPicker: Boolean(pageNode?.querySelector('.agent-expert-picker')),
expertHeading: pageNode?.querySelector('.agent-expert-heading')?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
}
}, channel)
if (channel.label === '工作计划') {
await page.getByRole('button', { name: '设计', exact: true }).click()
await page.waitForTimeout(150)
taskTagFilterCheck = await page.evaluate(() => ({
activeTag: document.querySelector('.task-tag-filter.active')?.textContent?.trim() ?? '',
pendingTitles: [...document.querySelectorAll('.task-plan-title')].map((node) => node.textContent?.trim()),
completedTitles: [...document.querySelectorAll('.completed-plan-title')].map((node) => node.textContent?.trim()),
pressed: document.querySelector('.task-tag-filter[aria-pressed="true"]')?.textContent?.trim() ?? '',
}))
await page.screenshot({ path: 'test-results/project-task-tag-filter-light.png', fullPage: true })
}
if (channel.label === 'AI 会话') {
await page.screenshot({ path: 'test-results/project-ai-expert-picker-light.png', fullPage: true })
await page.getByRole('button', { name: /产品经理/ }).click()
await page.locator('.agent-composer textarea').fill('请分析这个项目的下一步计划')
await page.getByRole('button', { name: '发送消息' }).click()
await page.waitForSelector('.agent-user-message')
aiSessionCheck = await page.evaluate(() => ({
userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '',
activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '',
composerValue: document.querySelector('.agent-composer textarea')?.value ?? '',
expertName: document.querySelector('.agent-assistant-name')?.textContent?.trim() ?? '',
}))
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
}
channelPageChecks.push(pageCheck)
}
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,
addSourceModalCheck,
addedSourceVisible,
editSourceModalCheck,
editedSourceVisible,
projectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
taskTagFilterCheck,
aiSessionCheck,
errors,
}, null, 2))
const failures = []
const metrics = workspaceMetrics
if (!loginConnectionStatus?.includes('连接正常')) failures.push(`scheme-less server address must connect, got ${JSON.stringify(loginConnectionStatus)}`)
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.statusOnlineDot) failures.push('expected legacy online status dot')
if (!metrics.statusUpgrade) failures.push('expected legacy upgrade action')
if (!metrics.statusSystemText.includes('3 个计划进行中') || !metrics.statusSystemText.includes('AI 空闲') || !metrics.statusSystemText.includes('152GB 可用')) {
failures.push(`expected legacy system status summary, got ${JSON.stringify(metrics.statusSystemText)}`)
}
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.dashboardUsesPanelIcon || metrics.dashboardUsesBrandImage) failures.push('expected workspace entry to use the panel icon instead of the brand logo')
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 (!addSourceModalCheck.title.includes('添加数据源') || addSourceModalCheck.inputCount < 3) {
failures.push(`expected add data source modal with fields, got ${JSON.stringify(addSourceModalCheck)}`)
}
if (!addedSourceVisible) failures.push('expected saved data source to appear in the source cards')
if (!editSourceModalCheck.title.includes('编辑数据源') || editSourceModalCheck.name !== '手动收集') {
failures.push(`expected prefilled edit data source modal, got ${JSON.stringify(editSourceModalCheck)}`)
}
if (!editedSourceVisible) failures.push('expected edited data source name to update its card')
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.hasInboxChannel) failures.push('expected removed Inbox message stream to stay hidden from project channels')
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.expectedHeading && !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 (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) {
failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
}
if (check.label === 'AI 会话' && (
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||
!check.expertHeading.includes('选择专家进行会话')
)) {
failures.push(`expected headerless AI session chat layout, got ${JSON.stringify(check)}`)
}
}
if (
!taskTagFilterCheck ||
taskTagFilterCheck.activeTag !== '设计' ||
taskTagFilterCheck.pressed !== '设计' ||
taskTagFilterCheck.pendingTitles.join('|') !== '优化会话界面' ||
taskTagFilterCheck.completedTitles.length !== 0
) {
failures.push(`expected clickable task tags to filter pending and completed plans, got ${JSON.stringify(taskTagFilterCheck)}`)
}
if (
!aiSessionCheck ||
aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' ||
!aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') ||
aiSessionCheck.composerValue !== '' ||
aiSessionCheck.expertName !== '产品经理'
) {
failures.push(`expected AI composer to create and select a session, got ${JSON.stringify(aiSessionCheck)}`)
}
if (failures.length) {
throw new Error(failures.join('\n'))
}