Protect saved surface reload and preview recovery #71
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle')
|
||||
|
||||
const conversation: Conversation = {
|
||||
id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归',
|
||||
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
|
||||
}
|
||||
const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({
|
||||
identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||
spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] },
|
||||
}))
|
||||
const metadata: SurfaceMeta[] = snapshots.map(s => ({
|
||||
surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId,
|
||||
revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt,
|
||||
}))
|
||||
|
||||
test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
await page.addInitScript(({ id }) => {
|
||||
localStorage.setItem('as.conv', id)
|
||||
localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false }))
|
||||
}, { id: conversation.id })
|
||||
|
||||
// Replay the two distinct server contracts through the real app. Intercept all
|
||||
// API requests so this regression can run against a live bundle without writes.
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' })
|
||||
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { status: 'ok' }
|
||||
else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' }
|
||||
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }]
|
||||
else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation]
|
||||
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||
else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata
|
||||
else if (path.startsWith('/api/surfaces/')) {
|
||||
body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1))
|
||||
} else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||
await expect(page.locator('.preview-pane')).toContainText('恢复正文1')
|
||||
await page.reload()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
|
||||
await page.getByRole('button', { name: '返回文件列表' }).click()
|
||||
await expect(page.locator('[data-surface-row]')).toHaveCount(2)
|
||||
await page.locator('[data-surface-row="page-2"]').click()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2')
|
||||
await expect(page.locator('.preview-pane')).toContainText('恢复正文2')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
+78
-2
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as apiModule from './api'
|
||||
import { api } from './api'
|
||||
import { store } from './store'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||||
import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
|
||||
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||||
@@ -75,3 +77,77 @@ describe('U6 工作区任务页', () => {
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved surfaces on conversation reload', () => {
|
||||
const snapshot = (id: string, revision = 1) => surfEvent(1, id, revision, id).payload!
|
||||
const meta = (id: string): SurfaceMeta => ({
|
||||
surfaceId: id, title: id, pluginId: 'generative-ui', revision: 1,
|
||||
conversationId: 'c1', updatedAt: '2026-10-12T00:00:00Z',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceGet').mockImplementation(async id => snapshot(id))
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
|
||||
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0,
|
||||
}))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
const key = previewTabsKey(store.getState().currentConvId)
|
||||
for (const tab of store.getState().previewTabs[key] ?? []) store.closePreview(tab.id)
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('hydrates the metadata directory before exposing two renderable snapshots', async () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('reload-1'), meta('reload-2')])
|
||||
await store.openConversation('reload')
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().surfaces).toEqual([snapshot('reload-1'), snapshot('reload-2')])
|
||||
})
|
||||
expect(api.surfaceGet).toHaveBeenCalledWith('reload-1')
|
||||
expect(api.surfaceGet).toHaveBeenCalledWith('reload-2')
|
||||
expect(store.getState().previewActive.reload).toBe('surface:reload-1')
|
||||
})
|
||||
|
||||
it('isolates one unavailable page while restoring the remaining page', async () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('missing'), meta('available')])
|
||||
vi.mocked(api.surfaceGet).mockImplementation(async id => {
|
||||
if (id === 'missing') throw new Error('404: removed page')
|
||||
return snapshot(id)
|
||||
})
|
||||
await store.openConversation('partial')
|
||||
await vi.waitFor(() => expect(store.getState().surfaces).toEqual([snapshot('available')]))
|
||||
})
|
||||
|
||||
it('keeps a newer live revision when an older snapshot arrives late', async () => {
|
||||
let resolve!: (value: SurfaceSnapshot) => void
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('race')
|
||||
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision'))
|
||||
resolve(snapshot('racing', 1))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().surfaces).toHaveLength(1)
|
||||
expect(store.getState().surfaces[0].revision).toBe(2)
|
||||
expect(store.getState().surfaces[0].spec.title).toBe('live revision')
|
||||
})
|
||||
|
||||
it('discards hydration from the previous conversation after switching', async () => {
|
||||
let resolve!: (value: SurfaceSnapshot) => void
|
||||
vi.mocked(api.surfaceList).mockResolvedValueOnce([meta('previous')]).mockResolvedValue([])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('previous-conv')
|
||||
await store.openConversation('current-conv')
|
||||
resolve(snapshot('previous'))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().currentConvId).toBe('current-conv')
|
||||
expect(store.getState().surfaces).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user