fix(web): close final workbench interaction gaps

This commit is contained in:
2026-07-22 01:24:51 +08:00
parent 26c3e6fd8a
commit b1ce837845
23 changed files with 808 additions and 175 deletions

View File

@@ -50,9 +50,18 @@ test('interrupted successful response body rejects with a stable invalid_respons
})
test('204 response returns undefined without reading a body', async () => {
globalThis.fetch = async () => new Response(null, { status: 204 })
let textCalls = 0
globalThis.fetch = async () => ({
ok: true,
status: 204,
text: async () => {
textCalls += 1
throw new Error('204 body must not be read')
},
})
assert.equal(await apiRequest('/no-content'), undefined)
assert.equal(textCalls, 0)
})
test('explicit void response returns undefined for a non-204 response', async () => {

View File

@@ -18,6 +18,7 @@ const requiredFiles = [
'src/pages/projects/project-sidebar.tsx',
'src/pages/projects/project-topbar.tsx',
'src/pages/projects/project-statusbar.tsx',
'src/pages/projects/workbench-item-preview.tsx',
'src/pages/projects/project-types.ts',
'src/api/client.ts',
'src/api/projects.ts',
@@ -26,6 +27,7 @@ const requiredFiles = [
'src/api/inbox.ts',
'src/api/ai.ts',
'scripts/api-client.test.mjs',
'scripts/workspace-refresh-gate.test.mjs',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
@@ -57,6 +59,9 @@ if (existsSync('src/app/App.tsx')) {
failures.push(`src/app/App.tsx still contains ${forbidden}`)
}
}
for (const required of ['createWorkspaceRefreshGate', 'captureProjectInbox', 'refreshProjectWorkspace']) {
if (!appSource.includes(required)) failures.push(`src/app/App.tsx must include ${required}`)
}
}
const apiFiles = existsSync('src/api')
@@ -92,6 +97,8 @@ for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
}
if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields')
if (/\bfilePath\s*:/.test(projectsSource)) failures.push('public source DTO must not expose filePath')
if (!/\bstorageKey\s*:\s*string/.test(projectsSource)) failures.push('public source DTO must expose an opaque storageKey')
const searchSource = existsSync('src/api/search.ts') ? readFileSync('src/api/search.ts', 'utf8') : ''
if (searchSource && !searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
@@ -112,6 +119,12 @@ if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confi
const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : ''
if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
for (const required of ['收集到 Inbox', 'captureDraft', 'onCapture']) {
if (!inboxPageSource.includes(required)) failures.push(`project inbox capture flow must include ${required}`)
}
const channelPageSource = existsSync('src/pages/projects/project-channel-page.tsx') ? readFileSync('src/pages/projects/project-channel-page.tsx', 'utf8') : ''
if (!channelPageSource.includes("activeChannel.startsWith('custom:')")) failures.push('custom_link channels must render an explicit unavailable state')
const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts', 'utf8') : ''
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
@@ -151,6 +164,23 @@ const unsupportedControls = [
file: 'src/pages/projects/project-topbar.tsx',
forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'],
},
{
file: 'src/pages/projects/project-cron.tsx',
forbidden: ['<Switch', '最近一次自动任务', '资料索引刷新成功'],
},
{
file: 'src/pages/projects/project-overview.tsx',
forbidden: ['/share/projects/', 'projectShareURL', '查看全部'],
},
{
file: 'src/pages/projects/project-notes.tsx',
forbidden: ['搜索资料', '按更新时间排序'],
},
{
file: 'src/pages/login.tsx',
required: 'localStorage',
forbidden: ['无法连接?', '隐私政策', '服务协议', 'check-dot'],
},
]
for (const check of unsupportedControls) {
const source = existsSync(check.file) ? readFileSync(check.file, 'utf8') : ''
@@ -179,6 +209,9 @@ if (!appStyles.includes('.brand-icon {')) failures.push('brand-icon must have an
if (!appStyles.includes('.brand-lockup.large .brand-icon')) failures.push('login brand icon must have an explicit size')
if (!appStyles.includes('.rail-brand {')) failures.push('rail brand button must have explicit layout')
if (appStyles.includes('.brand-symbol')) failures.push('legacy brand-symbol styles must be removed')
if (!appStyles.includes('@media (max-width: 1179px)')) failures.push('channel sidebar must collapse below 1180px')
if (!appStyles.includes('.project-stack-scroll')) failures.push('long project rails need a dedicated scroll region')
if (!appStyles.includes('.mobile-search-button')) failures.push('mobile workbench must retain a global search entry')
for (const file of ['src/pages/login.tsx', 'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-rail.tsx']) {
const source = readFileSync(file, 'utf8')
if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`)

View File

@@ -33,6 +33,10 @@ 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 capturedInboxItemId = '019b0000-0000-7000-8000-000000000017'
const capturedInboxSuggestionId = '019b0000-0000-7000-8000-000000000018'
const conflictInboxItemId = '019b0000-0000-7000-8000-000000000020'
const conflictInboxSuggestionId = '019b0000-0000-7000-8000-000000000021'
const unknownProjectId = '019b0000-0000-7000-8000-000000000099'
const externalProjectId = '019b0000-0000-7000-8000-000000000088'
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
@@ -43,6 +47,7 @@ const projectPatchRequests = []
const inboxAnalyzeRequests = []
const inboxConfirmRequests = []
const aiSessionRequests = []
const inboxCaptureRequests = []
let delayedAIListsRemaining = 0
let delayNextAICreate = false
let delayedAICreateCompleted = false
@@ -140,11 +145,12 @@ const visualCheckWorkspace = {
},
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: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 4, 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 },
{ id: 'custom-link', projectId, type: 'custom_link', title: '外部集成', icon: 'link', count: 0, url: 'https://example.invalid', sortOrder: 6 },
],
tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }],
recentSessions: [
@@ -181,11 +187,32 @@ const visualCheckWorkspace = {
tag: '待处理',
time: '2026-07-21T02:10:00Z',
},
{
id: conflictInboxItemId,
projectId,
source: '手动收集',
title: '冲突确认测试',
summary: '使用独立条目验证 409 冲突后的原项目刷新。',
status: 'open',
tag: '待处理',
time: '2026-07-21T02:00:00Z',
},
],
tasks: [],
aiSessions: [],
notesSources: [],
cronPlans: [],
cronPlans: [
{
id: '019b0000-0000-7000-8000-000000000019',
projectId,
title: '每日整理提醒',
schedule: '0 9 * * *',
nextRun: '2026-07-23T01:00:00Z',
enabled: true,
lastResult: '',
owner: 'demo',
},
],
}
const secondVisualCheckWorkspace = {
@@ -201,7 +228,8 @@ const secondVisualCheckWorkspace = {
},
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 },
{ id: 'second-inbox', projectId: secondProjectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 0, url: '', sortOrder: 1 },
{ id: 'second-ai', projectId: secondProjectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 2 },
],
tags: [],
recentSessions: [],
@@ -212,6 +240,34 @@ const secondVisualCheckWorkspace = {
cronPlans: [],
}
const longProjectFixtures = Array.from({ length: 11 }, (_, index) => {
const sequence = String(index + 20).padStart(12, '0')
return {
id: `019b0000-0000-7000-8000-${sequence}`,
name: `长列表项目 ${index + 1}`,
identifier: `long-${index + 1}`,
icon: `L${index + 1}`,
background: '#14C9C9',
description: '用于检查长项目栏滚动区域。',
initials: `L${index + 1}`,
unreadCount: 0,
}
})
const longProjectWorkspaces = longProjectFixtures.map((project, index) => ({
project,
channels: [
{ id: `long-overview-${index}`, projectId: project.id, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
],
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()
@@ -224,7 +280,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return
}
if (url.pathname === '/api/v1/projects') {
await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project] })
await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project, ...longProjectFixtures] })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/workspace`) {
@@ -245,6 +301,34 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
await route.fulfill({ json: secondVisualCheckWorkspace })
return
}
const longProjectWorkspace = longProjectWorkspaces.find((workspace) => url.pathname === `/api/v1/projects/${workspace.project.id}/workspace`)
if (longProjectWorkspace) {
workspaceRequests.push(url.pathname)
await route.fulfill({ json: longProjectWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${secondProjectId}/inbox` && method === 'POST') {
const input = route.request().postDataJSON()
inboxCaptureRequests.push(input)
const capturedItem = {
id: capturedInboxItemId,
projectId: secondProjectId,
source: input.sourceType,
sourceType: input.sourceType,
title: input.title,
body: input.body,
summary: input.body,
status: 'open',
tag: '待处理',
time: '2026-07-22T03:00:00Z',
createdAt: '2026-07-22T03:00:00Z',
updatedAt: '2026-07-22T03:00:00Z',
}
secondVisualCheckWorkspace.inbox = [capturedItem]
secondVisualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count = 1
await route.fulfill({ status: 201, json: capturedItem })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
const responseSnapshot = structuredClone(controlledAISessions)
const delay = delayedAIListsRemaining > 0 ? 700 : 120
@@ -323,7 +407,29 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
})
return
}
if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`, `/api/v1/inbox/${thirdInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') {
if (url.pathname === `/api/v1/inbox/${capturedInboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(capturedInboxItemId)
await route.fulfill({
json: {
suggestions: [
{ id: capturedInboxSuggestionId, kind: 'task', title: '收集后的待办', body: '由真实收集条目生成的候选任务。' },
],
},
})
return
}
if (url.pathname === `/api/v1/inbox/${conflictInboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(conflictInboxItemId)
await route.fulfill({
json: {
suggestions: [
{ id: conflictInboxSuggestionId, kind: 'task', title: '冲突候选任务', body: '只用于 409 冲突流程。' },
],
},
})
return
}
if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`, `/api/v1/inbox/${thirdInboxItemId}/confirm`, `/api/v1/inbox/${capturedInboxItemId}/confirm`, `/api/v1/inbox/${conflictInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') {
inboxConfirmRequests.push(route.request().postDataJSON())
await new Promise((resolve) => setTimeout(resolve, 250))
if (failNextInboxConfirm) {
@@ -333,6 +439,9 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
}
if (conflictNextInboxConfirm) {
conflictNextInboxConfirm = false
const conflictedItemId = url.pathname.split('/').at(-2)
const conflictedItem = visualCheckWorkspace.inbox.find((item) => item.id === conflictedItemId)
if (conflictedItem) conflictedItem.status = 'processed'
await route.fulfill({
status: 409,
json: { error: { code: 'conflict', message: '确认状态发生冲突' } },
@@ -357,8 +466,9 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
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
const confirmedWorkspace = confirmedItemId === capturedInboxItemId ? secondVisualCheckWorkspace : visualCheckWorkspace
confirmedWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed'
confirmedWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1
if (confirmedItemId === secondInboxItemId) failNextInboxWorkspaceRefresh = true
await route.fulfill({ json: { createdCount: inboxConfirmRequests.at(-1).suggestionIds.length } })
return
@@ -425,6 +535,16 @@ const waitForDrawerOpen = async (selector) => page.waitForFunction((drawerSelect
return drawer?.classList.contains('open') && Math.abs(drawer.getBoundingClientRect().left) < 1
}, selector)
const closeItemPreview = async (expectedTitle) => {
const preview = page.getByRole('dialog', { name: '只读预览' })
await preview.waitFor({ state: 'visible' })
if (!await preview.getByText(expectedTitle, { exact: true }).isVisible()) {
failures.push(`read-only item preview missing ${expectedTitle}`)
}
await page.keyboard.press('Escape')
await preview.waitFor({ state: 'hidden' })
}
const collectMetrics = async () => page.evaluate(() => {
const statusbar = document.querySelector('.statusbar')
const statusUser = document.querySelector('.status-user')
@@ -436,6 +556,7 @@ const collectMetrics = async () => page.evaluate(() => {
const projectOverview = document.querySelector('.overview-page:not(.workspace-page)')
const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children')
const projectStackScroll = rail?.querySelector('.project-stack-scroll')
const sidebar = document.querySelector('.channel-sidebar')
const channelList = document.querySelector('.channel-list')
const stage = document.querySelector('.stage')
@@ -447,13 +568,13 @@ const collectMetrics = async () => page.evaluate(() => {
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 projectItems = [...document.querySelectorAll('.project-button')]
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 itemRects = projectItems.map(rect).filter(Boolean)
const horizontalInsets = railBox && project
? {
left: project.getBoundingClientRect().left - railBox.left,
@@ -501,6 +622,8 @@ const collectMetrics = async () => page.evaluate(() => {
verticalGaps,
projectRailOverflow: overflowState(rail),
projectRailChildrenOverflow: overflowState(railChildren),
projectStackOverflow: overflowState(projectStackScroll),
projectCount: projectItems.length,
channelSidebarOverflow: overflowState(sidebar),
channelListOverflow: overflowState(channelList),
stageOverflow: overflowState(stage),
@@ -539,9 +662,16 @@ if (process.env.VISUAL_CHECK_SCOPE === 'login') {
}
await page.setViewportSize({ width: 1440, height: 1024 })
await page.getByText('记住服务器地址', { exact: true }).click()
if (!await page.getByRole('checkbox', { name: '记住服务器地址', exact: true }).isChecked()) {
failures.push('remember server checkbox must be interactive')
}
await page.locator('.login-form-panel .arco-btn-primary').click()
await page.waitForSelector('.workbench-shell')
await page.waitForTimeout(700)
await page.waitForFunction(() => document.querySelectorAll('.project-button').length >= 13)
if (await page.evaluate(() => window.localStorage.getItem('senlinai.server')) !== 'http://localhost:9150') {
failures.push('remember server must persist the selected server in localStorage after login')
}
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics()
@@ -549,6 +679,19 @@ 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')
}
const mobileSearchButton = page.getByRole('button', { name: '打开全局搜索', exact: true })
if (await mobileSearchButton.count() !== 1) {
failures.push('390px workspace must provide one global search entry')
} else {
await mobileSearchButton.click()
await page.getByPlaceholder('搜索项目、任务和笔记').waitFor({ state: 'visible' })
await page.screenshot({ path: 'test-results/workbench-mobile-search.png' })
await page.getByRole('button', { name: '关闭全局搜索', exact: true }).click()
await page.waitForFunction(() => {
const search = document.querySelector('.global-search')
return search && getComputedStyle(search).display === 'none'
})
}
await page.setViewportSize({ width: 1440, height: 1024 })
await page.locator('.dashboard-button[title="探索"]').click()
@@ -565,6 +708,25 @@ 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: 1024, height: 768 })
await page.waitForFunction(() => {
const sidebar = document.querySelector('aside.channel-sidebar')
return sidebar && getComputedStyle(sidebar).visibility === 'hidden'
})
const tabletChannelNavButton = page.getByRole('button', { name: '打开频道导航', exact: true })
if (await tabletChannelNavButton.count() !== 1) {
failures.push('1024px project view must provide one channel drawer entry')
} else {
await tabletChannelNavButton.click()
await page.waitForFunction(() => {
const sidebar = document.querySelector('aside.channel-sidebar')
return sidebar?.classList.contains('open') && Math.abs(sidebar.getBoundingClientRect().left - 96) <= 1
})
await page.screenshot({ path: 'test-results/channel-navigation-tablet-1024.png' })
await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 900, y: 20 } })
await page.waitForFunction(() => !document.querySelector('aside.channel-sidebar')?.classList.contains('open'))
}
await page.setViewportSize({ width: 390, height: 844 })
await page.waitForFunction(() => [...document.querySelectorAll('.project-rail, .channel-sidebar')]
.every((node) => getComputedStyle(node).visibility === 'hidden'))
@@ -572,7 +734,7 @@ 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 projectIdentifier = document.querySelector('.project-overview-share .arco-tag')
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) => {
@@ -585,7 +747,10 @@ const mobileProjectOverviewLayout = await page.evaluate(() => {
viewportWidth: document.documentElement.clientWidth,
hero: hero?.getBoundingClientRect().toJSON() ?? null,
heroTitle: heroTitle ? { ...heroTitle.getBoundingClientRect().toJSON(), lines: lineCount(heroTitle) } : null,
shareButton: shareButton?.getBoundingClientRect().toJSON() ?? null,
projectIdentifier: projectIdentifier ? {
...projectIdentifier.getBoundingClientRect().toJSON(),
text: projectIdentifier.textContent?.trim() ?? '',
} : null,
metricColumnLefts: metricColumns.map((column) => Math.round(column.getBoundingClientRect().left)),
metricLabels: metricLabels.map((label) => ({
text: label.textContent?.trim() ?? '',
@@ -597,8 +762,10 @@ const mobileProjectOverviewLayout = await page.evaluate(() => {
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 (!mobileProjectOverviewLayout.projectIdentifier || mobileProjectOverviewLayout.projectIdentifier.right > mobileProjectOverviewLayout.viewportWidth) {
failures.push(`mobile project identifier must stay inside the viewport, got ${JSON.stringify(mobileProjectOverviewLayout.projectIdentifier)}`)
} else if (mobileProjectOverviewLayout.projectIdentifier.text !== 'forest') {
failures.push(`project overview must display the persisted identifier, got ${JSON.stringify(mobileProjectOverviewLayout.projectIdentifier.text)}`)
}
if (new Set(mobileProjectOverviewLayout.metricColumnLefts).size !== 2) {
failures.push(`mobile project metrics must use two columns, got left edges ${JSON.stringify(mobileProjectOverviewLayout.metricColumnLefts)}`)
@@ -616,6 +783,12 @@ if (await projectNavButton.count() === 0) {
} else {
await projectNavButton.click()
await waitForDrawerOpen('aside.project-rail')
const lastLongProject = page.locator('.project-button[title="长列表项目 11"]')
await lastLongProject.scrollIntoViewIfNeeded()
if (!await lastLongProject.isVisible()) failures.push('12+ project fixture must allow scrolling to the final project')
if (!await page.getByRole('button', { name: '新建项目', exact: true }).isVisible()) {
failures.push('create-project must remain reachable while the long project list scrolls')
}
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')
@@ -658,6 +831,12 @@ if (await channelNavButton.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 } })
}
const itemPreview = page.getByRole('dialog', { name: '只读预览' })
if (!await itemPreview.isVisible() || !await itemPreview.getByText('移动导航会话', { exact: true }).isVisible()) {
failures.push('selecting a recent session must consume selectedItem in a read-only preview')
}
await page.keyboard.press('Escape')
await itemPreview.waitFor({ state: 'hidden' })
}
await page.setViewportSize({ width: 1440, height: 1024 })
@@ -792,6 +971,10 @@ if (await settingsButton.count() === 0) {
if (!await settingsModal.getByText('项目设置保存失败,请稍后重试', { exact: true }).isVisible()) {
failures.push('failed project settings update must render the API Chinese error inside the modal')
}
if (await settingsModal.getByLabel('简介').inputValue() !== '触发失败') {
failures.push('failed project settings update must retain the edited draft')
}
await page.screenshot({ path: 'test-results/project-mutation-failure-draft-retained.png' })
expectingProjectPatchError = false
if (expectedProjectPatchConsoleErrorCount !== 1) {
failures.push(`expected one simulated PATCH console error, got ${expectedProjectPatchConsoleErrorCount}`)
@@ -806,6 +989,32 @@ const stageHoverMetrics = await collectMetrics()
await page.locator('.channel-list').hover()
const channelListHoverMetrics = await collectMetrics()
await page.locator('.project-button[title="并行项目"]').click()
await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click()
const emptyInboxPage = page.locator('.project-inbox-page')
await emptyInboxPage.getByText('当前没有待处理的 Inbox 内容', { exact: true }).waitFor({ state: 'visible' })
await emptyInboxPage.getByLabel('Inbox 标题').fill('视觉收集条目')
await emptyInboxPage.getByLabel('Inbox 收集内容').fill('从空 Inbox 真实收集,再进入分析流程。')
await emptyInboxPage.getByRole('button', { name: '收集到 Inbox', exact: true }).click()
await emptyInboxPage.locator('.mail-item', { hasText: '视觉收集条目' }).waitFor({ state: 'visible' })
const captureRequest = inboxCaptureRequests.at(-1)
if (
captureRequest?.sourceType !== 'manual'
|| captureRequest?.title !== '视觉收集条目'
|| captureRequest?.body !== '从空 Inbox 真实收集,再进入分析流程。'
) {
failures.push(`empty Inbox capture must POST the entered draft, got ${JSON.stringify(captureRequest)}`)
}
await emptyInboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await emptyInboxPage.getByText('收集后的待办', { exact: true }).waitFor({ state: 'visible' })
if (!inboxAnalyzeRequests.includes(capturedInboxItemId)) {
failures.push('a newly captured Inbox item must continue into the real analyze flow')
}
await page.screenshot({ path: 'test-results/project-inbox-empty-capture-analysis.png', fullPage: true })
await page.locator('.project-button[title="森林项目已更新"]').click()
const analyzeRequestsBeforeExistingInboxFlow = inboxAnalyzeRequests.length
const confirmRequestsBeforeExistingInboxFlow = inboxConfirmRequests.length
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')
@@ -832,7 +1041,10 @@ if (await inboxChannelButton.count() === 0) {
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) {
if (
inboxAnalyzeRequests.length !== analyzeRequestsBeforeExistingInboxFlow + 2
|| inboxConfirmRequests.length !== confirmRequestsBeforeExistingInboxFlow
) {
failures.push('analysis must load drafts without invoking confirmation writes')
}
if (await inboxPage.getByRole('checkbox').count() !== 3) {
@@ -1011,7 +1223,7 @@ if (await inboxChannelButton.count() === 0) {
await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click()
const conflictInboxPage = page.locator('.project-inbox-page')
await conflictInboxPage.locator('.mail-item', { hasText: '延迟确认测试' }).click()
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
@@ -1019,7 +1231,7 @@ if (await inboxChannelButton.count() === 0) {
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(() => {})
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')
}
@@ -1056,8 +1268,18 @@ for (const channel of [
await aiPage.locator('textarea').fill('只创建受控会话入口')
await aiPage.getByRole('button', { name: '创建会话', exact: true }).click()
await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await closeItemPreview('新会话视觉检查')
await page.screenshot({ path: 'test-results/project-ai-controlled.png', fullPage: true })
}
if (channel.label === '计划任务') {
const cronPage = page.locator('.project-cron-page')
if (await cronPage.locator('.arco-switch').count() !== 0) {
failures.push('cron metadata page must not expose a non-persistent switch')
}
for (const text of ['每日整理提醒', '暂无执行记录', '仅展示服务端保存的计划元数据']) {
if (!await cronPage.getByText(text, { exact: false }).isVisible()) failures.push(`cron metadata page missing ${text}`)
}
}
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? ''
@@ -1079,6 +1301,7 @@ for (const channel of [
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 closeItemPreview('乱序请求保留的新会话')
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')
@@ -1122,6 +1345,16 @@ for (const channel of [
}
}
const locationBeforeCustomChannel = page.url()
await page.locator('.channel-button', { hasText: '外部集成' }).click()
const customChannelPage = page.locator('.project-unavailable-page')
await customChannelPage.getByText('自定义链接频道暂未开放', { exact: true }).waitFor({ state: 'visible' })
if (page.url() !== locationBeforeCustomChannel || await customChannelPage.locator('a').count() !== 0) {
failures.push('custom_link channel must remain a local read-only unavailable state')
}
await page.screenshot({ path: 'test-results/project-custom-link-unavailable.png', fullPage: true })
await page.locator('.channel-button', { hasText: '新建频道' }).click()
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 ?? '',
@@ -1299,6 +1532,22 @@ for (const check of channelPageChecks) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
}
}
if (metrics.projectCount < 13) failures.push(`long project fixture must render at least 13 projects, got ${metrics.projectCount}`)
if (
!metrics.projectStackOverflow
|| metrics.projectStackOverflow.overflowY !== 'auto'
|| metrics.projectStackOverflow.scrollHeight <= metrics.projectStackOverflow.clientHeight
) {
failures.push(`long project list must use its dedicated scroll region, got ${JSON.stringify(metrics.projectStackOverflow)}`)
}
if (
!metrics.createProjectButton
|| !metrics.projectRail
|| metrics.createProjectButton.top < metrics.projectRail.top
|| metrics.createProjectButton.top + metrics.createProjectButton.height > metrics.projectRail.top + metrics.projectRail.height
) {
failures.push(`create-project must remain pinned inside the rail, got rail=${JSON.stringify(metrics.projectRail)} create=${JSON.stringify(metrics.createProjectButton)}`)
}
if (aiSessionRequests.length !== 3) failures.push(`expected three controlled AI session requests, got ${aiSessionRequests.length}`)
for (const request of aiSessionRequests) {

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createWorkspaceRefreshGate } from '../src/app/workspace-refresh-gate.ts'
test('a newer full workspace refresh aborts and invalidates the previous generation', () => {
const gate = createWorkspaceRefreshGate()
const first = gate.begin()
const second = gate.begin()
assert.equal(first.signal.aborted, true)
assert.equal(first.isCurrent(), false)
assert.equal(second.signal.aborted, false)
assert.equal(second.isCurrent(), true)
})
test('a targeted origin-project refresh also supersedes an older full refresh', () => {
const gate = createWorkspaceRefreshGate()
const fullRefresh = gate.begin()
const originRefresh = gate.begin()
assert.equal(fullRefresh.signal.aborted, true)
assert.equal(fullRefresh.isCurrent(), false)
assert.equal(originRefresh.isCurrent(), true)
})
test('invalidating on session cleanup prevents stale workspace commits', () => {
const gate = createWorkspaceRefreshGate()
const request = gate.begin()
gate.invalidate()
assert.equal(request.signal.aborted, true)
assert.equal(request.isCurrent(), false)
})