diff --git a/e2e/surface-recovery.playwright.ts b/e2e/surface-recovery.playwright.ts new file mode 100644 index 0000000..2b046dc --- /dev/null +++ b/e2e/surface-recovery.playwright.ts @@ -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([]) +}) diff --git a/src/surface.test.ts b/src/surface.test.ts index 5347206..158c228 100644 --- a/src/surface.test.ts +++ b/src/surface.test.ts @@ -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(r => { resolve = r })) + await store.openConversation('race') + store.applyEvent(surfEvent(301, 'racing', 2, 'live revision')) + resolve(snapshot('racing', 1)) + await new Promise(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(r => { resolve = r })) + await store.openConversation('previous-conv') + await store.openConversation('current-conv') + resolve(snapshot('previous')) + await new Promise(r => setTimeout(r, 0)) + expect(store.getState().currentConvId).toBe('current-conv') + expect(store.getState().surfaces).toEqual([]) + }) +})