fix(context): reset team caches on context changes
This commit is contained in:
parent
96478a604f
commit
e46868b6d7
6 changed files with 576 additions and 3 deletions
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
|
|
@ -98,6 +98,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
...getFullResetState(),
|
||||
...getContextScopedTeamResetState(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
|
@ -107,6 +108,8 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
const state = get();
|
||||
void state.fetchProjects();
|
||||
void state.fetchRepositoryGroups();
|
||||
void state.fetchTeams();
|
||||
void state.fetchAllTasks();
|
||||
|
||||
// Save connection config (without password) for form pre-fill on next launch
|
||||
const saved: SshLastConnection = {
|
||||
|
|
@ -156,12 +159,15 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
...getFullResetState(),
|
||||
...getContextScopedTeamResetState(),
|
||||
});
|
||||
|
||||
// Re-fetch local data
|
||||
const state = get();
|
||||
void state.fetchProjects();
|
||||
void state.fetchRepositoryGroups();
|
||||
void state.fetchTeams();
|
||||
void state.fetchAllTasks();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
set({ connectionError: message });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { api } from '@renderer/api';
|
|||
import { contextStorage } from '@renderer/services/contextStorage';
|
||||
import { draftStorage } from '@renderer/services/draftStorage';
|
||||
|
||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { ContextSnapshot } from '@renderer/services/contextStorage';
|
||||
|
|
@ -47,6 +47,7 @@ export interface ContextSlice {
|
|||
function getEmptyContextState(): Partial<AppState> {
|
||||
return {
|
||||
...getFullResetState(),
|
||||
...getContextScopedTeamResetState(),
|
||||
projects: [],
|
||||
projectsLoading: false,
|
||||
projectsInitialized: false,
|
||||
|
|
@ -259,11 +260,17 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
|||
|
||||
// Fetch active context from main process
|
||||
const activeContextId = await api.context.getActive();
|
||||
const previousContextId = get().activeContextId;
|
||||
|
||||
set({
|
||||
...(activeContextId !== previousContextId ? getContextScopedTeamResetState() : {}),
|
||||
contextSnapshotsReady: true,
|
||||
activeContextId,
|
||||
});
|
||||
if (activeContextId !== previousContextId) {
|
||||
void get().fetchTeams();
|
||||
void get().fetchAllTasks();
|
||||
}
|
||||
|
||||
// Fetch available contexts
|
||||
await get().fetchAvailableContexts();
|
||||
|
|
@ -317,6 +324,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
|||
// Step 2: Apply cached snapshot immediately for instant visual feedback
|
||||
if (targetSnapshot) {
|
||||
set({
|
||||
...getContextScopedTeamResetState(),
|
||||
projects: targetSnapshot.projects,
|
||||
projectsLoading: false,
|
||||
projectsInitialized: true,
|
||||
|
|
@ -402,6 +410,8 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
|||
|
||||
// Step 4: Fetch notifications in background
|
||||
void get().fetchNotifications();
|
||||
void get().fetchTeams();
|
||||
void get().fetchAllTasks();
|
||||
} catch (error) {
|
||||
console.error('[contextSlice] Failed to switch context:', error);
|
||||
// Do NOT leave in broken state
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set<string>();
|
|||
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
|
||||
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
|
||||
const pendingTeamPendingReplyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let latestTeamsFetchRequestId = 0;
|
||||
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
||||
let pendingFreshGlobalTasksRefresh = false;
|
||||
interface RefreshTeamDataOptions {
|
||||
|
|
@ -286,6 +287,9 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
|||
clearTimeout(timer);
|
||||
}
|
||||
pendingTeamPendingReplyRefreshTimers.clear();
|
||||
latestTeamsFetchRequestId = 0;
|
||||
inFlightGlobalTasksRefresh = null;
|
||||
pendingFreshGlobalTasksRefresh = false;
|
||||
clearAllPendingReplyRefreshWaits();
|
||||
clearAllLastResolvedTeamDataRefreshes();
|
||||
clearAllTeamLocalStateEpochs();
|
||||
|
|
@ -1401,6 +1405,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
// Only effective during initial load (when teamsLoading is set to true below).
|
||||
// Refreshes are already serialized by the throttle timer in onTeamChange.
|
||||
if (get().teamsLoading) return;
|
||||
const requestContextId = get().activeContextId;
|
||||
const requestId = ++latestTeamsFetchRequestId;
|
||||
// Only show loading spinner on initial load — avoids flickering when refreshing
|
||||
const isInitialLoad = get().teams.length === 0;
|
||||
if (isInitialLoad) {
|
||||
|
|
@ -1412,6 +1418,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
TEAM_FETCH_TIMEOUT_MS,
|
||||
'fetchTeams'
|
||||
);
|
||||
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
const teamByName: Record<string, TeamSummary> = {};
|
||||
const teamBySessionId: Record<string, TeamSummary> = {};
|
||||
for (const team of teams) {
|
||||
|
|
@ -1444,6 +1453,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
// On refresh failure, keep existing teams visible
|
||||
set({
|
||||
teamsLoading: false,
|
||||
|
|
@ -1475,15 +1487,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (isInitialLoad) {
|
||||
set({ globalTasksLoading: true, globalTasksError: null });
|
||||
}
|
||||
const requestContextId = get().activeContextId;
|
||||
const oldTasks = get().globalTasks;
|
||||
const wasFirst = consumeFirstGlobalTasksFetchFlag();
|
||||
try {
|
||||
const tasks = await withTimeout(
|
||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||
TEAM_FETCH_TIMEOUT_MS,
|
||||
'fetchAllTasks'
|
||||
);
|
||||
if (get().activeContextId !== requestContextId) {
|
||||
continue;
|
||||
}
|
||||
const notificationState = get();
|
||||
const wasFirst = consumeFirstGlobalTasksFetchFlag();
|
||||
processGlobalTaskNotifications({
|
||||
oldTasks,
|
||||
newTasks: tasks,
|
||||
|
|
@ -1499,6 +1515,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
globalTasksError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (get().activeContextId !== requestContextId) {
|
||||
continue;
|
||||
}
|
||||
set({
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: true,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,74 @@ export function getProjectSelectionResetState(): Partial<AppState> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset team/task data that belongs to the active main-process context.
|
||||
* These caches are populated through context-aware IPC calls and must not
|
||||
* survive a local/SSH context switch.
|
||||
*/
|
||||
export function getContextScopedTeamResetState(): Partial<AppState> {
|
||||
return {
|
||||
teams: [],
|
||||
teamByName: {},
|
||||
teamBySessionId: {},
|
||||
branchByPath: {},
|
||||
teamsLoading: false,
|
||||
teamsError: null,
|
||||
globalTasks: [],
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: false,
|
||||
globalTasksError: null,
|
||||
globalTaskDetail: null,
|
||||
pendingMemberProfile: null,
|
||||
pendingTeamSectionFocus: null,
|
||||
pendingReviewRequest: null,
|
||||
selectedTeamName: null,
|
||||
selectedTeamData: null,
|
||||
teamDataCacheByName: {},
|
||||
selectedTeamLoading: false,
|
||||
selectedTeamLoadNonce: 0,
|
||||
selectedTeamError: null,
|
||||
sendingMessage: false,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
lastSendMessageResult: null,
|
||||
reviewActionError: null,
|
||||
graphLayoutModeByTeam: {},
|
||||
gridOwnerOrderByTeam: {},
|
||||
slotAssignmentsByTeam: {},
|
||||
teamMessagesByName: {},
|
||||
memberActivityMetaByTeam: {},
|
||||
graphLayoutSessionByTeam: {},
|
||||
provisioningRuns: {},
|
||||
provisioningSnapshotByTeam: {},
|
||||
currentProvisioningRunIdByTeam: {},
|
||||
currentRuntimeRunIdByTeam: {},
|
||||
ignoredProvisioningRunIds: {},
|
||||
ignoredRuntimeRunIds: {},
|
||||
provisioningStartedAtFloorByTeam: {},
|
||||
leadActivityByTeam: {},
|
||||
leadContextByTeam: {},
|
||||
activeTaskLogActivityByTeam: {},
|
||||
activeToolsByTeam: {},
|
||||
finishedVisibleByTeam: {},
|
||||
toolHistoryByTeam: {},
|
||||
memberSpawnStatusesByTeam: {},
|
||||
memberSpawnSnapshotsByTeam: {},
|
||||
teamAgentRuntimeByTeam: {},
|
||||
provisioningErrorByTeam: {},
|
||||
crossTeamTargets: [],
|
||||
crossTeamTargetsLoading: false,
|
||||
kanbanFilterQuery: null,
|
||||
addingComment: false,
|
||||
addCommentError: null,
|
||||
deletedTasks: [],
|
||||
deletedTasksLoading: false,
|
||||
pendingApprovals: [],
|
||||
resolvedApprovals: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full state reset (session + project + repository + conversation).
|
||||
* Used when closing all tabs or resetting to initial state.
|
||||
|
|
|
|||
304
test/renderer/store/contextSliceTeamReset.test.ts
Normal file
304
test/renderer/store/contextSliceTeamReset.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createTestStore } from './storeTestUtils';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
context: {
|
||||
switch: vi.fn(async () => undefined),
|
||||
list: vi.fn(async () => [{ id: 'local', type: 'local' }]),
|
||||
getActive: vi.fn(async () => 'local'),
|
||||
onChanged: vi.fn(() => () => undefined),
|
||||
},
|
||||
getProjects: vi.fn(async (): Promise<unknown[]> => []),
|
||||
getRepositoryGroups: vi.fn(async (): Promise<unknown[]> => []),
|
||||
notifications: {
|
||||
get: vi.fn(async () => ({
|
||||
notifications: [],
|
||||
total: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
hasMore: false,
|
||||
})),
|
||||
},
|
||||
teams: {
|
||||
list: vi.fn(async () => []),
|
||||
getAllTasks: vi.fn(async () => []),
|
||||
showMessageNotification: vi.fn(async () => undefined),
|
||||
},
|
||||
ssh: {
|
||||
connect: vi.fn(async () => ({ state: 'connected', host: 'dev', error: null })),
|
||||
disconnect: vi.fn(async () => ({ state: 'disconnected', host: null, error: null })),
|
||||
saveLastConnection: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const contextStorageMock = vi.hoisted(() => ({
|
||||
saveSnapshot: vi.fn(async () => undefined),
|
||||
loadSnapshot: vi.fn(),
|
||||
cleanupExpired: vi.fn(async () => undefined),
|
||||
isAvailable: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const draftStorageMock = vi.hoisted(() => ({
|
||||
cleanupExpired: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/services/contextStorage', () => ({
|
||||
contextStorage: contextStorageMock,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/services/draftStorage', () => ({
|
||||
draftStorage: draftStorageMock,
|
||||
}));
|
||||
|
||||
function targetSnapshot() {
|
||||
return {
|
||||
projects: [
|
||||
{
|
||||
id: 'ssh-project',
|
||||
name: 'SSH Project',
|
||||
path: '/ssh/project',
|
||||
sessions: [],
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
},
|
||||
],
|
||||
selectedProjectId: null,
|
||||
repositoryGroups: [],
|
||||
selectedRepositoryId: null,
|
||||
selectedWorktreeId: null,
|
||||
viewMode: 'flat' as const,
|
||||
sessions: [],
|
||||
selectedSessionId: null,
|
||||
sessionsCursor: null,
|
||||
sessionsHasMore: false,
|
||||
sessionsTotalCount: 0,
|
||||
pinnedSessionIds: [],
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
activeProjectId: null,
|
||||
paneLayout: {
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-default',
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
},
|
||||
],
|
||||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
sidebarCollapsed: false,
|
||||
_metadata: {
|
||||
contextId: 'ssh-dev',
|
||||
capturedAt: Date.now(),
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('context slice team/task reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
contextStorageMock.loadSnapshot.mockResolvedValue(targetSnapshot());
|
||||
apiMock.context.getActive.mockResolvedValue('local');
|
||||
apiMock.getProjects.mockResolvedValue(targetSnapshot().projects);
|
||||
apiMock.getRepositoryGroups.mockResolvedValue([]);
|
||||
apiMock.teams.list.mockResolvedValue([]);
|
||||
apiMock.teams.getAllTasks.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('drops previous-context team and task caches before refreshing the target context', async () => {
|
||||
const store = createTestStore();
|
||||
store.setState({
|
||||
activeContextId: 'local',
|
||||
teams: [
|
||||
{
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
],
|
||||
teamByName: {
|
||||
'local-team': {
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
},
|
||||
teamBySessionId: {},
|
||||
globalTasks: [
|
||||
{
|
||||
id: 'local-task',
|
||||
subject: 'Local task',
|
||||
status: 'todo',
|
||||
teamName: 'local-team',
|
||||
teamDisplayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
comments: [],
|
||||
},
|
||||
],
|
||||
globalTasksInitialized: true,
|
||||
selectedTeamName: 'local-team',
|
||||
selectedTeamData: { teamName: 'local-team' },
|
||||
teamDataCacheByName: { 'local-team': { teamName: 'local-team' } },
|
||||
} as never);
|
||||
|
||||
await store.getState().switchContext('ssh-dev');
|
||||
|
||||
expect(store.getState().activeContextId).toBe('ssh-dev');
|
||||
expect(store.getState().teams).toEqual([]);
|
||||
expect(store.getState().teamByName).toEqual({});
|
||||
expect(store.getState().globalTasks).toEqual([]);
|
||||
expect(store.getState().selectedTeamName).toBeNull();
|
||||
expect(store.getState().selectedTeamData).toBeNull();
|
||||
expect(store.getState().teamDataCacheByName).toEqual({});
|
||||
expect(apiMock.teams.list).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops previous-context team and task caches when lazy context initialization changes context', async () => {
|
||||
apiMock.context.getActive.mockResolvedValue('ssh-dev');
|
||||
const store = createTestStore();
|
||||
store.setState({
|
||||
activeContextId: 'local',
|
||||
teams: [
|
||||
{
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
],
|
||||
teamByName: {
|
||||
'local-team': {
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
},
|
||||
globalTasks: [
|
||||
{
|
||||
id: 'local-task',
|
||||
subject: 'Local task',
|
||||
status: 'todo',
|
||||
teamName: 'local-team',
|
||||
teamDisplayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
comments: [],
|
||||
},
|
||||
],
|
||||
globalTasksInitialized: true,
|
||||
} as never);
|
||||
|
||||
await store.getState().initializeContextSystem();
|
||||
|
||||
expect(store.getState().activeContextId).toBe('ssh-dev');
|
||||
expect(store.getState().teams).toEqual([]);
|
||||
expect(store.getState().teamByName).toEqual({});
|
||||
expect(store.getState().globalTasks).toEqual([]);
|
||||
expect(apiMock.teams.list).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops previous-context team and task caches on direct SSH connect', async () => {
|
||||
const store = createTestStore();
|
||||
store.setState({
|
||||
activeContextId: 'local',
|
||||
teams: [
|
||||
{
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
],
|
||||
teamByName: {
|
||||
'local-team': {
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
},
|
||||
globalTasks: [
|
||||
{
|
||||
id: 'local-task',
|
||||
subject: 'Local task',
|
||||
status: 'todo',
|
||||
teamName: 'local-team',
|
||||
teamDisplayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
comments: [],
|
||||
},
|
||||
],
|
||||
globalTasksInitialized: true,
|
||||
} as never);
|
||||
|
||||
await store.getState().connectSsh({
|
||||
host: 'dev',
|
||||
port: 22,
|
||||
username: 'me',
|
||||
authMethod: 'privateKey',
|
||||
privateKeyPath: '/tmp/key',
|
||||
});
|
||||
|
||||
expect(store.getState().activeContextId).toBe('ssh-dev');
|
||||
expect(store.getState().teams).toEqual([]);
|
||||
expect(store.getState().teamByName).toEqual({});
|
||||
expect(store.getState().globalTasks).toEqual([]);
|
||||
expect(apiMock.teams.list).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops previous-context team and task caches on direct SSH disconnect', async () => {
|
||||
const store = createTestStore();
|
||||
store.setState({
|
||||
activeContextId: 'ssh-dev',
|
||||
teams: [
|
||||
{
|
||||
teamName: 'ssh-team',
|
||||
displayName: 'SSH Team',
|
||||
projectPath: '/ssh/project',
|
||||
},
|
||||
],
|
||||
teamByName: {
|
||||
'ssh-team': {
|
||||
teamName: 'ssh-team',
|
||||
displayName: 'SSH Team',
|
||||
projectPath: '/ssh/project',
|
||||
},
|
||||
},
|
||||
globalTasks: [
|
||||
{
|
||||
id: 'ssh-task',
|
||||
subject: 'SSH task',
|
||||
status: 'todo',
|
||||
teamName: 'ssh-team',
|
||||
teamDisplayName: 'SSH Team',
|
||||
projectPath: '/ssh/project',
|
||||
comments: [],
|
||||
},
|
||||
],
|
||||
globalTasksInitialized: true,
|
||||
} as never);
|
||||
|
||||
await store.getState().disconnectSsh();
|
||||
|
||||
expect(store.getState().activeContextId).toBe('local');
|
||||
expect(store.getState().teams).toEqual([]);
|
||||
expect(store.getState().teamByName).toEqual({});
|
||||
expect(store.getState().globalTasks).toEqual([]);
|
||||
expect(apiMock.teams.list).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
166
test/renderer/store/teamSliceContextRace.test.ts
Normal file
166
test/renderer/store/teamSliceContextRace.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
__resetTeamSliceModuleStateForTests,
|
||||
createTeamSlice,
|
||||
} from '../../../src/renderer/store/slices/teamSlice';
|
||||
|
||||
import type { AppState } from '../../../src/renderer/store/types';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
teams: {
|
||||
list: vi.fn(),
|
||||
getAllTasks: vi.fn(),
|
||||
showMessageNotification: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
interface TeamSummaryLike {
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
interface GlobalTaskLike {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
teamName: string;
|
||||
teamDisplayName: string;
|
||||
projectPath: string;
|
||||
comments: [];
|
||||
}
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createSliceStore() {
|
||||
return create<AppState>()((set, get, store) =>
|
||||
({
|
||||
...createTeamSlice(set as never, get as never, store as never),
|
||||
activeContextId: 'local',
|
||||
appConfig: null,
|
||||
paneLayout: {
|
||||
focusedPaneId: 'pane-default',
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-default',
|
||||
widthFraction: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
openTab: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
updateTabLabel: vi.fn(),
|
||||
getAllPaneTabs: vi.fn(() => []),
|
||||
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
||||
invalidateTaskChangePresence: vi.fn(),
|
||||
}) as unknown as AppState
|
||||
);
|
||||
}
|
||||
|
||||
describe('team slice context races', () => {
|
||||
beforeEach(() => {
|
||||
__resetTeamSliceModuleStateForTests();
|
||||
apiMock.teams.list.mockReset();
|
||||
apiMock.teams.getAllTasks.mockReset();
|
||||
apiMock.teams.showMessageNotification.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetTeamSliceModuleStateForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ignores a team list response loaded for a previous context', async () => {
|
||||
const store = createSliceStore();
|
||||
const localList = deferred<TeamSummaryLike[]>();
|
||||
apiMock.teams.list.mockReturnValueOnce(localList.promise);
|
||||
|
||||
const fetchPromise = store.getState().fetchTeams();
|
||||
expect(store.getState().teamsLoading).toBe(true);
|
||||
|
||||
store.setState({
|
||||
activeContextId: 'ssh-dev',
|
||||
teams: [],
|
||||
teamByName: {},
|
||||
teamBySessionId: {},
|
||||
teamsLoading: false,
|
||||
});
|
||||
localList.resolve([
|
||||
{
|
||||
teamName: 'local-team',
|
||||
displayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
},
|
||||
]);
|
||||
await fetchPromise;
|
||||
|
||||
expect(store.getState().teams).toEqual([]);
|
||||
expect(store.getState().teamByName).toEqual({});
|
||||
expect(store.getState().teamsLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('reruns a pending global task refresh for the current context instead of applying stale tasks', async () => {
|
||||
const store = createSliceStore();
|
||||
const localTasks = deferred<GlobalTaskLike[]>();
|
||||
apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'ssh-task',
|
||||
subject: 'SSH task',
|
||||
status: 'todo',
|
||||
teamName: 'ssh-team',
|
||||
teamDisplayName: 'SSH Team',
|
||||
projectPath: '/ssh/project',
|
||||
comments: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const firstFetch = store.getState().fetchAllTasks();
|
||||
expect(store.getState().globalTasksLoading).toBe(true);
|
||||
|
||||
store.setState({
|
||||
activeContextId: 'ssh-dev',
|
||||
globalTasks: [],
|
||||
globalTasksLoading: false,
|
||||
globalTasksInitialized: false,
|
||||
});
|
||||
const secondFetch = store.getState().fetchAllTasks();
|
||||
|
||||
localTasks.resolve([
|
||||
{
|
||||
id: 'local-task',
|
||||
subject: 'Local task',
|
||||
status: 'todo',
|
||||
teamName: 'local-team',
|
||||
teamDisplayName: 'Local Team',
|
||||
projectPath: '/local/project',
|
||||
comments: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await Promise.all([firstFetch, secondFetch]);
|
||||
|
||||
expect(apiMock.teams.getAllTasks).toHaveBeenCalledTimes(2);
|
||||
expect(store.getState().globalTasks).toEqual([
|
||||
expect.objectContaining({ id: 'ssh-task', teamName: 'ssh-team' }),
|
||||
]);
|
||||
expect(store.getState().globalTasksInitialized).toBe(true);
|
||||
expect(store.getState().globalTasksLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue