263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
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<typeof storeState>) => {
|
|
Object.assign(storeState, patch);
|
|
}),
|
|
}
|
|
);
|
|
return { useStore };
|
|
});
|
|
|
|
vi.mock('zustand/react/shallow', () => ({
|
|
useShallow: <T,>(selector: T) => selector,
|
|
}));
|
|
|
|
function deferred<T>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
} {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((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<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe('useRecentProjectsSection', () => {
|
|
let host: HTMLDivElement;
|
|
let root: Root;
|
|
let latest: ReturnType<typeof useRecentProjectsSection> | null;
|
|
|
|
function Harness(): React.JSX.Element | null {
|
|
latest = useRecentProjectsSection('', 20);
|
|
return null;
|
|
}
|
|
|
|
async function renderHarness(): Promise<void> {
|
|
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<DashboardRecentProjectsPayload>();
|
|
const sshRequest = deferred<DashboardRecentProjectsPayload>();
|
|
const freshLocalRequest = deferred<DashboardRecentProjectsPayload>();
|
|
|
|
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<string[]>();
|
|
const sshAliveRequest = deferred<string[]>();
|
|
const freshAliveRequest = deferred<string[]>();
|
|
|
|
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',
|
|
]);
|
|
});
|
|
});
|