Skip the project surfaces request when no project is current #122

Merged
weichen merged 1 commits from fix/no-project-surfaces-404 into main 2026-10-20 15:39:58 +00:00
2 changed files with 65 additions and 1 deletions
+1 -1
View File
@@ -817,7 +817,7 @@ export const store = {
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
const metas = list ?? []
+64
View File
@@ -0,0 +1,64 @@
// ps-frontend-no-project-surfaces-r2:openConversation 在 state.currentProjectId 为空时
// 不再触发 /api/projects/<id>/surfaces 请求(避免被移除项目成员打开历史会话时的 404 噪音)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { Conversation, Message, SurfaceMeta } from '../src/contracts'
function detail(id: string) {
return {
conversation: { id, projectId: '', title: id, createdAt: '', updatedAt: '' } as Conversation,
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
}
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
let projectSurfacesSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
projectSurfacesSpy = vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
// 还原至 currentProjectId = null 的干净投影,避免别的套件残留影响本次断言。
store.resetIdentity()
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('openConversation projectSurfaces gating', () => {
it('skips the /api/projects/.../surfaces request and keeps projectSurfaces empty when no project is current', async () => {
expect(store.getState().currentProjectId).toBeNull()
await store.openConversation('cnv_no_project')
// 让微任务(包含 surfaceList/Stream 等链)落定,但不改变当前结论。
await new Promise(r => setTimeout(r, 0))
expect(projectSurfacesSpy).not.toHaveBeenCalled()
expect(store.getState().projectSurfaces).toEqual([])
expect(store.getState().currentProjectId).toBeNull()
})
it('still requests /api/projects/<id>/surfaces when a project is current', async () => {
const metas: SurfaceMeta[] = [{ surfaceId: 'surf_1', title: '页面一', pluginId: 'plg_1', revision: 1, updatedAt: '2026-10-12T00:00:00Z' }]
projectSurfacesSpy.mockResolvedValueOnce(metas)
await store.openProject('prj_with_surfaces')
expect(store.getState().currentProjectId).toBe('prj_with_surfaces')
projectSurfacesSpy.mockClear()
await store.openConversation('cnv_with_project')
expect(projectSurfacesSpy).toHaveBeenCalledTimes(1)
expect(projectSurfacesSpy.mock.calls[0][0]).toBe('prj_with_surfaces')
// 微任务处理完 .then 后,projectSurfaces 投影被填进服务端的元数据列表。
await new Promise(r => setTimeout(r, 0))
expect(store.getState().projectSurfaces).toEqual(metas)
})
})