fix(web): close final workbench interaction gaps
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
34
apps/web_v1/scripts/workspace-refresh-gate.test.mjs
Normal file
34
apps/web_v1/scripts/workspace-refresh-gate.test.mjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user