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

622 lines
31 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 taskSearchResultId = '019b0000-0000-7000-8000-000000000003'
const unknownProjectId = '019b0000-0000-7000-8000-000000000099'
const searchRequests = []
const projectPatchRequests = []
let expectingProjectPatchError = false
let expectedProjectPatchConsoleErrorCount = 0
page.on('console', (message) => {
if (message.type() !== 'error') 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
}
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: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 1 },
{ id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 2 },
{ id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 },
{ id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 },
],
tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }],
recentSessions: [
{ id: 'session-1', projectId, title: '移动导航会话', summary: '检查抽屉关闭行为', updatedAt: '2026-07-21T02:00:00Z', references: [] },
],
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] })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/workspace`) {
await route.fulfill({ json: visualCheckWorkspace })
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 projectIdForResult = query === '未知项目' ? unknownProjectId : projectId
await route.fulfill({
json: {
items: [{
id: taskSearchResultId,
type: 'task',
title: query === '未知项目' ? '未知项目任务' : query === '慢请求' ? '慢请求任务' : query === '快请求' ? '快请求任务' : '回调任务',
projectId: projectIdForResult,
snippet: '检查签名',
}],
},
})
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', fullPage: true })
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()
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 })
const mobileProjectMetrics = await collectMetrics()
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)}`)
}
}
await searchInput.fill('未知项目')
await searchButton.click()
await page.waitForTimeout(150)
const unknownResult = page.getByRole('button', { name: /未知项目任务/ })
if (await unknownResult.count() === 0) {
failures.push('Search button must submit the current query and show results')
} else {
const projectBeforeUnknownResult = await page.locator('.project-title h5').textContent()
await unknownResult.click()
const projectAfterUnknownResult = await page.locator('.project-title h5').textContent()
if (projectAfterUnknownResult !== projectBeforeUnknownResult) {
failures.push('a result for an unavailable project must not fabricate project navigation')
}
}
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 channelPageChecks = []
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: '选择专家,开始对话' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
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))
}
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,
projectMetrics,
mobileProjectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
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 (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 (failures.length) {
throw new Error(failures.join('\n'))
}