1323 lines
67 KiB
JavaScript
1323 lines
67 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()
|
|
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)}`)
|
|
}
|
|
}
|
|
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'))
|
|
}
|