2026-07-20 09:50:55 +08:00
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 = [ ]
2026-07-21 13:04:57 +08:00
const failures = [ ]
2026-07-21 16:29:42 +08:00
const projectId = '019b0000-0000-7000-8000-000000000001'
2026-07-21 18:42:47 +08:00
const secondProjectId = '019b0000-0000-7000-8000-000000000012'
2026-07-21 17:16:45 +08:00
const taskSearchResultId = '019b0000-0000-7000-8000-000000000003'
2026-07-21 18:24:15 +08:00
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'
2026-07-21 18:42:47 +08:00
const thirdInboxItemId = '019b0000-0000-7000-8000-000000000013'
const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014'
2026-07-21 19:19:36 +08:00
const aiSessionId = '019b0000-0000-7000-8000-000000000015'
const createdAISessionId = '019b0000-0000-7000-8000-000000000016'
2026-07-21 17:16:45 +08:00
const unknownProjectId = '019b0000-0000-7000-8000-000000000099'
2026-07-21 17:42:22 +08:00
const externalProjectId = '019b0000-0000-7000-8000-000000000088'
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
const externalNoteId = '019b0000-0000-7000-8000-000000000090'
2026-07-21 17:16:45 +08:00
const searchRequests = [ ]
2026-07-21 17:42:22 +08:00
const workspaceRequests = [ ]
2026-07-21 17:16:45 +08:00
const projectPatchRequests = [ ]
2026-07-21 18:24:15 +08:00
const inboxAnalyzeRequests = [ ]
const inboxConfirmRequests = [ ]
2026-07-21 19:19:36 +08:00
const aiSessionRequests = [ ]
2026-07-21 19:54:25 +08:00
let delayedAIListsRemaining = 0
let delayNextAICreate = false
let delayedAICreateCompleted = false
let failNextSecondProjectAIList = false
let expectingSecondProjectAIListError = false
let expectedSecondProjectAIListConsoleErrorCount = 0
2026-07-21 17:16:45 +08:00
let expectingProjectPatchError = false
let expectedProjectPatchConsoleErrorCount = 0
2026-07-21 18:24:15 +08:00
let expectingInboxConfirmError = false
let expectedInboxConfirmConsoleErrorCount = 0
let expectingInboxRefreshError = false
let expectedInboxRefreshConsoleErrorCount = 0
2026-07-21 18:42:47 +08:00
let expectingInboxConflict = false
let expectedInboxConflictConsoleErrorCount = 0
2026-07-21 18:57:38 +08:00
let expectingInboxValidationError = false
let expectedInboxValidationConsoleErrorCount = 0
2026-07-21 18:24:15 +08:00
let failNextInboxConfirm = true
let failNextInboxWorkspaceRefresh = false
2026-07-21 18:42:47 +08:00
let conflictNextInboxConfirm = false
2026-07-21 18:57:38 +08:00
let validationNextInboxConfirm = false
let invalidResponseNextInboxConfirm = false
2026-07-21 19:19:36 +08:00
const controlledAISessions = [
{
id : aiSessionId ,
projectId ,
title : '项目风险梳理' ,
context : '只维护会话上下文,不创建正式对象。' ,
status : 'ready' ,
createdAt : '2026-07-21T03:00:00Z' ,
updatedAt : '2026-07-21T03:00:00Z' ,
} ,
]
2026-07-20 09:50:55 +08:00
page . on ( 'console' , ( message ) => {
2026-07-21 17:16:45 +08:00
if ( message . type ( ) !== 'error' ) return
2026-07-21 19:54:25 +08:00
if (
expectingSecondProjectAIListError &&
message . text ( ) . includes ( 'Failed to load resource' )
) {
expectedSecondProjectAIListConsoleErrorCount += 1
return
}
2026-07-21 17:16:45 +08:00
if (
expectingProjectPatchError &&
expectedProjectPatchConsoleErrorCount === 0 &&
message . text ( ) === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)'
) {
expectedProjectPatchConsoleErrorCount += 1
return
}
2026-07-21 18:24:15 +08:00
if (
expectingInboxConfirmError &&
expectedInboxConfirmConsoleErrorCount === 0 &&
2026-07-21 18:42:47 +08:00
message . text ( ) . includes ( 'Failed to load resource' )
2026-07-21 18:24:15 +08:00
) {
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
}
2026-07-21 18:42:47 +08:00
if (
expectingInboxConflict &&
expectedInboxConflictConsoleErrorCount === 0 &&
message . text ( ) === 'Failed to load resource: the server responded with a status of 409 (Conflict)'
) {
expectedInboxConflictConsoleErrorCount += 1
return
}
2026-07-21 18:57:38 +08:00
if (
expectingInboxValidationError &&
expectedInboxValidationConsoleErrorCount === 0 &&
message . text ( ) === 'Failed to load resource: the server responded with a status of 400 (Bad Request)'
) {
expectedInboxValidationConsoleErrorCount += 1
return
}
2026-07-21 17:16:45 +08:00
errors . push ( message . text ( ) )
2026-07-20 09:50:55 +08:00
} )
2026-07-21 13:28:58 +08:00
const visualCheckWorkspace = {
project : {
2026-07-21 16:29:42 +08:00
id : projectId ,
2026-07-21 13:28:58 +08:00
name : '森林项目' ,
identifier : 'forest' ,
icon : '森' ,
background : '#165DFF' ,
description : '移动导航视觉检查项目' ,
initials : '森林' ,
unreadCount : 2 ,
} ,
channels : [
2026-07-21 16:29:42 +08:00
{ id : 'overview' , projectId , type : 'overview' , title : '概况' , icon : 'home' , count : 0 , url : '' , sortOrder : 0 } ,
2026-07-21 18:42:47 +08:00
{ id : 'inbox' , projectId , type : 'inbox' , title : 'Inbox 消息流' , icon : 'mail' , count : 3 , url : '' , sortOrder : 1 } ,
2026-07-21 18:24:15 +08:00
{ 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 } ,
2026-07-21 13:28:58 +08:00
] ,
2026-07-21 16:29:42 +08:00
tags : [ { id : '019b0000-0000-7000-8000-000000000002' , name : '产品' } ] ,
2026-07-21 13:28:58 +08:00
recentSessions : [
2026-07-21 16:29:42 +08:00
{ id : 'session-1' , projectId , title : '移动导航会话' , summary : '检查抽屉关闭行为' , updatedAt : '2026-07-21T02:00:00Z' , references : [ ] } ,
2026-07-21 13:28:58 +08:00
] ,
2026-07-21 18:24:15 +08:00
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' ,
} ,
2026-07-21 18:42:47 +08:00
{
id : thirdInboxItemId ,
projectId ,
source : '手动收集' ,
title : '延迟确认测试' ,
summary : '确认期间切换项目时必须保留最新选择。' ,
status : 'open' ,
tag : '待处理' ,
time : '2026-07-21T02:10:00Z' ,
} ,
2026-07-21 18:24:15 +08:00
] ,
2026-07-21 13:28:58 +08:00
tasks : [ ] ,
aiSessions : [ ] ,
notesSources : [ ] ,
cronPlans : [ ] ,
}
2026-07-21 18:42:47 +08:00
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 } ,
2026-07-21 19:54:25 +08:00
{ id : 'second-ai' , projectId : secondProjectId , type : 'ai_sessions' , title : 'AI 助手' , icon : 'robot' , count : 0 , url : '' , sortOrder : 1 } ,
2026-07-21 18:42:47 +08:00
] ,
tags : [ ] ,
recentSessions : [ ] ,
inbox : [ ] ,
tasks : [ ] ,
aiSessions : [ ] ,
notesSources : [ ] ,
cronPlans : [ ] ,
}
2026-07-21 16:29:42 +08:00
await page . route ( 'http://localhost:9150/api/v1/**' , async ( route ) => {
2026-07-21 13:28:58 +08:00
const url = new URL ( route . request ( ) . url ( ) )
2026-07-21 17:16:45 +08:00
const method = route . request ( ) . method ( )
2026-07-21 16:29:42 +08:00
if ( url . pathname === '/api/v1/status' ) {
await route . fulfill ( { json : { timestamp : new Date ( ) . toISOString ( ) } } )
2026-07-21 13:28:58 +08:00
return
}
2026-07-21 16:29:42 +08:00
if ( url . pathname === '/api/v1/auth/login' ) {
2026-07-21 13:28:58 +08:00
await route . fulfill ( { json : { token : 'visual-check-token' } } )
return
}
2026-07-21 16:29:42 +08:00
if ( url . pathname === '/api/v1/projects' ) {
2026-07-21 18:42:47 +08:00
await route . fulfill ( { json : [ visualCheckWorkspace . project , secondVisualCheckWorkspace . project ] } )
2026-07-21 13:28:58 +08:00
return
}
2026-07-21 16:29:42 +08:00
if ( url . pathname === ` /api/v1/projects/ ${ projectId } /workspace ` ) {
2026-07-21 17:42:22 +08:00
workspaceRequests . push ( url . pathname )
2026-07-21 18:24:15 +08:00
if ( failNextInboxWorkspaceRefresh ) {
failNextInboxWorkspaceRefresh = false
await route . fulfill ( {
status : 500 ,
json : { error : { code : 'internal_error' , message : '工作区刷新失败,请稍后重试' } } ,
} )
return
}
2026-07-21 13:28:58 +08:00
await route . fulfill ( { json : visualCheckWorkspace } )
return
}
2026-07-21 18:42:47 +08:00
if ( url . pathname === ` /api/v1/projects/ ${ secondProjectId } /workspace ` ) {
workspaceRequests . push ( url . pathname )
await route . fulfill ( { json : secondVisualCheckWorkspace } )
return
}
2026-07-21 19:19:36 +08:00
if ( url . pathname === ` /api/v1/projects/ ${ projectId } /ai-sessions ` && method === 'GET' ) {
2026-07-21 19:54:25 +08:00
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 ( ( ) => { } )
2026-07-21 19:19:36 +08:00
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' ,
}
2026-07-21 19:54:25 +08:00
if ( delayNextAICreate ) {
delayNextAICreate = false
await new Promise ( ( resolve ) => setTimeout ( resolve , 700 ) )
delayedAICreateCompleted = true
}
2026-07-21 19:19:36 +08:00
controlledAISessions . unshift ( created )
2026-07-21 19:54:25 +08:00
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 : [ ] } )
2026-07-21 19:19:36 +08:00
return
}
2026-07-21 18:24:15 +08:00
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
}
2026-07-21 18:42:47 +08:00
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' ) {
2026-07-21 18:24:15 +08:00
inboxConfirmRequests . push ( route . request ( ) . postDataJSON ( ) )
await new Promise ( ( resolve ) => setTimeout ( resolve , 250 ) )
if ( failNextInboxConfirm ) {
failNextInboxConfirm = false
2026-07-21 18:42:47 +08:00
await route . abort ( 'failed' )
return
}
if ( conflictNextInboxConfirm ) {
conflictNextInboxConfirm = false
2026-07-21 18:24:15 +08:00
await route . fulfill ( {
2026-07-21 18:42:47 +08:00
status : 409 ,
json : { error : { code : 'conflict' , message : '确认状态发生冲突' } } ,
2026-07-21 18:24:15 +08:00
} )
return
}
2026-07-21 18:57:38 +08:00
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
}
2026-07-21 18:24:15 +08:00
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
}
2026-07-21 17:16:45 +08:00
if ( url . pathname === '/api/v1/search' ) {
const query = url . searchParams . get ( 'q' ) ? ? ''
searchRequests . push ( query )
if ( query === '慢请求' ) await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) )
2026-07-21 17:42:22 +08:00
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 : '检查签名' ,
}
2026-07-21 17:16:45 +08:00
await route . fulfill ( {
json : {
2026-07-21 17:42:22 +08:00
items : [ result ] ,
2026-07-21 17:16:45 +08:00
} ,
} )
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
}
2026-07-21 16:29:42 +08:00
await route . fulfill ( { status : 404 , json : { error : { code : 'not_found' , message : '视觉检查未配置该接口' } } } )
2026-07-21 13:28:58 +08:00
} )
2026-07-21 13:04:57 +08:00
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' ,
}
} )
2026-07-21 13:09:37 +08:00
const getColumnCount = ( columns ) => columns === 'none'
? 0
: columns . split ( ' ' ) . filter ( Boolean ) . length
2026-07-21 13:28:58 +08:00
const waitForDrawerOpen = async ( selector ) => page . waitForFunction ( ( drawerSelector ) => {
const drawer = document . querySelector ( drawerSelector )
return drawer ? . classList . contains ( 'open' ) && Math . abs ( drawer . getBoundingClientRect ( ) . left ) < 1
} , selector )
2026-07-20 11:49:01 +08:00
const collectMetrics = async ( ) => page . evaluate ( ( ) => {
2026-07-20 09:50:55 +08:00
const statusbar = document . querySelector ( '.statusbar' )
2026-07-21 13:28:58 +08:00
const statusUser = document . querySelector ( '.status-user' )
2026-07-20 11:43:02 +08:00
const statusName = document . querySelector ( '.status-name' )
const statusOnlineDot = document . querySelector ( '.status-online-dot' )
2026-07-20 09:50:55 +08:00
const search = document . querySelector ( '.global-search' )
2026-07-20 11:03:27 +08:00
const main = document . querySelector ( '.workbench-main' )
2026-07-20 11:40:45 +08:00
const workspacePage = document . querySelector ( '.workspace-page' )
2026-07-20 11:49:01 +08:00
const projectOverview = document . querySelector ( '.overview-page:not(.workspace-page)' )
2026-07-20 10:00:47 +08:00
const rail = document . querySelector ( '.project-rail' )
2026-07-20 10:13:05 +08:00
const railChildren = rail ? . querySelector ( '.arco-layout-sider-children' )
2026-07-20 11:03:27 +08:00
const sidebar = document . querySelector ( '.channel-sidebar' )
2026-07-20 12:13:57 +08:00
const channelList = document . querySelector ( '.channel-list' )
2026-07-20 11:03:27 +08:00
const stage = document . querySelector ( '.stage' )
const inspector = document . querySelector ( '.inspector' )
2026-07-20 11:57:32 +08:00
const workspaceActions = [ ... document . querySelectorAll ( '.workspace-page .overview-head .arco-btn' ) ]
. map ( ( button ) => button . textContent ? . trim ( ) )
2026-07-20 10:00:47 +08:00
const dashboard = document . querySelector ( '.dashboard-button' )
2026-07-21 00:46:09 +08:00
const workspaceExplore = document . querySelector ( '.dashboard-button[title="探索"]' )
2026-07-20 10:00:47 +08:00
const project = document . querySelector ( '.project-button' )
const create = document . querySelector ( '.create-project' )
2026-07-20 10:09:09 +08:00
const firstBadge = document . querySelector ( '.project-badge .arco-badge-number' )
const railItems = [ ... document . querySelectorAll ( '.dashboard-button, .project-button, .create-project' ) ]
2026-07-20 10:00:47 +08:00
const rect = ( node ) => {
const box = node ? . getBoundingClientRect ( )
2026-07-20 10:09:09 +08:00
return box ? { left : box . left , right : box . right , top : box . top , width : box . width , height : box . height } : null
2026-07-20 10:00:47 +08:00
}
2026-07-20 10:09:09 +08:00
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 ) )
2026-07-20 10:13:05 +08:00
const overflowState = ( node ) => node
? {
clientWidth : node . clientWidth ,
scrollWidth : node . scrollWidth ,
clientHeight : node . clientHeight ,
scrollHeight : node . scrollHeight ,
overflowX : getComputedStyle ( node ) . overflowX ,
overflowY : getComputedStyle ( node ) . overflowY ,
2026-07-20 12:08:52 +08:00
scrollbarWidth : getComputedStyle ( node ) . scrollbarWidth ,
2026-07-20 10:13:05 +08:00
}
: null
2026-07-20 10:00:47 +08:00
2026-07-20 09:50:55 +08:00
return {
statusbarHeight : statusbar ? . getBoundingClientRect ( ) . height ,
2026-07-21 13:28:58 +08:00
statusUserTag : statusUser ? . tagName ,
nestedButtonCount : document . querySelectorAll ( 'button button' ) . length ,
2026-07-20 11:43:02 +08:00
statusName : rect ( statusName ) ,
statusOnlineDot : rect ( statusOnlineDot ) ,
2026-07-20 09:50:55 +08:00
searchHeight : search ? . getBoundingClientRect ( ) . height ,
2026-07-20 11:03:27 +08:00
workbenchMain : rect ( main ) ,
2026-07-20 11:40:45 +08:00
hasWorkspacePage : Boolean ( workspacePage ) ,
2026-07-20 11:49:01 +08:00
hasProjectOverview : Boolean ( projectOverview ) ,
2026-07-20 10:00:47 +08:00
projectRailWidth : rail ? . getBoundingClientRect ( ) . width ,
2026-07-20 11:03:27 +08:00
projectRail : rect ( rail ) ,
channelSidebar : rect ( sidebar ) ,
stage : rect ( stage ) ,
inspector : rect ( inspector ) ,
2026-07-20 11:57:32 +08:00
workspaceActions ,
2026-07-20 10:00:47 +08:00
dashboardButton : rect ( dashboard ) ,
2026-07-20 11:40:45 +08:00
dashboardButtonActive : dashboard ? . classList . contains ( 'active' ) ? ? false ,
2026-07-21 00:46:09 +08:00
workspaceExploreButton : rect ( workspaceExplore ) ,
workspaceExploreButtonActive : workspaceExplore ? . classList . contains ( 'active' ) ? ? false ,
2026-07-20 10:00:47 +08:00
projectButton : rect ( project ) ,
2026-07-20 11:40:45 +08:00
projectButtonActive : project ? . classList . contains ( 'active' ) ? ? false ,
2026-07-20 10:00:47 +08:00
createProjectButton : rect ( create ) ,
2026-07-20 10:09:09 +08:00
firstProjectBadge : rect ( firstBadge ) ,
horizontalInsets ,
verticalGaps ,
2026-07-20 10:13:05 +08:00
projectRailOverflow : overflowState ( rail ) ,
projectRailChildrenOverflow : overflowState ( railChildren ) ,
2026-07-20 12:04:36 +08:00
channelSidebarOverflow : overflowState ( sidebar ) ,
2026-07-20 12:13:57 +08:00
channelListOverflow : overflowState ( channelList ) ,
2026-07-20 12:04:36 +08:00
stageOverflow : overflowState ( stage ) ,
inspectorOverflow : overflowState ( inspector ) ,
2026-07-20 09:50:55 +08:00
overflowX : document . documentElement . scrollWidth > document . documentElement . clientWidth ,
2026-07-20 11:03:27 +08:00
viewportWidth : window . innerWidth ,
2026-07-20 09:50:55 +08:00
}
} )
2026-07-20 11:49:01 +08:00
await page . goto ( ` http://127.0.0.1: ${ port } / ` , { waitUntil : 'networkidle' } )
await page . screenshot ( { path : 'test-results/login-react-acro.png' , fullPage : true } )
2026-07-21 13:04:57 +08:00
const desktopLoginMetrics = await collectLoginMetrics ( )
2026-07-21 13:09:37 +08:00
const desktopColumnCount = getColumnCount ( desktopLoginMetrics . columns )
2026-07-21 13:04:57 +08:00
if ( desktopLoginMetrics . overflowX ) failures . push ( 'login must not overflow horizontally' )
if ( desktopLoginMetrics . title !== '森林AI' ) failures . push ( ` unexpected title ${ desktopLoginMetrics . title } ` )
2026-07-21 13:09:37 +08:00
if ( desktopColumnCount !== 2 ) failures . push ( ` desktop login must use two columns, got ${ desktopLoginMetrics . columns } ` )
2026-07-21 13:04:57 +08:00
await page . setViewportSize ( { width : 390 , height : 844 } )
2026-07-21 21:59:20 +08:00
await page . screenshot ( { path : 'test-results/login-react-acro-mobile.png' } )
2026-07-21 13:04:57 +08:00
const mobileLoginMetrics = await collectLoginMetrics ( )
2026-07-21 13:09:37 +08:00
const mobileColumnCount = getColumnCount ( mobileLoginMetrics . columns )
2026-07-21 13:04:57 +08:00
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 } )
2026-07-20 11:49:01 +08:00
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 ( )
2026-07-21 13:28:58 +08:00
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 } )
2026-07-21 00:46:09 +08:00
await page . locator ( '.dashboard-button[title="探索"]' ) . click ( )
2026-07-20 18:25:04 +08:00
await page . waitForTimeout ( 500 )
2026-07-21 00:46:09 +08:00
await page . screenshot ( { path : 'test-results/workspace-explore-react-acro-light.png' , fullPage : true } )
const workspaceExploreMetrics = await collectMetrics ( )
2026-07-21 19:19:36 +08:00
const workspaceExploreControls = await page . evaluate ( ( ) => ( {
emptyState : document . querySelector ( '.workspace-explore-page' ) ? . textContent ? ? '' ,
buttonCount : document . querySelectorAll ( '.workspace-explore-page button' ) . length ,
} ) )
2026-07-20 18:25:04 +08:00
2026-07-20 11:49:01 +08:00
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 ( )
2026-07-21 13:28:58 +08:00
await page . setViewportSize ( { width : 390 , height : 844 } )
2026-07-21 21:59:20 +08:00
await page . waitForFunction ( ( ) => [ ... document . querySelectorAll ( '.project-rail, .channel-sidebar' ) ]
. every ( ( node ) => getComputedStyle ( node ) . visibility === 'hidden' ) )
2026-07-21 13:28:58 +08:00
const mobileProjectMetrics = await collectMetrics ( )
2026-07-21 23:28:49 +08:00
const mobileProjectOverviewLayout = await page . evaluate ( ( ) => {
const hero = document . querySelector ( '.project-overview-hero' )
const heroTitle = document . querySelector ( '.project-overview-identity h3' )
const shareButton = document . querySelector ( '.project-overview-share .arco-btn' )
const metricColumns = [ ... document . querySelectorAll ( '.project-overview-page > .metric-row > .arco-col' ) ]
const metricLabels = [ ... document . querySelectorAll ( '.project-overview-page .metric-card .arco-typography-secondary' ) ]
const lineCount = ( node ) => {
if ( ! node ) return 0
const styles = getComputedStyle ( node )
const lineHeight = Number . parseFloat ( styles . lineHeight )
return lineHeight > 0 ? Math . round ( node . getBoundingClientRect ( ) . height / lineHeight ) : 0
}
return {
viewportWidth : document . documentElement . clientWidth ,
hero : hero ? . getBoundingClientRect ( ) . toJSON ( ) ? ? null ,
heroTitle : heroTitle ? { ... heroTitle . getBoundingClientRect ( ) . toJSON ( ) , lines : lineCount ( heroTitle ) } : null ,
shareButton : shareButton ? . getBoundingClientRect ( ) . toJSON ( ) ? ? null ,
metricColumnLefts : metricColumns . map ( ( column ) => Math . round ( column . getBoundingClientRect ( ) . left ) ) ,
metricLabels : metricLabels . map ( ( label ) => ( {
text : label . textContent ? . trim ( ) ? ? '' ,
width : label . getBoundingClientRect ( ) . width ,
lines : lineCount ( label ) ,
} ) ) ,
}
} )
if ( ! mobileProjectOverviewLayout . heroTitle || mobileProjectOverviewLayout . heroTitle . lines > 2 ) {
failures . push ( ` mobile project title must fit in at most two readable lines, got ${ JSON . stringify ( mobileProjectOverviewLayout . heroTitle ) } ` )
}
if ( ! mobileProjectOverviewLayout . shareButton || mobileProjectOverviewLayout . shareButton . right > mobileProjectOverviewLayout . viewportWidth ) {
failures . push ( ` mobile project URL must stay inside the viewport, got ${ JSON . stringify ( mobileProjectOverviewLayout . shareButton ) } ` )
}
if ( new Set ( mobileProjectOverviewLayout . metricColumnLefts ) . size !== 2 ) {
failures . push ( ` mobile project metrics must use two columns, got left edges ${ JSON . stringify ( mobileProjectOverviewLayout . metricColumnLefts ) } ` )
}
for ( const label of mobileProjectOverviewLayout . metricLabels ) {
const minimumReadableWidth = label . text . length * 12
if ( label . width < minimumReadableWidth || label . lines > 2 ) {
failures . push ( ` mobile project metric label must remain horizontally readable, got ${ JSON . stringify ( label ) } ` )
}
}
2026-07-21 21:59:20 +08:00
await page . screenshot ( { path : 'test-results/project-react-acro-mobile.png' , fullPage : true } )
2026-07-21 13:28:58 +08:00
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' )
}
2026-07-21 13:35:35 +08:00
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' )
2026-07-21 13:28:58 +08:00
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 } )
2026-07-21 17:16:45 +08:00
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 ) } ` )
}
}
2026-07-21 17:42:22 +08:00
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 ( '外部笔记' )
2026-07-21 17:16:45 +08:00
await searchButton . click ( )
await page . waitForTimeout ( 150 )
2026-07-21 17:42:22 +08:00
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 : /未知类型结果/ } )
2026-07-21 17:16:45 +08:00
if ( await unknownResult . count ( ) === 0 ) {
2026-07-21 17:42:22 +08:00
failures . push ( 'fixture must expose the unknown result for its safety assertion' )
2026-07-21 17:16:45 +08:00
} else {
await unknownResult . click ( )
2026-07-21 17:42:22 +08:00
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' )
2026-07-21 17:16:45 +08:00
}
}
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 ( )
}
2026-07-20 12:08:52 +08:00
await page . locator ( '.channel-sidebar' ) . hover ( )
const channelSidebarHoverMetrics = await collectMetrics ( )
await page . locator ( '.stage' ) . hover ( )
const stageHoverMetrics = await collectMetrics ( )
2026-07-20 12:13:57 +08:00
await page . locator ( '.channel-list' ) . hover ( )
const channelListHoverMetrics = await collectMetrics ( )
2026-07-20 12:08:52 +08:00
2026-07-21 18:24:15 +08:00
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' )
}
2026-07-21 18:42:47 +08:00
if ( ! await confirmButton . isDisabled ( ) ) {
failures . push ( 'Inbox confirmation must remain disabled while the write is pending' )
}
2026-07-21 18:24:15 +08:00
await page . waitForTimeout ( 300 )
2026-07-21 18:42:47 +08:00
if ( ! await inboxPage . getByText ( '确认结果未知,服务端支持幂等处理,可安全重试' , { exact : true } ) . isVisible ( ) ) {
failures . push ( 'a network-failed Inbox confirmation must explain that an idempotent retry is safe' )
2026-07-21 18:24:15 +08:00
}
expectingInboxConfirmError = false
if ( expectedInboxConfirmConsoleErrorCount !== 1 ) {
failures . push ( ` expected one simulated Inbox confirm console error, got ${ expectedInboxConfirmConsoleErrorCount } ` )
}
2026-07-21 18:57:38 +08:00
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 重试确认' )
}
2026-07-21 18:24:15 +08:00
const workspaceRequestCount = workspaceRequests . length
2026-07-21 18:57:38 +08:00
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 ( )
2026-07-21 18:24:15 +08:00
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 ) } ` )
}
2026-07-21 18:57:38 +08:00
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' )
}
2026-07-21 18:24:15 +08:00
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 ( ( ) => { } )
2026-07-21 18:57:38 +08:00
validationNextInboxConfirm = true
expectingInboxValidationError = true
2026-07-21 18:24:15 +08:00
const secondConfirmButton = inboxPage . getByRole ( 'button' , { name : '确认创建' , exact : true } )
await secondConfirmButton . click ( )
2026-07-21 18:57:38 +08:00
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 ( )
2026-07-21 18:24:15 +08:00
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 } )
2026-07-21 18:42:47 +08:00
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 } ` )
}
2026-07-21 18:24:15 +08:00
}
}
2026-07-20 12:26:21 +08:00
const channelPageChecks = [ ]
for ( const channel of [
{ label : '工作计划' , pageClass : 'project-tasks-page' , expectedHeading : '工作计划' } ,
2026-07-21 19:19:36 +08:00
{ label : 'AI 助手' , pageClass : 'project-ai-page' , expectedHeading : 'AI 助手' } ,
2026-07-20 12:26:21 +08:00
{ label : '笔记资料' , pageClass : 'project-notes-page' , expectedHeading : '笔记资料' } ,
2026-07-21 00:46:09 +08:00
{ label : '计划任务' , pageClass : 'project-cron-page' , expectedHeading : '计划任务' } ,
{ label : '新建频道' , pageClass : 'project-new-channel-page' , expectedHeading : '新建频道' } ,
2026-07-20 12:26:21 +08:00
] ) {
await page . locator ( '.channel-button' , { hasText : channel . label } ) . click ( )
await page . waitForTimeout ( 250 )
2026-07-21 21:59:20 +08:00
if ( channel . label === '工作计划' ) {
await page . screenshot ( { path : 'test-results/project-tasks.png' , fullPage : true } )
}
2026-07-21 19:19:36 +08:00
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 } )
}
2026-07-20 12:26:21 +08:00
channelPageChecks . push ( await page . evaluate ( ( expected ) => {
const pageNode = document . querySelector ( ` . ${ expected . pageClass } ` )
2026-07-21 13:28:58 +08:00
const heading = pageNode ? . querySelector ( 'h2, h3, h4, h5' ) ? . textContent ? ? ''
2026-07-20 12:26:21 +08:00
const activeChannel = document . querySelector ( '.channel-button.active' ) ? . textContent ? ? ''
return {
... expected ,
foundPage : Boolean ( pageNode ) ,
heading ,
activeChannel ,
}
} , channel ) )
2026-07-21 19:54:25 +08:00
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 )
}
2026-07-20 12:26:21 +08:00
}
2026-07-21 19:19:36 +08:00
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 ,
} ) )
2026-07-21 13:28:58 +08:00
await page . getByRole ( 'button' , { name : '切换深色模式' , exact : true } ) . click ( )
2026-07-20 11:49:01 +08:00
await page . screenshot ( { path : 'test-results/project-react-acro-dark.png' , fullPage : true } )
2026-07-20 09:50:55 +08:00
await browser . close ( )
await server . close ( )
2026-07-20 12:13:57 +08:00
console . log ( JSON . stringify ( {
workspaceMetrics ,
2026-07-21 00:46:09 +08:00
workspaceExploreMetrics ,
2026-07-21 19:19:36 +08:00
workspaceExploreControls ,
2026-07-20 12:13:57 +08:00
projectMetrics ,
2026-07-21 13:28:58 +08:00
mobileProjectMetrics ,
2026-07-20 12:13:57 +08:00
channelSidebarHoverMetrics ,
stageHoverMetrics ,
channelListHoverMetrics ,
2026-07-20 12:26:21 +08:00
channelPageChecks ,
2026-07-21 19:19:36 +08:00
unsupportedControlMetrics ,
aiSessionRequests ,
2026-07-20 12:13:57 +08:00
errors ,
} , null , 2 ) )
2026-07-20 10:00:47 +08:00
2026-07-20 11:49:01 +08:00
const metrics = workspaceMetrics
2026-07-20 10:00:47 +08:00
if ( errors . length ) failures . push ( ` console errors: ${ errors . join ( '; ' ) } ` )
if ( metrics . statusbarHeight !== 32 ) failures . push ( ` expected statusbar height 32, got ${ metrics . statusbarHeight } ` )
2026-07-21 13:28:58 +08:00
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 } ` )
2026-07-20 10:00:47 +08:00
if ( metrics . searchHeight !== 42 ) failures . push ( ` expected search height 42, got ${ metrics . searchHeight } ` )
if ( metrics . overflowX ) failures . push ( 'expected no horizontal overflow' )
2026-07-20 11:03:27 +08:00
if ( ! metrics . workbenchMain ) failures . push ( 'missing workbench main' )
2026-07-20 11:40:45 +08:00
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' )
2026-07-21 00:46:09 +08:00
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' )
2026-07-20 11:40:45 +08:00
if ( metrics . projectButtonActive ) failures . push ( 'expected first project button not to be active on workspace landing page' )
2026-07-20 11:49:01 +08:00
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 ) } ` )
2026-07-20 11:57:32 +08:00
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 ) } ` )
}
2026-07-20 11:49:01 +08:00
if ( ! metrics . projectRail || ! metrics . stage ) {
failures . push ( ` missing workspace layout regions: rail= ${ JSON . stringify ( metrics . projectRail ) } , stage= ${ JSON . stringify ( metrics . stage ) } ` )
2026-07-20 11:03:27 +08:00
} else {
2026-07-20 11:49:01 +08:00
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 } ` )
2026-07-20 11:03:27 +08:00
}
2026-07-20 11:49:01 +08:00
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 } ` )
2026-07-20 11:03:27 +08:00
}
}
2026-07-20 10:00:47 +08:00
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' )
2026-07-20 10:09:09 +08:00
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 ) } ` )
}
2026-07-20 10:13:05 +08:00
for ( const [ name , overflow ] of [
[ 'project rail' , metrics . projectRailOverflow ] ,
[ 'project rail children' , metrics . projectRailChildrenOverflow ] ,
] ) {
2026-07-20 11:49:01 +08:00
if ( ! overflow || overflow . overflowX !== 'hidden' || overflow . overflowY !== 'hidden' ) {
2026-07-20 10:13:05 +08:00
failures . push ( ` expected ${ name } scrollbars to be hidden with no overflow, got ${ JSON . stringify ( overflow ) } ` )
}
}
2026-07-20 10:09:09 +08:00
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 ) } ` )
}
2026-07-20 10:00:47 +08:00
if (
! metrics . dashboardButton ||
metrics . dashboardButton . width !== metrics . projectButton ? . width ||
metrics . dashboardButton . height !== metrics . projectButton ? . height
) {
2026-07-20 11:49:01 +08:00
failures . push ( ` dashboard button size ${ JSON . stringify ( metrics . dashboardButton ) } does not match project button ${ JSON . stringify ( metrics . projectButton ) } ` )
2026-07-20 10:00:47 +08:00
}
2026-07-20 18:25:04 +08:00
if (
2026-07-21 00:46:09 +08:00
! metrics . workspaceExploreButton ||
metrics . workspaceExploreButton . width !== metrics . projectButton ? . width ||
metrics . workspaceExploreButton . height !== metrics . projectButton ? . height
2026-07-20 18:25:04 +08:00
) {
2026-07-21 00:46:09 +08:00
failures . push ( ` workspace explore button size ${ JSON . stringify ( metrics . workspaceExploreButton ) } does not match project button ${ JSON . stringify ( metrics . projectButton ) } ` )
2026-07-20 18:25:04 +08:00
}
2026-07-20 10:00:47 +08:00
if (
! metrics . createProjectButton ||
metrics . createProjectButton . width !== metrics . projectButton ? . width ||
metrics . createProjectButton . height !== metrics . projectButton ? . height
) {
2026-07-20 11:49:01 +08:00
failures . push ( ` create project button size ${ JSON . stringify ( metrics . createProjectButton ) } does not match project button ${ JSON . stringify ( metrics . projectButton ) } ` )
}
2026-07-21 00:46:09 +08:00
if ( ! workspaceExploreMetrics . workspaceExploreButtonActive ) failures . push ( 'expected fixed workspace explore button to become active when selected' )
2026-07-21 19:19:36 +08:00
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 } ` )
2026-07-21 00:46:09 +08:00
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 } ` )
2026-07-20 18:25:04 +08:00
}
2026-07-20 11:49:01 +08:00
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' )
2026-07-20 12:45:13 +08:00
if ( projectMetrics . inspector ) {
failures . push ( ` expected project page to remove right inspector until it becomes an on-demand panel, got ${ JSON . stringify ( projectMetrics . inspector ) } ` )
}
2026-07-21 13:28:58 +08:00
if ( mobileProjectMetrics . overflowX ) failures . push ( 'mobile project viewport must not overflow horizontally' )
2026-07-20 12:45:13 +08:00
if ( ! projectMetrics . projectRail || ! projectMetrics . channelSidebar || ! projectMetrics . stage ) {
2026-07-20 10:00:47 +08:00
failures . push (
2026-07-20 12:45:13 +08:00
` missing project layout regions: rail= ${ JSON . stringify ( projectMetrics . projectRail ) } , sidebar= ${ JSON . stringify ( projectMetrics . channelSidebar ) } , stage= ${ JSON . stringify ( projectMetrics . stage ) } ` ,
2026-07-20 10:00:47 +08:00
)
2026-07-20 11:49:01 +08:00
} else {
2026-07-20 12:45:13 +08:00
const sameTop = [ projectMetrics . projectRail , projectMetrics . channelSidebar , projectMetrics . stage ]
2026-07-20 11:49:01 +08:00
. every ( ( region ) => Math . abs ( region . top - projectMetrics . projectRail . top ) <= 1 )
if ( ! sameTop ) {
failures . push (
2026-07-20 12:45:13 +08:00
` 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 } ` ,
2026-07-20 11:49:01 +08:00
)
}
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 } ` )
}
2026-07-20 12:45:13 +08:00
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 } ` )
2026-07-20 11:49:01 +08:00
}
2026-07-20 10:00:47 +08:00
}
2026-07-20 12:04:36 +08:00
for ( const [ name , overflow ] of [
[ 'channel sidebar' , projectMetrics . channelSidebarOverflow ] ,
2026-07-20 12:13:57 +08:00
[ 'channel list' , projectMetrics . channelListOverflow ] ,
2026-07-20 12:04:36 +08:00
[ '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 ) } ` )
}
2026-07-20 12:08:52 +08:00
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 ] ,
2026-07-20 12:13:57 +08:00
[ 'channel list hover' , channelListHoverMetrics . channelListOverflow ] ,
2026-07-20 12:08:52 +08:00
[ 'stage hover' , stageHoverMetrics . stageOverflow ] ,
] ) {
if ( overflow ? . scrollbarWidth !== 'thin' ) {
failures . push ( ` expected ${ name } scrollbar to appear on hover, got ${ JSON . stringify ( overflow ) } ` )
}
2026-07-20 12:04:36 +08:00
}
2026-07-20 12:26:21 +08:00
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 ) } ` )
}
}
2026-07-20 10:00:47 +08:00
2026-07-21 19:54:25 +08:00
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 ( )
2026-07-21 19:19:36 +08:00
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 ) } ` )
}
2026-07-21 19:54:25 +08:00
if ( /升级|支付|AI 空闲|GB 可用|服务已连接/ . test ( unsupportedControlMetrics . statusbarText ) ) {
2026-07-21 19:19:36 +08:00
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 ) } ` )
}
2026-07-20 10:00:47 +08:00
if ( failures . length ) {
throw new Error ( failures . join ( '\n' ) )
}