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 { api } from '@renderer/api';
|
||||||
|
|
||||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -98,6 +98,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
focusedPaneId: 'pane-default',
|
focusedPaneId: 'pane-default',
|
||||||
},
|
},
|
||||||
...getFullResetState(),
|
...getFullResetState(),
|
||||||
|
...getContextScopedTeamResetState(),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
@ -107,6 +108,8 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
const state = get();
|
const state = get();
|
||||||
void state.fetchProjects();
|
void state.fetchProjects();
|
||||||
void state.fetchRepositoryGroups();
|
void state.fetchRepositoryGroups();
|
||||||
|
void state.fetchTeams();
|
||||||
|
void state.fetchAllTasks();
|
||||||
|
|
||||||
// Save connection config (without password) for form pre-fill on next launch
|
// Save connection config (without password) for form pre-fill on next launch
|
||||||
const saved: SshLastConnection = {
|
const saved: SshLastConnection = {
|
||||||
|
|
@ -156,12 +159,15 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
focusedPaneId: 'pane-default',
|
focusedPaneId: 'pane-default',
|
||||||
},
|
},
|
||||||
...getFullResetState(),
|
...getFullResetState(),
|
||||||
|
...getContextScopedTeamResetState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch local data
|
// Re-fetch local data
|
||||||
const state = get();
|
const state = get();
|
||||||
void state.fetchProjects();
|
void state.fetchProjects();
|
||||||
void state.fetchRepositoryGroups();
|
void state.fetchRepositoryGroups();
|
||||||
|
void state.fetchTeams();
|
||||||
|
void state.fetchAllTasks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
set({ connectionError: message });
|
set({ connectionError: message });
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { api } from '@renderer/api';
|
||||||
import { contextStorage } from '@renderer/services/contextStorage';
|
import { contextStorage } from '@renderer/services/contextStorage';
|
||||||
import { draftStorage } from '@renderer/services/draftStorage';
|
import { draftStorage } from '@renderer/services/draftStorage';
|
||||||
|
|
||||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { ContextSnapshot } from '@renderer/services/contextStorage';
|
import type { ContextSnapshot } from '@renderer/services/contextStorage';
|
||||||
|
|
@ -47,6 +47,7 @@ export interface ContextSlice {
|
||||||
function getEmptyContextState(): Partial<AppState> {
|
function getEmptyContextState(): Partial<AppState> {
|
||||||
return {
|
return {
|
||||||
...getFullResetState(),
|
...getFullResetState(),
|
||||||
|
...getContextScopedTeamResetState(),
|
||||||
projects: [],
|
projects: [],
|
||||||
projectsLoading: false,
|
projectsLoading: false,
|
||||||
projectsInitialized: false,
|
projectsInitialized: false,
|
||||||
|
|
@ -259,11 +260,17 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
|
|
||||||
// Fetch active context from main process
|
// Fetch active context from main process
|
||||||
const activeContextId = await api.context.getActive();
|
const activeContextId = await api.context.getActive();
|
||||||
|
const previousContextId = get().activeContextId;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
...(activeContextId !== previousContextId ? getContextScopedTeamResetState() : {}),
|
||||||
contextSnapshotsReady: true,
|
contextSnapshotsReady: true,
|
||||||
activeContextId,
|
activeContextId,
|
||||||
});
|
});
|
||||||
|
if (activeContextId !== previousContextId) {
|
||||||
|
void get().fetchTeams();
|
||||||
|
void get().fetchAllTasks();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch available contexts
|
// Fetch available contexts
|
||||||
await get().fetchAvailableContexts();
|
await get().fetchAvailableContexts();
|
||||||
|
|
@ -317,6 +324,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
// Step 2: Apply cached snapshot immediately for instant visual feedback
|
// Step 2: Apply cached snapshot immediately for instant visual feedback
|
||||||
if (targetSnapshot) {
|
if (targetSnapshot) {
|
||||||
set({
|
set({
|
||||||
|
...getContextScopedTeamResetState(),
|
||||||
projects: targetSnapshot.projects,
|
projects: targetSnapshot.projects,
|
||||||
projectsLoading: false,
|
projectsLoading: false,
|
||||||
projectsInitialized: true,
|
projectsInitialized: true,
|
||||||
|
|
@ -402,6 +410,8 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
|
|
||||||
// Step 4: Fetch notifications in background
|
// Step 4: Fetch notifications in background
|
||||||
void get().fetchNotifications();
|
void get().fetchNotifications();
|
||||||
|
void get().fetchTeams();
|
||||||
|
void get().fetchAllTasks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[contextSlice] Failed to switch context:', error);
|
console.error('[contextSlice] Failed to switch context:', error);
|
||||||
// Do NOT leave in broken state
|
// Do NOT leave in broken state
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,7 @@ const pendingFreshTeamMessagesHeadRefreshes = new Set<string>();
|
||||||
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
|
const inFlightTeamMemberActivityMetaRequests = new Map<string, Promise<void>>();
|
||||||
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
|
const pendingFreshTeamMemberActivityMetaRefreshes = new Set<string>();
|
||||||
const pendingTeamPendingReplyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pendingTeamPendingReplyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
let latestTeamsFetchRequestId = 0;
|
||||||
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
let inFlightGlobalTasksRefresh: Promise<void> | null = null;
|
||||||
let pendingFreshGlobalTasksRefresh = false;
|
let pendingFreshGlobalTasksRefresh = false;
|
||||||
interface RefreshTeamDataOptions {
|
interface RefreshTeamDataOptions {
|
||||||
|
|
@ -286,6 +287,9 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
pendingTeamPendingReplyRefreshTimers.clear();
|
pendingTeamPendingReplyRefreshTimers.clear();
|
||||||
|
latestTeamsFetchRequestId = 0;
|
||||||
|
inFlightGlobalTasksRefresh = null;
|
||||||
|
pendingFreshGlobalTasksRefresh = false;
|
||||||
clearAllPendingReplyRefreshWaits();
|
clearAllPendingReplyRefreshWaits();
|
||||||
clearAllLastResolvedTeamDataRefreshes();
|
clearAllLastResolvedTeamDataRefreshes();
|
||||||
clearAllTeamLocalStateEpochs();
|
clearAllTeamLocalStateEpochs();
|
||||||
|
|
@ -1401,6 +1405,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Only effective during initial load (when teamsLoading is set to true below).
|
// Only effective during initial load (when teamsLoading is set to true below).
|
||||||
// Refreshes are already serialized by the throttle timer in onTeamChange.
|
// Refreshes are already serialized by the throttle timer in onTeamChange.
|
||||||
if (get().teamsLoading) return;
|
if (get().teamsLoading) return;
|
||||||
|
const requestContextId = get().activeContextId;
|
||||||
|
const requestId = ++latestTeamsFetchRequestId;
|
||||||
// Only show loading spinner on initial load — avoids flickering when refreshing
|
// Only show loading spinner on initial load — avoids flickering when refreshing
|
||||||
const isInitialLoad = get().teams.length === 0;
|
const isInitialLoad = get().teams.length === 0;
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
|
|
@ -1412,6 +1418,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
'fetchTeams'
|
'fetchTeams'
|
||||||
);
|
);
|
||||||
|
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const teamByName: Record<string, TeamSummary> = {};
|
const teamByName: Record<string, TeamSummary> = {};
|
||||||
const teamBySessionId: Record<string, TeamSummary> = {};
|
const teamBySessionId: Record<string, TeamSummary> = {};
|
||||||
for (const team of teams) {
|
for (const team of teams) {
|
||||||
|
|
@ -1444,6 +1453,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (get().activeContextId !== requestContextId || latestTeamsFetchRequestId !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// On refresh failure, keep existing teams visible
|
// On refresh failure, keep existing teams visible
|
||||||
set({
|
set({
|
||||||
teamsLoading: false,
|
teamsLoading: false,
|
||||||
|
|
@ -1475,15 +1487,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
set({ globalTasksLoading: true, globalTasksError: null });
|
set({ globalTasksLoading: true, globalTasksError: null });
|
||||||
}
|
}
|
||||||
|
const requestContextId = get().activeContextId;
|
||||||
const oldTasks = get().globalTasks;
|
const oldTasks = get().globalTasks;
|
||||||
const wasFirst = consumeFirstGlobalTasksFetchFlag();
|
|
||||||
try {
|
try {
|
||||||
const tasks = await withTimeout(
|
const tasks = await withTimeout(
|
||||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||||
TEAM_FETCH_TIMEOUT_MS,
|
TEAM_FETCH_TIMEOUT_MS,
|
||||||
'fetchAllTasks'
|
'fetchAllTasks'
|
||||||
);
|
);
|
||||||
|
if (get().activeContextId !== requestContextId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const notificationState = get();
|
const notificationState = get();
|
||||||
|
const wasFirst = consumeFirstGlobalTasksFetchFlag();
|
||||||
processGlobalTaskNotifications({
|
processGlobalTaskNotifications({
|
||||||
oldTasks,
|
oldTasks,
|
||||||
newTasks: tasks,
|
newTasks: tasks,
|
||||||
|
|
@ -1499,6 +1515,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
globalTasksError: null,
|
globalTasksError: null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (get().activeContextId !== requestContextId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
globalTasksLoading: false,
|
globalTasksLoading: false,
|
||||||
globalTasksInitialized: true,
|
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).
|
* Full state reset (session + project + repository + conversation).
|
||||||
* Used when closing all tabs or resetting to initial state.
|
* 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