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

324 lines
17 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 = []
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
const collectMetrics = async () => page.evaluate(() => {
const statusbar = document.querySelector('.statusbar')
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,
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 })
await page.locator('.login-form-panel .arco-btn-primary').click()
await page.waitForSelector('.workbench-shell')
await page.waitForTimeout(700)
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics()
await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceExploreMetrics = await collectMetrics()
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
const projectMetrics = await collectMetrics()
await page.locator('.channel-sidebar').hover()
const channelSidebarHoverMetrics = await collectMetrics()
await page.locator('.stage').hover()
const stageHoverMetrics = await collectMetrics()
await page.locator('.channel-list').hover()
const channelListHoverMetrics = await collectMetrics()
const channelPageChecks = []
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)
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h4, h5')?.textContent ?? ''
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
return {
...expected,
foundPage: Boolean(pageNode),
heading,
activeChannel,
}
}, channel))
}
await page.locator('.topbar-actions .arco-btn').first().click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
await browser.close()
await server.close()
console.log(JSON.stringify({
workspaceMetrics,
workspaceExploreMetrics,
projectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
errors,
}, null, 2))
const failures = []
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.statusOnlineDot) failures.push('expected online status to render as an icon dot')
if (metrics.statusName && metrics.statusOnlineDot && Math.abs(metrics.statusName.top - metrics.statusOnlineDot.top) > 6) {
failures.push(`expected online dot to sit beside username, got name=${JSON.stringify(metrics.statusName)}, dot=${JSON.stringify(metrics.statusOnlineDot)}`)
}
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 (!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'))
}