import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { createProjectSlice } from '../../../src/renderer/store/slices/projectSlice'; import { createRepositorySlice } from '../../../src/renderer/store/slices/repositorySlice'; import { invalidateContextScopedRequestEpoch, resetContextScopedRequestEpochForTests, } from '../../../src/renderer/store/utils/contextScopedRequestEpoch'; import type { AppState } from '../../../src/renderer/store/types'; import type { Project, RepositoryGroup } from '../../../src/renderer/types/data'; const apiMock = vi.hoisted(() => ({ getProjects: vi.fn(), getRepositoryGroups: vi.fn(), })); vi.mock('@renderer/api', () => ({ api: apiMock, })); function deferred(): { promise: Promise; resolve: (value: T) => void; } { let resolve!: (value: T) => void; const promise = new Promise((innerResolve) => { resolve = innerResolve; }); return { promise, resolve }; } function project(id: string, path = `/${id}`): Project { return { id, path, name: id, sessions: [], totalSessions: 0, createdAt: 0, mostRecentSession: 0, }; } function repositoryGroup(id: string, path = `/${id}`): RepositoryGroup { return { id, identity: null, name: id, totalSessions: 0, mostRecentSession: 0, worktrees: [ { id: `${id}-worktree`, path, name: id, isMainWorktree: true, source: 'unknown', sessions: [], totalSessions: 0, createdAt: 0, mostRecentSession: 0, }, ], }; } function createProjectRepositoryStore() { return create()((set, get, store) => ({ ...createProjectSlice(set as never, get as never, store as never), ...createRepositorySlice(set as never, get as never, store as never), activeContextId: 'local', activeProjectId: null, fetchSessionsInitial: vi.fn(async () => undefined), }) as unknown as AppState ); } describe('project and repository context races', () => { beforeEach(() => { resetContextScopedRequestEpochForTests(); apiMock.getProjects.mockReset(); apiMock.getRepositoryGroups.mockReset(); }); afterEach(() => { resetContextScopedRequestEpochForTests(); vi.restoreAllMocks(); }); it('applies current-context project loads', async () => { const store = createProjectRepositoryStore(); apiMock.getProjects.mockResolvedValue([project('current-project')]); await store.getState().fetchProjects(); expect(store.getState().projects).toEqual([project('current-project')]); expect(store.getState().projectsInitialized).toBe(true); expect(store.getState().projectsLoading).toBe(false); }); it('ignores project loads resolved for a previous context', async () => { const store = createProjectRepositoryStore(); const localProjects = deferred(); const currentProjects = [project('ssh-project', '/ssh/project')]; apiMock.getProjects.mockReturnValueOnce(localProjects.promise); const fetchPromise = store.getState().fetchProjects(); expect(store.getState().projectsLoading).toBe(true); store.setState({ activeContextId: 'ssh-dev', projects: currentProjects, projectsLoading: false, projectsInitialized: true, }); localProjects.resolve([project('local-project', '/local/project')]); await fetchPromise; expect(store.getState().projects).toBe(currentProjects); expect(store.getState().projectsLoading).toBe(false); }); it('ignores project loads resolved before a same-context epoch reset', async () => { const store = createProjectRepositoryStore(); const oldLocalProjects = deferred(); const currentProjects = [project('fresh-local-project', '/fresh-local/project')]; apiMock.getProjects.mockReturnValueOnce(oldLocalProjects.promise); const fetchPromise = store.getState().fetchProjects(); expect(store.getState().projectsLoading).toBe(true); invalidateContextScopedRequestEpoch(); store.setState({ activeContextId: 'local', projects: currentProjects, projectsLoading: false, projectsInitialized: true, }); oldLocalProjects.resolve([project('old-local-project', '/old-local/project')]); await fetchPromise; expect(store.getState().projects).toBe(currentProjects); expect(store.getState().projectsLoading).toBe(false); }); it('applies current-context repository group loads', async () => { const store = createProjectRepositoryStore(); apiMock.getRepositoryGroups.mockResolvedValue([repositoryGroup('current-repo')]); await store.getState().fetchRepositoryGroups(); expect(store.getState().repositoryGroups).toEqual([repositoryGroup('current-repo')]); expect(store.getState().repositoryGroupsInitialized).toBe(true); expect(store.getState().repositoryGroupsLoading).toBe(false); }); it('ignores repository group loads resolved for a previous context', async () => { const store = createProjectRepositoryStore(); const localGroups = deferred(); const currentGroups = [repositoryGroup('ssh-repo', '/ssh/repo')]; apiMock.getRepositoryGroups.mockReturnValueOnce(localGroups.promise); const fetchPromise = store.getState().fetchRepositoryGroups(); expect(store.getState().repositoryGroupsLoading).toBe(true); store.setState({ activeContextId: 'ssh-dev', repositoryGroups: currentGroups, repositoryGroupsLoading: false, repositoryGroupsInitialized: true, }); localGroups.resolve([repositoryGroup('local-repo', '/local/repo')]); await fetchPromise; expect(store.getState().repositoryGroups).toBe(currentGroups); expect(store.getState().repositoryGroupsLoading).toBe(false); }); it('ignores repository group loads resolved before a same-context epoch reset', async () => { const store = createProjectRepositoryStore(); const oldLocalGroups = deferred(); const currentGroups = [repositoryGroup('fresh-local-repo', '/fresh-local/repo')]; apiMock.getRepositoryGroups.mockReturnValueOnce(oldLocalGroups.promise); const fetchPromise = store.getState().fetchRepositoryGroups(); expect(store.getState().repositoryGroupsLoading).toBe(true); invalidateContextScopedRequestEpoch(); store.setState({ activeContextId: 'local', repositoryGroups: currentGroups, repositoryGroupsLoading: false, repositoryGroupsInitialized: true, }); oldLocalGroups.resolve([repositoryGroup('old-local-repo', '/old-local/repo')]); await fetchPromise; expect(store.getState().repositoryGroups).toBe(currentGroups); expect(store.getState().repositoryGroupsLoading).toBe(false); }); });