import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useRecentProjectsSection } from '@features/recent-projects/renderer/hooks/useRecentProjectsSection'; import { __resetRecentProjectsClientCacheForTests, loadRecentProjectsWithClientCache, } from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; import { invalidateContextScopedRequestEpoch, resetContextScopedRequestEpochForTests, } from '@renderer/store/utils/contextScopedRequestEpoch'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DashboardRecentProject, DashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; import type { TeamSummary } from '@shared/types'; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const apiMock = vi.hoisted(() => ({ getDashboardRecentProjects: vi.fn(), teams: { aliveList: vi.fn(), }, config: { addCustomProjectPath: vi.fn(), selectFolders: vi.fn(), }, openPath: vi.fn(), })); const storeState = vi.hoisted(() => ({ globalTasks: [], globalTasksInitialized: false, globalTasksLoading: false, fetchAllTasks: vi.fn(), teams: [] as TeamSummary[], activeContextId: 'local', provisioningRuns: {}, currentProvisioningRunIdByTeam: {}, provisioningSnapshotByTeam: {}, repositoryGroups: [], fetchRepositoryGroups: vi.fn(), openTeamsTab: vi.fn(), fetchSessionsInitial: vi.fn(), })); vi.mock('@renderer/api', () => ({ api: apiMock, isElectronMode: () => true, })); vi.mock('@renderer/store', () => { const useStore = Object.assign( (selector: (state: typeof storeState) => unknown) => selector(storeState), { getState: () => storeState, setState: vi.fn((patch: Partial) => { Object.assign(storeState, patch); }), } ); return { useStore }; }); vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector, })); function deferred(): { promise: Promise; resolve: (value: T) => void; } { let resolve!: (value: T) => void; const promise = new Promise((promiseResolve) => { resolve = promiseResolve; }); return { promise, resolve }; } function project(id: string, projectPath = `/tmp/${id}`): DashboardRecentProject { return { id, name: id, primaryPath: projectPath, associatedPaths: [projectPath], mostRecentActivity: Date.parse('2026-04-14T12:00:00.000Z'), providerIds: ['anthropic'], source: 'claude', openTarget: { type: 'synthetic-path', path: projectPath, }, }; } function payload(id: string, projectPath?: string): DashboardRecentProjectsPayload { return { projects: [project(id, projectPath)], degraded: false, }; } function team(teamName: string, projectPath: string): TeamSummary { return { teamName, displayName: teamName, description: '', memberCount: 1, taskCount: 0, lastActivity: null, projectPath, }; } async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } describe('useRecentProjectsSection', () => { let host: HTMLDivElement; let root: Root; let latest: ReturnType | null; function Harness(): React.JSX.Element | null { latest = useRecentProjectsSection('', 20); return null; } async function renderHarness(): Promise { await act(async () => { root.render(React.createElement(Harness)); await flushPromises(); }); } beforeEach(() => { __resetRecentProjectsClientCacheForTests(); resetContextScopedRequestEpochForTests(); vi.clearAllMocks(); latest = null; storeState.globalTasks = []; storeState.globalTasksInitialized = false; storeState.globalTasksLoading = false; storeState.teams = []; storeState.activeContextId = 'local'; storeState.provisioningRuns = {}; storeState.currentProvisioningRunIdByTeam = {}; storeState.provisioningSnapshotByTeam = {}; storeState.repositoryGroups = []; apiMock.teams.aliveList.mockResolvedValue([]); host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host); }); afterEach(() => { act(() => { root.unmount(); }); host.remove(); __resetRecentProjectsClientCacheForTests(); resetContextScopedRequestEpochForTests(); }); it('ignores stale recent-project loads after the context epoch changes back to the same id', async () => { const oldLocalRequest = deferred(); const sshRequest = deferred(); const freshLocalRequest = deferred(); apiMock.getDashboardRecentProjects .mockReturnValueOnce(oldLocalRequest.promise) .mockReturnValueOnce(sshRequest.promise) .mockReturnValueOnce(freshLocalRequest.promise); await renderHarness(); expect(apiMock.getDashboardRecentProjects).toHaveBeenCalledTimes(1); invalidateContextScopedRequestEpoch(); storeState.activeContextId = 'ssh-dev'; await renderHarness(); expect(apiMock.getDashboardRecentProjects).toHaveBeenCalledTimes(2); invalidateContextScopedRequestEpoch(); storeState.activeContextId = 'local'; await renderHarness(); expect(apiMock.getDashboardRecentProjects).toHaveBeenCalledTimes(3); await act(async () => { oldLocalRequest.resolve(payload('old-local')); await oldLocalRequest.promise; await flushPromises(); }); expect(latest?.cards.map((card) => card.name)).toEqual([]); expect(latest?.loading).toBe(true); await act(async () => { freshLocalRequest.resolve(payload('fresh-local')); await freshLocalRequest.promise; await flushPromises(); }); expect(latest?.cards.map((card) => card.name)).toEqual(['fresh-local']); expect(latest?.loading).toBe(false); }); it('cancels stale alive-list responses when only the active context changes', async () => { const oldAliveRequest = deferred(); const sshAliveRequest = deferred(); const freshAliveRequest = deferred(); await loadRecentProjectsWithClientCache('local', () => Promise.resolve(payload('alpha')), { force: true, }); apiMock.getDashboardRecentProjects.mockResolvedValue(payload('alpha')); apiMock.teams.aliveList .mockReturnValueOnce(oldAliveRequest.promise) .mockReturnValueOnce(sshAliveRequest.promise) .mockReturnValueOnce(freshAliveRequest.promise); storeState.teams = [team('old-team', '/tmp/alpha'), team('fresh-team', '/tmp/alpha')]; await renderHarness(); expect(apiMock.teams.aliveList).toHaveBeenCalledTimes(1); invalidateContextScopedRequestEpoch(); storeState.activeContextId = 'ssh-dev'; await renderHarness(); expect(apiMock.teams.aliveList).toHaveBeenCalledTimes(2); invalidateContextScopedRequestEpoch(); storeState.activeContextId = 'local'; await renderHarness(); expect(apiMock.teams.aliveList).toHaveBeenCalledTimes(3); await act(async () => { freshAliveRequest.resolve(['fresh-team']); await freshAliveRequest.promise; await flushPromises(); }); expect(latest?.cards[0]?.activeTeams?.map((activeTeam) => activeTeam.teamName)).toEqual([ 'fresh-team', ]); await act(async () => { oldAliveRequest.resolve(['old-team']); await oldAliveRequest.promise; await flushPromises(); }); expect(latest?.cards[0]?.activeTeams?.map((activeTeam) => activeTeam.teamName)).toEqual([ 'fresh-team', ]); }); });