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

1283 lines
64 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 failures = []
const projectId = '019b0000-0000-7000-8000-000000000001'
const secondProjectId = '019b0000-0000-7000-8000-000000000012'
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 thirdInboxItemId = '019b0000-0000-7000-8000-000000000013'
const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014'
const aiSessionId = '019b0000-0000-7000-8000-000000000015'
const createdAISessionId = '019b0000-0000-7000-8000-000000000016'
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 = []
const aiSessionRequests = []
let delayedAIListsRemaining = 0
let delayNextAICreate = false
let delayedAICreateCompleted = false
let failNextSecondProjectAIList = false
let expectingSecondProjectAIListError = false
let expectedSecondProjectAIListConsoleErrorCount = 0
let expectingProjectPatchError = false
let expectedProjectPatchConsoleErrorCount = 0
let expectingInboxConfirmError = false
let expectedInboxConfirmConsoleErrorCount = 0
let expectingInboxRefreshError = false
let expectedInboxRefreshConsoleErrorCount = 0
let expectingInboxConflict = false
let expectedInboxConflictConsoleErrorCount = 0
let expectingInboxValidationError = false
let expectedInboxValidationConsoleErrorCount = 0
let failNextInboxConfirm = true
let failNextInboxWorkspaceRefresh = false
let conflictNextInboxConfirm = false
let validationNextInboxConfirm = false
let invalidResponseNextInboxConfirm = false
const controlledAISessions = [
{
id: aiSessionId,
projectId,
title: '项目风险梳理',
context: '只维护会话上下文,不创建正式对象。',
status: 'ready',
createdAt: '2026-07-21T03:00:00Z',
updatedAt: '2026-07-21T03:00:00Z',
},
]
page.on('console', (message) => {
if (message.type() !== 'error') return
if (
expectingSecondProjectAIListError &&
message.text().includes('Failed to load resource')
) {
expectedSecondProjectAIListConsoleErrorCount += 1
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().includes('Failed to load resource')
) {
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
}
if (
expectingInboxConflict &&
expectedInboxConflictConsoleErrorCount === 0 &&
message.text() === 'Failed to load resource: the server responded with a status of 409 (Conflict)'
) {
expectedInboxConflictConsoleErrorCount += 1
return
}
if (
expectingInboxValidationError &&
expectedInboxValidationConsoleErrorCount === 0 &&
message.text() === 'Failed to load resource: the server responded with a status of 400 (Bad Request)'
) {
expectedInboxValidationConsoleErrorCount += 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: 3, 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',
},
{
id: thirdInboxItemId,
projectId,
source: '手动收集',
title: '延迟确认测试',
summary: '确认期间切换项目时必须保留最新选择。',
status: 'open',
tag: '待处理',
time: '2026-07-21T02:10:00Z',
},
],
tasks: [],
aiSessions: [],
notesSources: [],
cronPlans: [],
}
const secondVisualCheckWorkspace = {
project: {
id: secondProjectId,
name: '并行项目',
identifier: 'parallel',
icon: '并',
background: '#722ED1',
description: '用于验证异步操作不会恢复旧项目选择。',
initials: '并行',
unreadCount: 0,
},
channels: [
{ id: 'second-overview', projectId: secondProjectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
{ id: 'second-ai', projectId: secondProjectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 1 },
],
tags: [],
recentSessions: [],
inbox: [],
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, secondVisualCheckWorkspace.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/projects/${secondProjectId}/workspace`) {
workspaceRequests.push(url.pathname)
await route.fulfill({ json: secondVisualCheckWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
const responseSnapshot = structuredClone(controlledAISessions)
const delay = delayedAIListsRemaining > 0 ? 700 : 120
if (delayedAIListsRemaining > 0) delayedAIListsRemaining -= 1
await new Promise((resolve) => setTimeout(resolve, delay))
await route.fulfill({ json: responseSnapshot }).catch(() => {})
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'POST') {
const input = route.request().postDataJSON()
aiSessionRequests.push(input)
const created = {
id: createdAISessionId,
projectId,
title: input.title,
context: input.context,
status: 'ready',
createdAt: '2026-07-21T03:10:00Z',
updatedAt: '2026-07-21T03:10:00Z',
}
if (delayNextAICreate) {
delayNextAICreate = false
await new Promise((resolve) => setTimeout(resolve, 700))
delayedAICreateCompleted = true
}
controlledAISessions.unshift(created)
await route.fulfill({ status: 201, json: created }).catch(() => {})
return
}
if (url.pathname === `/api/v1/projects/${secondProjectId}/ai-sessions` && method === 'GET') {
if (failNextSecondProjectAIList) {
await route.fulfill({
status: 500,
json: { error: { code: 'ai_list_failed', message: '新项目会话加载失败' } },
})
return
}
await route.fulfill({ json: [] })
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 (url.pathname === `/api/v1/inbox/${thirdInboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(thirdInboxItemId)
await route.fulfill({
json: {
suggestions: [
{ id: thirdInboxTaskSuggestionId, kind: 'task', title: '延迟确认任务', body: '验证跨项目异步选择。' },
],
},
})
return
}
if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`, `/api/v1/inbox/${thirdInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') {
inboxConfirmRequests.push(route.request().postDataJSON())
await new Promise((resolve) => setTimeout(resolve, 250))
if (failNextInboxConfirm) {
failNextInboxConfirm = false
await route.abort('failed')
return
}
if (conflictNextInboxConfirm) {
conflictNextInboxConfirm = false
await route.fulfill({
status: 409,
json: { error: { code: 'conflict', message: '确认状态发生冲突' } },
})
return
}
if (validationNextInboxConfirm) {
validationNextInboxConfirm = false
await route.fulfill({
status: 400,
json: { error: { code: 'invalid_request', message: '建议选择无效,请重新核对' } },
})
return
}
if (invalidResponseNextInboxConfirm) {
invalidResponseNextInboxConfirm = false
await route.fulfill({
status: 200,
contentType: 'text/plain',
body: 'unparseable confirmation result',
})
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' })
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()
const workspaceExploreControls = await page.evaluate(() => ({
emptyState: document.querySelector('.workspace-explore-page')?.textContent ?? '',
buttonCount: document.querySelectorAll('.workspace-explore-page button').length,
}))
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 })
await page.waitForFunction(() => [...document.querySelectorAll('.project-rail, .channel-sidebar')]
.every((node) => getComputedStyle(node).visibility === 'hidden'))
const mobileProjectMetrics = await collectMetrics()
await page.screenshot({ path: 'test-results/project-react-acro-mobile.png', fullPage: true })
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')
}
if (!await confirmButton.isDisabled()) {
failures.push('Inbox confirmation must remain disabled while the write is pending')
}
await page.waitForTimeout(300)
if (!await inboxPage.getByText('确认结果未知,服务端支持幂等处理,可安全重试', { exact: true }).isVisible()) {
failures.push('a network-failed Inbox confirmation must explain that an idempotent retry is safe')
}
expectingInboxConfirmError = false
if (expectedInboxConfirmConsoleErrorCount !== 1) {
failures.push(`expected one simulated Inbox confirm console error, got ${expectedInboxConfirmConsoleErrorCount}`)
}
const firstUncertainRequest = inboxConfirmRequests.at(-1)
const analyzeRequestCountAfterUncertainFailure = inboxAnalyzeRequests.length
const analyzeButtonAfterUncertainFailure = inboxPage.getByRole('button', { name: '分析内容', exact: true })
if (!await analyzeButtonAfterUncertainFailure.isDisabled()) {
failures.push('an uncertain Inbox confirmation must freeze re-analysis')
}
if (!await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isDisabled()) {
failures.push('an uncertain Inbox confirmation must freeze suggestion selection')
}
if (!await secondInboxRow.isDisabled()) {
failures.push('an uncertain Inbox confirmation must freeze Inbox item switching')
}
const sourceCheckedBeforeForcedChange = await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isChecked()
await sourceSuggestionCheckbox.click({ force: true })
if (await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isChecked() !== sourceCheckedBeforeForcedChange) {
failures.push('an uncertain Inbox confirmation must reject forced checkbox changes')
}
await analyzeButtonAfterUncertainFailure.click({ force: true })
await page.waitForTimeout(50)
if (inboxAnalyzeRequests.length !== analyzeRequestCountAfterUncertainFailure) {
failures.push('an uncertain Inbox confirmation must not send another analyze request')
}
const retryConfirmButton = inboxPage.getByRole('button', { name: '重试确认', exact: true })
if (await retryConfirmButton.count() !== 1) {
failures.push('an uncertain Inbox confirmation must change the CTA to 重试确认')
}
const workspaceRequestCount = workspaceRequests.length
const retryAction = await retryConfirmButton.count() === 1 ? retryConfirmButton : confirmButton
invalidResponseNextInboxConfirm = true
await retryAction.click()
await page.waitForTimeout(350)
if (JSON.stringify(inboxConfirmRequests.at(-1)) !== JSON.stringify(firstUncertainRequest)) {
failures.push(`an invalid-response retry must reuse the exact uncertain request body, got ${JSON.stringify(inboxConfirmRequests.at(-1))}`)
}
const retryAfterInvalidResponse = inboxPage.getByRole('button', { name: '重试确认', exact: true })
if (await retryAfterInvalidResponse.count() !== 1) {
failures.push('an invalid confirmation response must preserve the uncertain retry state')
}
if (!await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isDisabled()) {
failures.push('an invalid confirmation response must keep suggestion selection frozen')
}
const finalRetryAction = await retryAfterInvalidResponse.count() === 1
? retryAfterInvalidResponse
: inboxPage.getByRole('button', { name: '确认创建', exact: true })
await finalRetryAction.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 (JSON.stringify(inboxConfirmRequests.at(-1)) !== JSON.stringify(firstUncertainRequest)) {
failures.push(`an uncertain Inbox retry must reuse the exact first request body, got first=${JSON.stringify(firstUncertainRequest)} retry=${JSON.stringify(inboxConfirmRequests.at(-1))}`)
}
if (await secondInboxRow.isDisabled()) {
failures.push('a successful Inbox retry must clear the uncertain state for other Inbox items')
}
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(() => {})
validationNextInboxConfirm = true
expectingInboxValidationError = true
const secondConfirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true })
await secondConfirmButton.click()
await inboxPage.getByText('建议选择无效,请重新核对', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
if (await inboxPage.getByRole('button', { name: '重试确认', exact: true }).count() !== 0) {
failures.push('a deterministic validation failure must not enter the uncertain retry state')
}
if (await inboxPage.getByRole('checkbox').first().isDisabled()) {
failures.push('a deterministic validation failure must leave suggestion selection editable')
}
if (await inboxPage.getByRole('button', { name: '分析内容', exact: true }).isDisabled()) {
failures.push('a deterministic validation failure must leave re-analysis available')
}
expectingInboxValidationError = false
if (expectedInboxValidationConsoleErrorCount !== 1) {
failures.push(`expected one simulated Inbox validation console error, got ${expectedInboxValidationConsoleErrorCount}`)
}
expectingInboxRefreshError = 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 thirdInboxRow = inboxPage.locator('.mail-item', { hasText: '延迟确认测试' })
await thirdInboxRow.click()
await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {})
const thirdConfirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true })
await thirdConfirmButton.click()
await page.waitForTimeout(50)
if (!await thirdConfirmButton.isDisabled()) {
failures.push('delayed Inbox confirmation must stay disabled before its response')
}
await page.locator('.project-button[title="并行项目"]').click()
await page.waitForTimeout(700)
const projectAfterDelayedConfirm = await page.locator('.project-title h5').textContent()
if (projectAfterDelayedConfirm !== '并行项目') {
failures.push(`delayed Inbox confirmation must preserve the latest project selection, got ${projectAfterDelayedConfirm}`)
}
if (!await page.locator('.project-button[title="并行项目"]').evaluate((button) => button.classList.contains('active'))) {
failures.push('the project selected during Inbox confirmation must remain active after the request settles')
}
await page.locator('.project-button').first().click()
await page.waitForTimeout(250)
await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click()
const conflictInboxPage = page.locator('.project-inbox-page')
await conflictInboxPage.locator('.mail-item', { hasText: '延迟确认测试' }).click()
await conflictInboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await conflictInboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {})
conflictNextInboxConfirm = true
expectingInboxConflict = true
const workspaceRequestsBeforeConflict = workspaceRequests.length
const conflictConfirmButton = conflictInboxPage.getByRole('button', { name: '确认创建', exact: true })
await conflictConfirmButton.click()
await conflictInboxPage.getByText('确认状态发生冲突,工作区已刷新,请重新核对', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
if (workspaceRequests.length <= workspaceRequestsBeforeConflict) {
failures.push('a non-idempotency 409 must refresh the current workspace before reporting the conflict')
}
if (!await conflictConfirmButton.isDisabled()) {
failures.push('a conflict refresh that finds the item processed must prevent another confirmation')
}
if (await conflictInboxPage.getByText('确认结果未知,服务端支持幂等处理,可安全重试', { exact: true }).count() !== 0) {
failures.push('a 409 conflict must not be presented as a safe retryable network uncertainty')
}
expectingInboxConflict = false
if (expectedInboxConflictConsoleErrorCount !== 1) {
failures.push(`expected one simulated Inbox conflict console error, got ${expectedInboxConflictConsoleErrorCount}`)
}
}
}
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)
if (channel.label === '工作计划') {
await page.screenshot({ path: 'test-results/project-tasks.png', fullPage: true })
}
if (channel.label === 'AI 助手') {
const aiPage = page.locator('.project-ai-page')
await aiPage.getByText('项目风险梳理', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await aiPage.locator('input').fill('新会话视觉检查')
await aiPage.locator('textarea').fill('只创建受控会话入口')
await aiPage.getByRole('button', { name: '创建会话', exact: true }).click()
await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.screenshot({ path: 'test-results/project-ai-controlled.png', fullPage: true })
}
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))
if (channel.label === 'AI 助手') {
await page.locator('.channel-button', { hasText: '工作计划' }).click()
delayedAIListsRemaining = 2
await page.locator('.channel-button', { hasText: 'AI 助手' }).click()
const sameProjectRacePage = page.locator('.project-ai-page')
await sameProjectRacePage.locator('input').fill('乱序请求保留的新会话')
await sameProjectRacePage.locator('textarea').fill('POST 完成后,旧 GET 不得覆盖结果')
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.waitForTimeout(850)
if (await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).count() !== 1) {
failures.push('a stale AI session GET must not overwrite a newer POST result in the same project')
}
delayedAIListsRemaining = 0
delayNextAICreate = true
await sameProjectRacePage.locator('input').fill('旧项目延迟会话')
await sameProjectRacePage.locator('textarea').fill('切换项目后不得回流')
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
failNextSecondProjectAIList = true
expectingSecondProjectAIListError = true
await page.locator('.project-button[title="并行项目"]').click()
await page.locator('.project-title h5').getByText('并行项目', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.locator('.channel-button', { hasText: 'AI 助手' }).click()
const secondProjectAIPage = page.locator('.project-ai-page')
await secondProjectAIPage.waitFor({ state: 'visible', timeout: 2000 })
await page.waitForTimeout(500)
const secondProjectAIText = await secondProjectAIPage.textContent()
if (!secondProjectAIText?.includes('新项目会话加载失败')) {
failures.push(`the new project AI list failure must be visible, got ${JSON.stringify(secondProjectAIText)}`)
}
if (!secondProjectAIText?.includes('暂无 AI 会话')) {
failures.push(`the new project AI list failure must retain an empty list, got ${JSON.stringify(secondProjectAIText)}`)
}
await page.waitForTimeout(850)
if (!delayedAICreateCompleted) failures.push('the simulated old-project AI POST did not complete')
if (await secondProjectAIPage.getByText('旧项目延迟会话', { exact: true }).count() !== 0) {
failures.push('an old-project AI POST must not mutate the newly selected project')
}
if (await secondProjectAIPage.getByText('新项目会话加载失败', { exact: true }).count() !== 1) {
failures.push('an old-project AI POST completion must not clear the new project load error')
}
failNextSecondProjectAIList = false
expectingSecondProjectAIListError = false
if (expectedSecondProjectAIListConsoleErrorCount < 1) {
failures.push(`expected a simulated second-project AI list console error, got ${expectedSecondProjectAIListConsoleErrorCount}`)
}
await page.locator('.project-button[title="森林项目已更新"]').click()
await page.waitForTimeout(250)
}
}
const unsupportedControlMetrics = await page.evaluate(() => ({
topbarLabels: [...document.querySelectorAll('.topbar-actions button')].map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()),
statusbarText: document.querySelector('.statusbar')?.textContent ?? '',
newChannelText: document.querySelector('.project-new-channel-page')?.textContent ?? '',
newChannelButtonCount: document.querySelectorAll('.project-new-channel-page button').length,
}))
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,
workspaceExploreControls,
projectMetrics,
mobileProjectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
unsupportedControlMetrics,
aiSessionRequests,
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 (!workspaceExploreControls.emptyState.includes('暂未开放')) failures.push('workspace explore must present the unsupported feature as 暂未开放')
if (workspaceExploreControls.buttonCount !== 0) failures.push(`workspace explore must not render active controls, got ${workspaceExploreControls.buttonCount}`)
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 (aiSessionRequests.length !== 3) failures.push(`expected three controlled AI session requests, got ${aiSessionRequests.length}`)
for (const request of aiSessionRequests) {
const requestKeys = Object.keys(request).sort()
if (JSON.stringify(requestKeys) !== JSON.stringify(['context', 'title'])) {
failures.push(`AI session create must send only title/context, got ${JSON.stringify(requestKeys)}`)
}
}
if (unsupportedControlMetrics.topbarLabels.some((label) => label?.includes('停靠'))) {
failures.push(`topbar must not expose dock controls, got ${JSON.stringify(unsupportedControlMetrics.topbarLabels)}`)
}
if (/升级|支付|AI 空闲|GB 可用|服务已连接/.test(unsupportedControlMetrics.statusbarText)) {
failures.push(`statusbar contains unsupported product state: ${unsupportedControlMetrics.statusbarText}`)
}
if (!unsupportedControlMetrics.newChannelText.includes('暂未开放') || unsupportedControlMetrics.newChannelButtonCount !== 0) {
failures.push(`new channel must be a non-interactive 暂未开放 state, got ${JSON.stringify(unsupportedControlMetrics)}`)
}
if (failures.length) {
throw new Error(failures.join('\n'))
}