import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { auth } from './auth' import { PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS, createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus, } from './store' import type { ConversationView } from './api' import type { Project } from './contracts' const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' }) const task = (id: string, projectId: string): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt: '' }) const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((ok, fail) => { resolve = ok; reject = fail }) return { promise, resolve, reject } } /** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */ function fakeEnv() { const env = { visible: true, online: true, handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void }, } const port: ProjectSyncEnv = { visible: () => env.visible, online: () => env.online, subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } }, } return { port, hide() { env.visible = false; env.handlers?.sleep('paused') }, show() { env.visible = true; env.handlers?.wake() }, offline() { env.online = false; env.handlers?.sleep('offline') }, online() { env.online = true; env.handlers?.wake() }, focus() { env.handlers?.wake() }, } } let projects: Project[] let tasks: ConversationView[] let requests: { path: string; init?: RequestInit }[] let route: (path: string, init?: RequestInit) => Promise beforeEach(async () => { projects = [project('host-a')] tasks = [task('conv-a', 'host-a')] requests = [] const saved = new Map() vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null, setItem: (key: string, value: string) => { saved.set(key, value) }, removeItem: (key: string) => { saved.delete(key) } }) route = async (path, init) => { if (path === '/api/projects' && !init?.method) return response(projects) if (path === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId))) if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 }) if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] }) if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([]) if (path.endsWith('/cards')) return response({ cards: [] }) return response({}) } vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => { requests.push({ path, init }) return route(path, init) }) vi.stubGlobal('EventSource', class { addEventListener() {} close() {} }) auth.beginSession() await store.refreshMe() await store.refreshProjects() await store.openTask('conv-a') requests = [] }) afterEach(() => { store.stopProjectSync() vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() }) async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise { let elapsed = 0 while (!check() && elapsed < limitMs) { await vi.advanceTimersByTimeAsync(stepMs) elapsed += stepMs } return elapsed } describe('AutoDiscover project list', () => { it('keeps the visible-page budget inside 5 seconds', () => { expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000) expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS) }) it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => { vi.useFakeTimers() const env = fakeEnv() store.startProjectSync(env.port) projects = [project('host-a'), project('host-b', 'carol')] const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000) expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b']) expect(elapsed).toBeLessThanOrEqual(5000) expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true }) expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a') expect(store.getState().projectSync.status).toBe('ok') }) it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => { projects = [project('host-a'), project('host-b', 'carol')] await store.refreshProjects() vi.useFakeTimers() store.startProjectSync(fakeEnv().port) projects = [project('host-b', 'carol')] const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000) expect(elapsed).toBeLessThanOrEqual(5000) expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null }) expect(store.getState().projects.map(p => p.id)).toEqual(['host-b']) expect(store.getState().tasks.map(t => t.id)).toEqual([]) }) it('does not re-render or re-open anything when the list is unchanged', async () => { vi.useFakeTimers() store.startProjectSync(fakeEnv().port) const before = store.getState() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3) expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3) expect(store.getState().projects).toBe(before.projects) expect(requests.map(r => r.path)).not.toContain('/api/conversations') }) }) describe('RefreshSingleFlight', () => { it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => { vi.useFakeTimers() const env = fakeEnv() let concurrent = 0 let peak = 0 const calls: ReturnType>[] = [] const fetchOnce = vi.fn(() => { concurrent++; peak = Math.max(peak, concurrent) const call = deferred() calls.push(call) return call.promise.finally(() => { concurrent-- }) }) const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {}) sync.start() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) expect(fetchOnce).toHaveBeenCalledTimes(1) env.focus(); env.online(); env.show() await vi.advanceTimersByTimeAsync(500) expect(fetchOnce).toHaveBeenCalledTimes(1) calls[0].resolve() await vi.advanceTimersByTimeAsync(0) expect(fetchOnce).toHaveBeenCalledTimes(2) calls[1].resolve() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1) expect(fetchOnce).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(1) expect(fetchOnce).toHaveBeenCalledTimes(3) expect(peak).toBe(1) sync.stop() }) it('schedules the next round only after the current one finishes', async () => { vi.useFakeTimers() const slow = deferred() const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined) const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {}) sync.start() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500) expect(fetchOnce).toHaveBeenCalledTimes(1) slow.resolve() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) expect(fetchOnce).toHaveBeenCalledTimes(2) sync.stop() }) }) describe('RefreshBackoff', () => { it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => { vi.useFakeTimers() const statuses: [ProjectSyncStatus, number][] = [] let fail = true const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') }) const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures])) sync.start() await vi.advanceTimersByTimeAsync(2000) expect(statuses.at(-1)).toEqual(['degraded', 1]) await vi.advanceTimersByTimeAsync(3999) expect(fetchOnce).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) expect(fetchOnce).toHaveBeenCalledTimes(2) expect(statuses.at(-1)).toEqual(['degraded', 2]) fail = false await vi.advanceTimersByTimeAsync(8000) expect(fetchOnce).toHaveBeenCalledTimes(3) expect(statuses.at(-1)).toEqual(['ok', 0]) await vi.advanceTimersByTimeAsync(2000) expect(fetchOnce).toHaveBeenCalledTimes(4) expect(projectSyncDelay(10)).toBe(30000) sync.stop() }) it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => { vi.useFakeTimers() let signal: AbortSignal | undefined const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise(() => {}) }) const statuses: ProjectSyncStatus[] = [] const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status)) sync.start() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS) expect(signal?.aborted).toBe(true) expect(statuses.at(-1)).toBe('degraded') sync.stop() }) it('shows the store sync state as degraded while the list endpoint fails', async () => { vi.useFakeTimers() const previous = route route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init) store.startProjectSync(fakeEnv().port) await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 }) expect(store.getState().projects.map(p => p.id)).toEqual(['host-a']) }) }) describe('RefreshHiddenPause', () => { it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => { vi.useFakeTimers() const env = fakeEnv() const statuses: ProjectSyncStatus[] = [] const fetchOnce = vi.fn(async () => {}) const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status)) sync.start() env.hide() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10) expect(fetchOnce).not.toHaveBeenCalled() expect(statuses.at(-1)).toBe('paused') env.show() await vi.advanceTimersByTimeAsync(0) expect(fetchOnce).toHaveBeenCalledTimes(1) expect(statuses.at(-1)).toBe('ok') sync.stop() }) it('does not start when the page is already hidden', async () => { vi.useFakeTimers() const env = fakeEnv() env.hide() const fetchOnce = vi.fn(async () => {}) const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {}) sync.start() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5) expect(fetchOnce).not.toHaveBeenCalled() sync.stop() }) }) describe('RefreshReconnectCompensate', () => { it('stops while offline and re-checks immediately when the network returns', async () => { vi.useFakeTimers() const env = fakeEnv() const statuses: ProjectSyncStatus[] = [] const fetchOnce = vi.fn(async () => {}) const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status)) sync.start() env.offline() await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5) expect(fetchOnce).not.toHaveBeenCalled() expect(statuses.at(-1)).toBe('offline') env.online() await vi.advanceTimersByTimeAsync(0) expect(fetchOnce).toHaveBeenCalledTimes(1) sync.stop() }) it('re-checks immediately on focus instead of waiting for the next interval', async () => { vi.useFakeTimers() const env = fakeEnv() store.startProjectSync(env.port) projects = [project('host-a'), project('host-b', 'carol')] env.focus() await vi.advanceTimersByTimeAsync(0) expect(store.getState().projects.map(p => p.id)).toContain('host-b') }) it('drops a late list from the previous identity after a reconnect under another account', async () => { vi.useFakeTimers() const env = fakeEnv() const late = deferred() const previous = route route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init) store.startProjectSync(env.port) await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) auth.beginSession() route = previous projects = [project('host-c', 'dave')] await store.refreshProjects() late.resolve(response([project('host-a'), project('alice-secret')])) await vi.advanceTimersByTimeAsync(0) expect(store.getState().projects.map(p => p.id)).toEqual(['host-c']) }) }) function routeScratchSession() { const previous = route route = (path, init) => { if (path === '/api/scratch-sessions' && init?.method === 'POST') { return Promise.resolve(response({ project: project('scratch-1'), conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true } })) } if (path === '/api/conversations/scratch-conv') return Promise.resolve(response({ conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true }, messages: [], runs: [], activeHitl: [], lastSeq: 0 })) return previous(path, init) } } describe('DefaultProjectDisabled', () => { it('does not create a default project when the member has no projects yet', async () => { projects = [] await store.refreshProjects() routeScratchSession() requests = [] await store.createTask() const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path) expect(posts).toEqual(['/api/scratch-sessions']) expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' }) }) it('does not treat a list that has not arrived as empty', async () => { auth.beginSession() const pending = deferred() route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({})) void store.refreshProjects() await expect(store.createTask()).rejects.toThrow(/尚未加载/) expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) pending.resolve(response([])) }) it('points a creator to explicit project creation instead of creating one silently', async () => { const previous = route route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'], linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } })) : previous(path, init) await store.refreshMe() projects = [] await store.refreshProjects() routeScratchSession() requests = [] await store.createTask() expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([]) expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/scratch-sessions']) expect(store.getState().projects).toEqual([]) }) it('surfaces the stage-specific project error before any POST is sent', async () => { projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project] await store.refreshProjects() requests = [] await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/) expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) }) }) describe('AutoDiscover sync ownership on lifecycle events', () => { it('surfaces sync start and stop without leaking listeners across re-starts', () => { const env = fakeEnv() const release: Array<() => void> = [] const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => { const releaseFn = () => {} release.push(releaseFn) env.port.subscribe(handlers) return () => { releaseFn() } } } store.startProjectSync(port) store.stopProjectSync() store.startProjectSync(port) expect(release.length).toBe(2) store.stopProjectSync() }) it('keeps the store sync state idle after a clean stop and ready to re-start', () => { store.startProjectSync(fakeEnv().port) store.stopProjectSync() expect(store.getState().projectSync.status).toBe('idle') expect(store.getState().projectSync.failures).toBe(0) }) it('drops the late response when an identity reset interleaves a sync', async () => { vi.useFakeTimers() const env = fakeEnv() const pending = deferred() const previous = route route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init) store.startProjectSync(env.port) await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) store.resetIdentity() pending.resolve(response([project('host-late')])) await vi.advanceTimersByTimeAsync(0) expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined() store.stopProjectSync() }) })