Checkpoint of in-progress work: - renderer: team messages panel/composer, messagesPanelLogic, teamSlice, AnimatedHeightReveal plus their tests - main: runtime process usage-stats caching (ignoreCachedMisses, bounded eviction), alive-run-id helpers, team watch-scope notify wiring Note: the getTeamAgentRuntimeSnapshot rssBytes expectation in TeamAgentLaunchMatrix.safe-e2e is environment-dependent and still red.
665 lines
21 KiB
TypeScript
665 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { create } from 'zustand';
|
|
|
|
import {
|
|
__getTeamScopedTransientStateForTests,
|
|
__resetTeamSliceModuleStateForTests,
|
|
createTeamSlice,
|
|
} from '../../../src/renderer/store/slices/teamSlice';
|
|
import { invalidateTeamLocalStateEpoch } from '../../../src/renderer/store/team/teamLocalStateEpoch';
|
|
import { invalidateContextScopedRequestEpoch } from '../../../src/renderer/store/utils/contextScopedRequestEpoch';
|
|
|
|
import type { AppState } from '../../../src/renderer/store/types';
|
|
|
|
const apiMock = vi.hoisted(() => ({
|
|
teams: {
|
|
list: vi.fn(),
|
|
getAllTasks: vi.fn(),
|
|
getData: vi.fn(),
|
|
getMessagesPage: vi.fn(),
|
|
getMemberActivityMeta: vi.fn(),
|
|
getMemberSpawnStatuses: vi.fn(),
|
|
getTeamAgentRuntime: vi.fn(),
|
|
getTaskChangePresence: vi.fn(),
|
|
showMessageNotification: vi.fn(async () => undefined),
|
|
},
|
|
review: {
|
|
invalidateTaskChangeSummaries: vi.fn(async () => undefined),
|
|
},
|
|
crossTeam: {
|
|
listTargets: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
interface TeamSummaryLike {
|
|
teamName: string;
|
|
displayName: string;
|
|
projectPath: string;
|
|
}
|
|
|
|
interface GlobalTaskLike {
|
|
id: string;
|
|
subject: string;
|
|
status: string;
|
|
teamName: string;
|
|
teamDisplayName: string;
|
|
projectPath: string;
|
|
comments: [];
|
|
}
|
|
|
|
interface TeamSnapshotLike {
|
|
teamName: string;
|
|
config: {
|
|
name: string;
|
|
projectPath: string;
|
|
};
|
|
tasks: Array<{
|
|
id: string;
|
|
changePresence?: string;
|
|
}>;
|
|
members: [];
|
|
kanbanState: {
|
|
teamName: string;
|
|
reviewers: [];
|
|
tasks: Record<string, never>;
|
|
};
|
|
processes: [];
|
|
}
|
|
|
|
interface CrossTeamTargetLike {
|
|
teamName: string;
|
|
displayName: string;
|
|
}
|
|
|
|
const teamSnapshot = (
|
|
teamName: string,
|
|
projectPath: string,
|
|
tasks: TeamSnapshotLike['tasks'] = []
|
|
): TeamSnapshotLike => ({
|
|
teamName,
|
|
config: {
|
|
name: teamName,
|
|
projectPath,
|
|
},
|
|
tasks,
|
|
members: [],
|
|
kanbanState: {
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
},
|
|
processes: [],
|
|
});
|
|
|
|
const memberSpawnSnapshot = {
|
|
runId: 'runtime-run',
|
|
statuses: {
|
|
lead: {
|
|
status: 'online',
|
|
launchState: 'confirmed_alive',
|
|
},
|
|
},
|
|
};
|
|
|
|
const runtimeSnapshot = {
|
|
teamName: 'shared-team',
|
|
updatedAt: '2026-03-12T10:00:00.000Z',
|
|
runId: 'runtime-run',
|
|
members: {
|
|
lead: {
|
|
memberName: 'lead',
|
|
alive: true,
|
|
restartable: true,
|
|
updatedAt: '2026-03-12T10:00:00.000Z',
|
|
},
|
|
},
|
|
};
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: apiMock,
|
|
}));
|
|
|
|
function deferred<T>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (reason?: unknown) => void;
|
|
} {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((innerResolve, innerReject) => {
|
|
resolve = innerResolve;
|
|
reject = innerReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await 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(),
|
|
projects: [],
|
|
repositoryGroups: [],
|
|
selectedProjectId: null,
|
|
selectedWorktreeId: null,
|
|
fetchSessionsInitial: vi.fn(async () => undefined),
|
|
}) as unknown as AppState
|
|
);
|
|
}
|
|
|
|
describe('team slice context races', () => {
|
|
beforeEach(() => {
|
|
__resetTeamSliceModuleStateForTests();
|
|
apiMock.teams.list.mockReset();
|
|
apiMock.teams.getAllTasks.mockReset();
|
|
apiMock.teams.getData.mockReset();
|
|
apiMock.teams.getMessagesPage.mockReset();
|
|
apiMock.teams.getMemberActivityMeta.mockReset();
|
|
apiMock.teams.getMemberSpawnStatuses.mockReset();
|
|
apiMock.teams.getTeamAgentRuntime.mockReset();
|
|
apiMock.teams.getTaskChangePresence.mockReset();
|
|
apiMock.teams.showMessageNotification.mockClear();
|
|
apiMock.review.invalidateTaskChangeSummaries.mockClear();
|
|
apiMock.crossTeam.listTargets.mockReset();
|
|
});
|
|
|
|
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('ignores a team list response loaded before a context epoch reset with the same context id', 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);
|
|
|
|
invalidateContextScopedRequestEpoch();
|
|
store.setState({
|
|
activeContextId: 'local',
|
|
teams: [],
|
|
teamByName: {},
|
|
teamBySessionId: {},
|
|
teamsLoading: false,
|
|
});
|
|
localList.resolve([
|
|
{
|
|
teamName: 'old-local-team',
|
|
displayName: 'Old Local Team',
|
|
projectPath: '/old-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);
|
|
});
|
|
|
|
it('ignores global tasks loaded before a context epoch reset with the same context id', async () => {
|
|
const store = createSliceStore();
|
|
const localTasks = deferred<GlobalTaskLike[]>();
|
|
apiMock.teams.getAllTasks.mockReturnValueOnce(localTasks.promise);
|
|
|
|
const fetchPromise = store.getState().fetchAllTasks();
|
|
expect(store.getState().globalTasksLoading).toBe(true);
|
|
|
|
invalidateContextScopedRequestEpoch();
|
|
store.setState({
|
|
activeContextId: 'local',
|
|
globalTasks: [],
|
|
globalTasksLoading: false,
|
|
globalTasksInitialized: false,
|
|
});
|
|
localTasks.resolve([
|
|
{
|
|
id: 'old-local-task',
|
|
subject: 'Old local task',
|
|
status: 'todo',
|
|
teamName: 'old-local-team',
|
|
teamDisplayName: 'Old Local Team',
|
|
projectPath: '/old-local/project',
|
|
comments: [],
|
|
},
|
|
]);
|
|
await fetchPromise;
|
|
|
|
expect(store.getState().globalTasks).toEqual([]);
|
|
expect(store.getState().globalTasksInitialized).toBe(false);
|
|
expect(store.getState().globalTasksLoading).toBe(false);
|
|
});
|
|
|
|
it('ignores cross-team targets loaded for a previous context', async () => {
|
|
const store = createSliceStore();
|
|
const localTargets = deferred<CrossTeamTargetLike[]>();
|
|
apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise);
|
|
|
|
const fetchPromise = store.getState().fetchCrossTeamTargets();
|
|
expect(store.getState().crossTeamTargetsLoading).toBe(true);
|
|
|
|
store.setState({
|
|
activeContextId: 'ssh-dev',
|
|
crossTeamTargets: [],
|
|
crossTeamTargetsLoading: false,
|
|
});
|
|
localTargets.resolve([
|
|
{
|
|
teamName: 'local-target',
|
|
displayName: 'Local Target',
|
|
},
|
|
]);
|
|
await fetchPromise;
|
|
|
|
expect(store.getState().crossTeamTargets).toEqual([]);
|
|
expect(store.getState().crossTeamTargetsLoading).toBe(false);
|
|
});
|
|
|
|
it('ignores cross-team targets loaded before a context epoch reset with the same context id', async () => {
|
|
const store = createSliceStore();
|
|
const localTargets = deferred<CrossTeamTargetLike[]>();
|
|
apiMock.crossTeam.listTargets.mockReturnValueOnce(localTargets.promise);
|
|
|
|
const fetchPromise = store.getState().fetchCrossTeamTargets();
|
|
expect(store.getState().crossTeamTargetsLoading).toBe(true);
|
|
|
|
invalidateContextScopedRequestEpoch();
|
|
store.setState({
|
|
activeContextId: 'local',
|
|
crossTeamTargets: [],
|
|
crossTeamTargetsLoading: false,
|
|
});
|
|
localTargets.resolve([
|
|
{
|
|
teamName: 'old-local-target',
|
|
displayName: 'Old Local Target',
|
|
},
|
|
]);
|
|
await fetchPromise;
|
|
|
|
expect(store.getState().crossTeamTargets).toEqual([]);
|
|
expect(store.getState().crossTeamTargetsLoading).toBe(false);
|
|
});
|
|
|
|
it('resolves true after a successful cross-team targets fetch', async () => {
|
|
const store = createSliceStore();
|
|
apiMock.crossTeam.listTargets.mockResolvedValueOnce([
|
|
{ teamName: 'peer', displayName: 'Peer' },
|
|
]);
|
|
|
|
const ok = await store.getState().fetchCrossTeamTargets();
|
|
|
|
expect(ok).toBe(true);
|
|
expect(store.getState().crossTeamTargets).toEqual([{ teamName: 'peer', displayName: 'Peer' }]);
|
|
});
|
|
|
|
it('resolves false when the cross-team targets fetch fails so the composer can retry', async () => {
|
|
const store = createSliceStore();
|
|
apiMock.crossTeam.listTargets.mockRejectedValueOnce(new Error('boom'));
|
|
|
|
const ok = await store.getState().fetchCrossTeamTargets();
|
|
|
|
expect(ok).toBe(false);
|
|
expect(store.getState().crossTeamTargets).toEqual([]);
|
|
expect(store.getState().crossTeamTargetsLoading).toBe(false);
|
|
});
|
|
|
|
it('ignores selected team data loaded for a previous context', async () => {
|
|
const store = createSliceStore();
|
|
const localData = deferred<TeamSnapshotLike>();
|
|
apiMock.teams.getData.mockReturnValueOnce(localData.promise);
|
|
|
|
const selectPromise = store.getState().selectTeam('shared-team');
|
|
expect(store.getState().selectedTeamName).toBe('shared-team');
|
|
|
|
store.setState({
|
|
activeContextId: 'ssh-dev',
|
|
selectedTeamName: null,
|
|
selectedTeamData: null,
|
|
selectedTeamLoading: false,
|
|
teamDataCacheByName: {},
|
|
});
|
|
localData.resolve(teamSnapshot('shared-team', '/local/project'));
|
|
await selectPromise;
|
|
|
|
expect(store.getState().selectedTeamName).toBeNull();
|
|
expect(store.getState().selectedTeamData).toBeNull();
|
|
expect(store.getState().teamDataCacheByName).toEqual({});
|
|
});
|
|
|
|
it('ignores selected team data loaded before a context epoch reset with the same context id', async () => {
|
|
const store = createSliceStore();
|
|
const localData = deferred<TeamSnapshotLike>();
|
|
apiMock.teams.getData.mockReturnValueOnce(localData.promise);
|
|
|
|
const selectPromise = store.getState().selectTeam('shared-team');
|
|
expect(store.getState().selectedTeamName).toBe('shared-team');
|
|
|
|
invalidateContextScopedRequestEpoch();
|
|
store.setState({
|
|
activeContextId: 'local',
|
|
selectedTeamName: null,
|
|
selectedTeamData: null,
|
|
selectedTeamLoading: false,
|
|
teamDataCacheByName: {},
|
|
});
|
|
localData.resolve(teamSnapshot('shared-team', '/old-local/project'));
|
|
await selectPromise;
|
|
|
|
expect(store.getState().selectedTeamName).toBeNull();
|
|
expect(store.getState().selectedTeamData).toBeNull();
|
|
expect(store.getState().teamDataCacheByName).toEqual({});
|
|
});
|
|
|
|
it('does not let a stale silent team refresh overwrite the current context cache', async () => {
|
|
const store = createSliceStore();
|
|
const sshData = teamSnapshot('shared-team', '/ssh/project');
|
|
const localData = deferred<TeamSnapshotLike>();
|
|
apiMock.teams.getData.mockReturnValueOnce(localData.promise);
|
|
|
|
const refreshPromise = store.getState().refreshTeamData('shared-team');
|
|
store.setState({
|
|
activeContextId: 'ssh-dev',
|
|
teamDataCacheByName: {
|
|
'shared-team': sshData,
|
|
},
|
|
} as never);
|
|
|
|
localData.resolve(teamSnapshot('shared-team', '/local/project'));
|
|
await refreshPromise;
|
|
|
|
expect(store.getState().teamDataCacheByName['shared-team']).toBe(sshData);
|
|
});
|
|
|
|
it('ignores message head pages loaded for a previous context', async () => {
|
|
const store = createSliceStore();
|
|
const localMessages = deferred<{
|
|
messages: [];
|
|
feedRevision: string;
|
|
nextCursor: null;
|
|
hasMore: false;
|
|
}>();
|
|
apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise);
|
|
|
|
const refreshPromise = store.getState().refreshTeamMessagesHead('shared-team');
|
|
expect(store.getState().teamMessagesByName['shared-team']).toBeDefined();
|
|
|
|
store.setState({
|
|
activeContextId: 'ssh-dev',
|
|
teamMessagesByName: {},
|
|
});
|
|
localMessages.resolve({
|
|
messages: [],
|
|
feedRevision: 'local-feed',
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
});
|
|
await refreshPromise;
|
|
|
|
expect(store.getState().teamMessagesByName).toEqual({});
|
|
});
|
|
|
|
it('ignores member spawn statuses loaded before a same-context team reset', async () => {
|
|
const store = createSliceStore();
|
|
const localStatuses = deferred<typeof memberSpawnSnapshot>();
|
|
apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(localStatuses.promise);
|
|
|
|
const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team');
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
localStatuses.resolve(memberSpawnSnapshot);
|
|
await refreshPromise;
|
|
|
|
expect(store.getState().memberSpawnStatusesByTeam).toEqual({});
|
|
expect(store.getState().memberSpawnSnapshotsByTeam).toEqual({});
|
|
expect(store.getState().currentRuntimeRunIdByTeam).toEqual({});
|
|
});
|
|
|
|
it('does not let stale member spawn IPC failures poison the next team scope', async () => {
|
|
const store = createSliceStore();
|
|
const staleFailure = deferred<never>();
|
|
apiMock.teams.getMemberSpawnStatuses.mockReturnValueOnce(staleFailure.promise);
|
|
|
|
const refreshPromise = store.getState().fetchMemberSpawnStatuses('shared-team');
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
staleFailure.reject(new Error("No handler registered for 'team:memberSpawnStatuses'"));
|
|
await refreshPromise;
|
|
|
|
expect(
|
|
__getTeamScopedTransientStateForTests('shared-team').hasMemberSpawnStatusesIpcBackoff
|
|
).toBe(false);
|
|
});
|
|
|
|
it('ignores agent runtime snapshots loaded before a same-context team reset', async () => {
|
|
const store = createSliceStore();
|
|
const localRuntime = deferred<typeof runtimeSnapshot>();
|
|
apiMock.teams.getTeamAgentRuntime.mockReturnValueOnce(localRuntime.promise);
|
|
|
|
const refreshPromise = store.getState().fetchTeamAgentRuntime('shared-team');
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
localRuntime.resolve(runtimeSnapshot);
|
|
await refreshPromise;
|
|
|
|
expect(store.getState().teamAgentRuntimeByTeam).toEqual({});
|
|
});
|
|
|
|
it('ignores change presence loaded before a same-context team reset', async () => {
|
|
const store = createSliceStore();
|
|
const staleData = teamSnapshot('shared-team', '/local/project', [
|
|
{ id: 'task-1', changePresence: 'unknown' },
|
|
]);
|
|
const localPresence = deferred<{ 'task-1': 'has_changes' }>();
|
|
apiMock.teams.getTaskChangePresence.mockReturnValueOnce(localPresence.promise);
|
|
store.setState({
|
|
selectedTeamName: 'shared-team',
|
|
selectedTeamData: staleData,
|
|
teamDataCacheByName: {
|
|
'shared-team': staleData,
|
|
},
|
|
} as never);
|
|
|
|
const refreshPromise = store.getState().refreshTeamChangePresence('shared-team');
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
localPresence.resolve({ 'task-1': 'has_changes' });
|
|
await refreshPromise;
|
|
|
|
expect(store.getState().selectedTeamData).toBe(staleData);
|
|
expect(store.getState().teamDataCacheByName['shared-team']).toBe(staleData);
|
|
});
|
|
|
|
it('does not rerun pending full team data refreshes from a stale scope', async () => {
|
|
const store = createSliceStore();
|
|
const localData = deferred<TeamSnapshotLike>();
|
|
apiMock.teams.getData
|
|
.mockReturnValueOnce(localData.promise)
|
|
.mockResolvedValueOnce(teamSnapshot('shared-team', '/unexpected/project'));
|
|
|
|
const firstRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true });
|
|
const secondRefresh = store.getState().refreshTeamData('shared-team', { withDedup: true });
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
store.setState({ teamDataCacheByName: {} });
|
|
localData.resolve(teamSnapshot('shared-team', '/local/project'));
|
|
await Promise.all([firstRefresh, secondRefresh]);
|
|
await flushMicrotasks();
|
|
|
|
expect(apiMock.teams.getData).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not rerun pending message head refreshes from a stale scope', async () => {
|
|
const store = createSliceStore();
|
|
const localMessages = deferred<{
|
|
messages: [];
|
|
feedRevision: string;
|
|
nextCursor: null;
|
|
hasMore: false;
|
|
}>();
|
|
apiMock.teams.getMessagesPage.mockReturnValueOnce(localMessages.promise).mockResolvedValueOnce({
|
|
messages: [],
|
|
feedRevision: 'unexpected-feed',
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
});
|
|
|
|
const firstRefresh = store.getState().refreshTeamMessagesHead('shared-team');
|
|
const secondRefresh = store.getState().refreshTeamMessagesHead('shared-team');
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
store.setState({ teamMessagesByName: {} });
|
|
localMessages.resolve({
|
|
messages: [],
|
|
feedRevision: 'local-feed',
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
});
|
|
await Promise.all([firstRefresh, secondRefresh]);
|
|
await flushMicrotasks();
|
|
|
|
expect(apiMock.teams.getMessagesPage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not rerun pending member activity meta refreshes from a stale scope', async () => {
|
|
const store = createSliceStore();
|
|
const localMeta = deferred<{
|
|
teamName: string;
|
|
computedAt: string;
|
|
feedRevision: string;
|
|
members: Record<string, never>;
|
|
}>();
|
|
apiMock.teams.getMemberActivityMeta.mockReturnValueOnce(localMeta.promise).mockResolvedValueOnce({
|
|
teamName: 'shared-team',
|
|
computedAt: '2026-03-12T10:00:01.000Z',
|
|
feedRevision: 'unexpected-feed',
|
|
members: {},
|
|
});
|
|
store.setState({
|
|
teamMessagesByName: {
|
|
'shared-team': {
|
|
canonicalMessages: [],
|
|
optimisticMessages: [],
|
|
feedRevision: 'feed-1',
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
lastFetchedAt: 0,
|
|
loadingHead: false,
|
|
loadingOlder: false,
|
|
headHydrated: true,
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const firstRefresh = store.getState().refreshMemberActivityMeta('shared-team');
|
|
const secondRefresh = store.getState().refreshMemberActivityMeta('shared-team');
|
|
invalidateTeamLocalStateEpoch('shared-team');
|
|
store.setState({ teamMessagesByName: {}, memberActivityMetaByTeam: {} });
|
|
localMeta.resolve({
|
|
teamName: 'shared-team',
|
|
computedAt: '2026-03-12T10:00:00.000Z',
|
|
feedRevision: 'feed-1',
|
|
members: {},
|
|
});
|
|
await Promise.all([firstRefresh, secondRefresh]);
|
|
await flushMicrotasks();
|
|
|
|
expect(apiMock.teams.getMemberActivityMeta).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|