fix(web): preserve saved writes across refresh failures
This commit is contained in:
41
apps/web_v1/scripts/mutation-refresh.test.mjs
Normal file
41
apps/web_v1/scripts/mutation-refresh.test.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { executeSavedMutation } from '../src/app/mutation-refresh.ts'
|
||||
|
||||
test('a saved mutation stays successful when its refresh fails', async () => {
|
||||
const events = []
|
||||
const refreshFailure = new Error('refresh unavailable')
|
||||
|
||||
const outcome = await executeSavedMutation({
|
||||
mutate: async () => {
|
||||
events.push('mutate')
|
||||
return { id: 'created-1' }
|
||||
},
|
||||
onSaved: (value) => events.push(`saved:${value.id}`),
|
||||
refresh: async (value) => {
|
||||
events.push(`refresh:${value.id}`)
|
||||
throw refreshFailure
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(events, ['mutate', 'saved:created-1', 'refresh:created-1'])
|
||||
assert.equal(outcome.value.id, 'created-1')
|
||||
assert.equal(outcome.refreshError, refreshFailure)
|
||||
})
|
||||
|
||||
test('a failed mutation does not close its draft or attempt a refresh', async () => {
|
||||
const events = []
|
||||
const mutationFailure = new Error('save rejected')
|
||||
|
||||
await assert.rejects(() => executeSavedMutation({
|
||||
mutate: async () => {
|
||||
events.push('mutate')
|
||||
throw mutationFailure
|
||||
},
|
||||
onSaved: () => events.push('saved'),
|
||||
refresh: async () => events.push('refresh'),
|
||||
}), mutationFailure)
|
||||
|
||||
assert.deepEqual(events, ['mutate'])
|
||||
})
|
||||
42
apps/web_v1/scripts/mutation-wiring.test.mjs
Normal file
42
apps/web_v1/scripts/mutation-wiring.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const appSource = readFileSync(new URL('../src/app/App.tsx', import.meta.url), 'utf8')
|
||||
|
||||
function functionSource(name, nextName) {
|
||||
const start = appSource.indexOf(`function ${name}`)
|
||||
const end = appSource.indexOf(`function ${nextName}`, start + 1)
|
||||
assert.notEqual(start, -1, `missing ${name}`)
|
||||
assert.notEqual(end, -1, `missing boundary ${nextName}`)
|
||||
return appSource.slice(start, end)
|
||||
}
|
||||
|
||||
test('modal mutations close at the save boundary and refresh only their affected projects', () => {
|
||||
for (const [name, nextName] of [
|
||||
['handleCreateProject', 'handleCreateTask'],
|
||||
['handleCreateTask', 'handleUploadSource'],
|
||||
['handleUploadSource', 'handleCreateCronPlan'],
|
||||
['handleCreateCronPlan', 'handleCreateProjectTag'],
|
||||
['handleCreateProjectTag', 'openTask'],
|
||||
]) {
|
||||
const source = functionSource(name, nextName)
|
||||
assert.match(source, /runAction\(/, `${name} must use the saved-mutation boundary`)
|
||||
}
|
||||
assert.doesNotMatch(appSource, /refreshAfterAction/)
|
||||
assert.match(functionSource('handleUpdateWorkspaceTask', 'handleUpdateProject'), /refreshProjectWorkspaces/)
|
||||
})
|
||||
|
||||
test('project settings and Inbox capture keep save success separate from refresh failure', () => {
|
||||
assert.match(functionSource('handleUpdateProject', 'handleAnalyzeInbox'), /executeSavedMutation/)
|
||||
assert.match(functionSource('handleCaptureInbox', 'handleConfirmInbox'), /executeSavedMutation/)
|
||||
const confirmSource = functionSource('handleConfirmInbox', 'handleSelectSearchResult')
|
||||
assert.match(confirmSource, /offerRefreshRecovery/)
|
||||
assert.match(confirmSource, /createdCount: response\.createdCount/)
|
||||
})
|
||||
|
||||
test('refresh recovery remains actionable after a save', () => {
|
||||
assert.match(appSource, /数据已保存,但页面刷新失败/)
|
||||
assert.match(appSource, />刷新数据<\/Button>/)
|
||||
assert.match(appSource, /refreshRecovery\.retry\(\)/)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const requiredFiles = [
|
||||
'src/app/App.tsx',
|
||||
'src/app/mutation-refresh.ts',
|
||||
'src/pages/login.tsx',
|
||||
'src/pages/workspace-body.tsx',
|
||||
'src/pages/workspace-home.tsx',
|
||||
@@ -28,6 +29,8 @@ const requiredFiles = [
|
||||
'src/api/ai.ts',
|
||||
'scripts/api-client.test.mjs',
|
||||
'scripts/workspace-refresh-gate.test.mjs',
|
||||
'scripts/mutation-refresh.test.mjs',
|
||||
'scripts/mutation-wiring.test.mjs',
|
||||
]
|
||||
|
||||
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
||||
@@ -212,6 +215,21 @@ if (appStyles.includes('.brand-symbol')) failures.push('legacy brand-symbol styl
|
||||
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')
|
||||
const mapperSource = readFileSync('src/api/mappers.tsx', 'utf8')
|
||||
if (mapperSource.includes("plan.enabled ? '运行中' : '暂停'")) {
|
||||
failures.push('Cron enabled metadata must use 已启用/已停用 instead of execution-state wording')
|
||||
}
|
||||
const cronSource = readFileSync('src/pages/projects/project-cron.tsx', 'utf8')
|
||||
if (cronSource.includes('运行中') || cronSource.includes('暂停</Tag>')) {
|
||||
failures.push('Cron summary must describe enabled metadata as 已启用/已停用')
|
||||
}
|
||||
const projectOverviewSource = readFileSync('src/pages/projects/project-overview.tsx', 'utf8')
|
||||
if (
|
||||
projectOverviewSource.includes('<span>进度 {task.progress}</span>')
|
||||
&& !projectOverviewSource.includes('{task.progress && <span>进度 {task.progress}</span>}')
|
||||
) {
|
||||
failures.push('project overview must hide task progress when the backend provides no real progress')
|
||||
}
|
||||
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`)
|
||||
|
||||
@@ -684,13 +684,31 @@ 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' })
|
||||
const mobileSearchInput = page.getByPlaceholder('搜索项目、任务和笔记')
|
||||
await mobileSearchInput.waitFor({ state: 'visible' })
|
||||
await mobileSearchInput.fill('回调')
|
||||
await mobileSearchInput.press('Enter')
|
||||
const mobileSearchResult = page.getByRole('button', { name: /回调任务/ })
|
||||
await mobileSearchResult.waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
|
||||
if (!await mobileSearchResult.isVisible().catch(() => false)) {
|
||||
failures.push('390px search must keep its result panel visible after submit')
|
||||
} else {
|
||||
const mobileSearchLayerVisible = await page.evaluate(() => {
|
||||
const search = document.querySelector('.global-search')
|
||||
return Boolean(search && getComputedStyle(search).display !== 'none')
|
||||
})
|
||||
if (!mobileSearchLayerVisible) failures.push('390px search must keep its input layer anchored until result selection')
|
||||
await page.screenshot({ path: 'test-results/workbench-mobile-search-results.png' })
|
||||
await mobileSearchResult.click()
|
||||
const mobileActiveChannel = await page.locator('.channel-button.active').textContent().catch(() => '')
|
||||
if (!mobileActiveChannel?.includes('工作计划')) {
|
||||
failures.push('390px search result selection must navigate to its task channel')
|
||||
}
|
||||
if (await page.getByRole('button', { name: '关闭全局搜索', exact: true }).count() !== 0) {
|
||||
failures.push('390px search panel must close after selecting a result')
|
||||
}
|
||||
}
|
||||
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 })
|
||||
|
||||
@@ -1193,6 +1211,16 @@ if (await inboxChannelButton.count() === 0) {
|
||||
if (!await secondConfirmButton.isDisabled()) {
|
||||
failures.push('a confirmed item must remain non-repeatable when its workspace refresh fails')
|
||||
}
|
||||
const recoveryRefreshButton = page.getByRole('button', { name: '刷新数据', exact: true })
|
||||
if (!await recoveryRefreshButton.isVisible().catch(() => false)) {
|
||||
failures.push('a saved write with a failed refresh must provide an actionable recovery refresh')
|
||||
} else {
|
||||
await recoveryRefreshButton.click()
|
||||
await recoveryRefreshButton.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {})
|
||||
if (await recoveryRefreshButton.isVisible().catch(() => false)) {
|
||||
failures.push('a successful recovery refresh must clear its warning action')
|
||||
}
|
||||
}
|
||||
expectingInboxRefreshError = false
|
||||
if (expectedInboxRefreshConsoleErrorCount !== 1) {
|
||||
failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`)
|
||||
|
||||
Reference in New Issue
Block a user