Files
Agent-System-Frontend/src/store.test.ts
T
weichenandCursor 6e611c5de0
governance / governance (pull_request) Successful in 19s
ci / test-and-build (pull_request) Successful in 49s
Keep the scratch flag out of the Core-generated contracts
Why: src/contracts.ts is regenerated from Agent-System-Core by the Dev
composite; the scratch field added there is dropped on regeneration and
tsc then fails in make build.

Evidence: contracts.ts equals the Core-generated output; npx tsc -b
--noEmit, npx vitest run (685 tests) and check-knip pass locally (SELF).

Impact: ConversationView (Conversation & { scratch?: boolean }) in
api.ts carries the server-local flag, mirroring the Backend
conversationView; no behavior change.

Refs: Agent-System-Frontend#123, Agent-System-Dev#691;
agent-exec receipt u3f-run (cursor, success)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 06:06:01 +00:00

408 lines
17 KiB
TypeScript

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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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<Response>
beforeEach(async () => {
projects = [project('host-a')]
tasks = [task('conv-a', 'host-a')]
requests = []
const saved = new Map<string, string>()
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<number> {
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<typeof deferred<void>>[] = []
const fetchOnce = vi.fn(() => {
concurrent++; peak = Math.max(peak, concurrent)
const call = deferred<void>()
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<void>()
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<void>(() => {}) })
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<Response>()
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<Response>()
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<Response>()
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()
})
})