Why: Existing event and helper tests missed the SurfaceMeta to snapshot
reload path that blanked the app after the integration pointer regressed.
Evidence: New restore regressions fail on 1edd872 and pass on compatible
main; independent reviewer replays and real browser contract replay are
recorded in the Dev surface-black-screen-recovery task card.
Impact: Tests only; protects reload, partial failure, newer live revisions,
conversation switching, and two-page browser preview.
Refs: Frontend #68; Dev #454 and #452; Dev surface-black-screen-recovery
154 lines
7.2 KiB
TypeScript
154 lines
7.2 KiB
TypeScript
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, SurfaceMeta, SurfaceSnapshot } from './contracts'
|
||
|
||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||
|
||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||
return {
|
||
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||
payload: {
|
||
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
|
||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
|
||
revision, activationEpoch: 1,
|
||
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
|
||
},
|
||
}
|
||
}
|
||
|
||
describe('surface.updated projection', () => {
|
||
it('新事件入库;旧修订与重复事件不覆盖', () => {
|
||
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
|
||
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
|
||
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
|
||
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
|
||
const list = store.getState().surfaces
|
||
expect(list).toHaveLength(1)
|
||
expect(list[0].revision).toBe(2)
|
||
expect(list[0].spec.title).toBe('修订二')
|
||
})
|
||
|
||
it('多页面并存,互不覆盖', () => {
|
||
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
|
||
const list = store.getState().surfaces
|
||
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
|
||
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
|
||
})
|
||
})
|
||
|
||
|
||
describe('surface.updated 可见性联动', () => {
|
||
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||
const ui = store.getState().ui
|
||
expect(ui.rightTab).toBe('files')
|
||
expect(ui.rightCollapsed).toBe(false)
|
||
expect(ui.filesView).toBe('preview')
|
||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||
})
|
||
|
||
it('抑制开标签时留 toast 痕迹', () => {
|
||
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
|
||
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
|
||
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
|
||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('U6 工作区任务页', () => {
|
||
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
|
||
store.setUI({ workspaceSurfacePin: null })
|
||
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
|
||
const first = store.getState().ui.workspaceSurfacePin
|
||
expect(first).toBeNull() // 默认跟随(不自动 pin)
|
||
// 用户固定 w1
|
||
store.setUI({ workspaceSurfacePin: 'w1' })
|
||
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
|
||
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
|
||
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
|
||
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
|
||
expect(w2?.spec.title).toBe('第二页')
|
||
store.setUI({ workspaceSurfacePin: null })
|
||
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([])
|
||
})
|
||
})
|