1870 lines
63 KiB
TypeScript
1870 lines
63 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
onTeamChangeCb: null as
|
|
| ((
|
|
event: unknown,
|
|
data: {
|
|
type?: string;
|
|
teamName: string;
|
|
detail?: string;
|
|
runId?: string;
|
|
taskId?: string;
|
|
taskSignalKind?: 'log' | 'change';
|
|
}
|
|
) => void)
|
|
| null,
|
|
onProvisioningProgressCb: null as
|
|
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
|
| null,
|
|
}));
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
config: {
|
|
get: vi.fn(async () => ({
|
|
general: { theme: 'dark' },
|
|
notifications: { enabled: true, triggers: [] },
|
|
})),
|
|
},
|
|
getRepositoryGroups: vi.fn(async () => []),
|
|
notifications: {
|
|
onNew: vi.fn(() => () => undefined),
|
|
onUpdated: vi.fn(() => () => undefined),
|
|
onClicked: vi.fn(() => () => undefined),
|
|
get: vi.fn(async () => ({
|
|
notifications: [],
|
|
total: 0,
|
|
totalCount: 0,
|
|
unreadCount: 0,
|
|
hasMore: false,
|
|
})),
|
|
},
|
|
teams: {
|
|
setChangePresenceTracking: vi.fn(async () => undefined),
|
|
setToolActivityTracking: vi.fn(async () => undefined),
|
|
setTaskLogStreamTracking: vi.fn(async () => undefined),
|
|
onTeamChange: vi.fn(
|
|
(
|
|
cb: (
|
|
event: unknown,
|
|
data: {
|
|
teamName: string;
|
|
type?: string;
|
|
detail?: string;
|
|
runId?: string;
|
|
taskId?: string;
|
|
taskSignalKind?: 'log' | 'change';
|
|
}
|
|
) => void
|
|
): (() => void) => {
|
|
hoisted.onTeamChangeCb = cb;
|
|
return () => {
|
|
hoisted.onTeamChangeCb = null;
|
|
};
|
|
}
|
|
),
|
|
onProvisioningProgress: vi.fn(
|
|
(cb: (event: unknown, data: { runId: string; teamName: string }) => void): (() => void) => {
|
|
hoisted.onProvisioningProgressCb = cb;
|
|
return () => {
|
|
hoisted.onProvisioningProgressCb = null;
|
|
};
|
|
}
|
|
),
|
|
getAllTasks: vi.fn(async () => []),
|
|
list: vi.fn(async () => []),
|
|
},
|
|
schedules: {
|
|
list: vi.fn(async () => []),
|
|
onScheduleChange: vi.fn(() => () => undefined),
|
|
},
|
|
},
|
|
}));
|
|
|
|
import { api } from '@renderer/api';
|
|
|
|
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
|
|
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
|
|
import {
|
|
__resetTeamRefreshFanoutDiagnosticsForTests,
|
|
getTeamRefreshFanoutSnapshotForTests,
|
|
summarizeTeamRefreshFanout,
|
|
type TeamRefreshFanoutSnapshot,
|
|
} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics';
|
|
|
|
describe('team change throttling', () => {
|
|
let cleanup: (() => void) | null = null;
|
|
|
|
beforeEach(async () => {
|
|
vi.useFakeTimers();
|
|
__resetTeamSliceModuleStateForTests();
|
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
|
const fetchTeams = vi.fn(async () => undefined);
|
|
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
|
const fetchTeamAgentRuntime = vi.fn(async () => undefined);
|
|
const refreshTeamData = vi.fn(async () => undefined);
|
|
const refreshTeamMessagesHead = vi.fn(async () => ({
|
|
feedChanged: true,
|
|
headChanged: true,
|
|
feedRevision: 'rev-1',
|
|
}));
|
|
const refreshMemberActivityMeta = vi.fn(async () => undefined);
|
|
const refreshTeamChangePresence = vi.fn(async () => undefined);
|
|
|
|
useStore.setState({
|
|
fetchTeams,
|
|
fetchMemberSpawnStatuses,
|
|
fetchTeamAgentRuntime,
|
|
refreshTeamData,
|
|
refreshTeamMessagesHead,
|
|
refreshMemberActivityMeta,
|
|
refreshTeamChangePresence,
|
|
selectedTeamName: null,
|
|
selectedTeamData: null,
|
|
teamDataCacheByName: {},
|
|
provisioningRuns: {},
|
|
currentProvisioningRunIdByTeam: {},
|
|
currentRuntimeRunIdByTeam: {},
|
|
ignoredProvisioningRunIds: {},
|
|
ignoredRuntimeRunIds: {},
|
|
activeTaskLogActivityByTeam: {},
|
|
memberSpawnStatusesByTeam: {},
|
|
memberSpawnSnapshotsByTeam: {},
|
|
teamAgentRuntimeByTeam: {},
|
|
memberActivityMetaByTeam: {},
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
|
activeTabId: 't1',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
cleanup = initializeNotificationListeners();
|
|
|
|
// Flush microtask queue so the sequential init chain completes
|
|
// before test assertions start (prevents init calls from leaking into spies).
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup?.();
|
|
cleanup = null;
|
|
__resetTeamSliceModuleStateForTests();
|
|
__resetTeamRefreshFanoutDiagnosticsForTests();
|
|
window.localStorage.removeItem('team:processLiteFanout');
|
|
vi.mocked(console.warn).mockClear();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('throttles both team list and detail refresh', async () => {
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
// Fire 3 rapid events
|
|
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
|
|
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
|
|
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
|
|
|
|
// Both are throttled — nothing called synchronously
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
// Detail refresh fires at 800ms
|
|
await vi.advanceTimersByTimeAsync(799);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
|
|
// List refresh fires at 2000ms
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1200);
|
|
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not scan repository groups during centralized startup initialization', async () => {
|
|
const getRepositoryGroupsSpy = vi.mocked(api.getRepositoryGroups);
|
|
getRepositoryGroupsSpy.mockClear();
|
|
|
|
cleanup?.();
|
|
cleanup = initializeNotificationListeners();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(getRepositoryGroupsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('defers the initial global task fetch until the startup idle window', async () => {
|
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
|
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
|
|
|
cleanup?.();
|
|
cleanup = initializeNotificationListeners();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('cancels the deferred initial global task fetch during listener cleanup', async () => {
|
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
|
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
|
|
|
cleanup?.();
|
|
cleanup = initializeNotificationListeners();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
cleanup();
|
|
cleanup = null;
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
|
|
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows next refresh after throttle window passes', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
|
|
// Second event after throttle window
|
|
hoisted.onTeamChangeCb?.({}, { teamName: 'my-team' });
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps process events on the existing structural refresh path and records fanout', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
|
|
const snapshot = getTeamRefreshFanoutSnapshotForTests(
|
|
'my-team'
|
|
) as TeamRefreshFanoutSnapshot | null;
|
|
expect(
|
|
snapshot?.counts['team-change-listener:event:process:refreshTeamData:scheduled']
|
|
).toBe(1);
|
|
expect(snapshot?.counts['team-change-listener:event:process:refreshTeamData:executed']).toBe(
|
|
1
|
|
);
|
|
});
|
|
|
|
it('uses process-lite for strict candidates and delays structural reconcile', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
|
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledTimes(1);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
|
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledTimes(1);
|
|
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team');
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1999);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'dry-run:process-lite:processes-json-visible-runtime-context',
|
|
operation: 'wouldUseProcessLite',
|
|
phase: 'skipped',
|
|
}),
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-suppressed',
|
|
operation: 'refreshTeamData',
|
|
phase: 'skipped',
|
|
}),
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite',
|
|
operation: 'fetchMemberSpawnStatuses',
|
|
phase: 'executed',
|
|
}),
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite',
|
|
operation: 'fetchTeamAgentRuntime',
|
|
phase: 'executed',
|
|
}),
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-reconcile',
|
|
operation: 'refreshTeamData',
|
|
phase: 'executed',
|
|
}),
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-reconcile',
|
|
operation: 'fetchTeams',
|
|
phase: 'executed',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('uses process-lite when an active provisioning run exists without current runtime', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: {},
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-reconcile:suppressed-during-launch',
|
|
operation: 'refreshTeamData',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps active launch process file events runtime-only before team data hydrates', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: null,
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: {},
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
|
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(799);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
|
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team');
|
|
expect(useStore.getState().selectedTeamData).toBeNull();
|
|
expect(useStore.getState().teamDataCacheByName['my-team']).toBeUndefined();
|
|
|
|
await vi.advanceTimersByTimeAsync(19_200);
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'dry-run:process-lite:missing-visible-team-data',
|
|
operation: 'wouldKeepStructuralProcess',
|
|
phase: 'skipped',
|
|
}),
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-suppressed-during-launch',
|
|
operation: 'refreshTeamData',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps active launch process file events structural when process-lite is disabled', async () => {
|
|
window.localStorage.setItem('team:processLiteFanout', '0');
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: null,
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: {},
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
});
|
|
|
|
it('suppresses idle-watchdog structural refresh during active launch', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps idle-watchdog structural refresh available when process-lite is disabled', async () => {
|
|
window.localStorage.setItem('team:processLiteFanout', '0');
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'lead-activity', teamName: 'my-team', detail: 'active', runId: 'run-1' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(59_999);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
});
|
|
|
|
it('does not treat terminal or unknown provisioning states as process-lite active', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'ready',
|
|
message: 'Ready',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: {},
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
});
|
|
|
|
it('keeps unsafe process details structural during active launch', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team', detail: 'failed' });
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'dry-run:process-lite:unsafe-process-detail',
|
|
operation: 'wouldKeepStructuralProcess',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps strict process candidates on the structural path when process-lite is disabled', async () => {
|
|
window.localStorage.setItem('team:processLiteFanout', '0');
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
expect(fetchTeamAgentRuntimeSpy).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1200);
|
|
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:disabled',
|
|
operation: 'wouldKeepStructuralProcess',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('coalesces process-lite structural reconcile until idle or max wait', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
for (let elapsed = 2_000; elapsed <= 14_000; elapsed += 2_000) {
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
}
|
|
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('cancels pending process-lite structural reconcile when launch becomes active', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
useStore.setState({
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-2' },
|
|
provisioningRuns: {
|
|
'run-2': {
|
|
runId: 'run-2',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-2' },
|
|
} as never);
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-2' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-reconcile:cancelled-during-launch',
|
|
operation: 'refreshTeamData',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('skips pending process-lite structural reconcile if provisioning becomes active before the timer fires', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
useStore.setState({
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-2' },
|
|
provisioningRuns: {
|
|
'run-2': {
|
|
runId: 'run-2',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-2' },
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'event:process-lite:structural-reconcile:suppressed-during-launch',
|
|
operation: 'refreshTeamData',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps pending process-lite structural reconcile available when process-lite is disabled before the timer fires', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
window.localStorage.setItem('team:processLiteFanout', '0');
|
|
useStore.setState({
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-2' },
|
|
provisioningRuns: {
|
|
'run-2': {
|
|
runId: 'run-2',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-2' },
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
});
|
|
|
|
it('cancels pending process-lite reconcile when a normal structural event wins', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not let process-lite coalescing weaken member-spawn runtime refresh semantics', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
|
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' });
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
|
|
activeTabId: 't2',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledTimes(1);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
|
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledTimes(1);
|
|
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('cleans up pending process-lite reconcile timers', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
|
|
);
|
|
cleanup?.();
|
|
cleanup = null;
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('records unsafe process details as structural dry-run without changing refresh behavior', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team', detail: 'cancelled' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
|
|
const summary = summarizeTeamRefreshFanout('my-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'dry-run:process-lite:unsafe-process-detail',
|
|
operation: 'wouldKeepStructuralProcess',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps hidden process events out of visible detail refresh while recording structural dry-run', async () => {
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
|
activeTabId: 't1',
|
|
},
|
|
],
|
|
},
|
|
teamDataCacheByName: {
|
|
'other-team': {
|
|
teamName: 'other-team',
|
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
},
|
|
currentRuntimeRunIdByTeam: { 'other-team': 'run-1' },
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{ type: 'process', teamName: 'other-team', detail: 'processes.json' }
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalledWith('other-team', { withDedup: true });
|
|
|
|
const summary = summarizeTeamRefreshFanout('other-team');
|
|
expect(summary.rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
reason: 'dry-run:process-lite:hidden-team',
|
|
operation: 'wouldKeepStructuralProcess',
|
|
phase: 'skipped',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps task and config events on the existing global task refresh path', async () => {
|
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
|
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' });
|
|
hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
|
|
expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1);
|
|
|
|
const snapshot = getTeamRefreshFanoutSnapshotForTests(
|
|
'my-team'
|
|
) as TeamRefreshFanoutSnapshot | null;
|
|
expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:scheduled']).toBe(1);
|
|
expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:coalesced']).toBe(1);
|
|
expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:executed']).toBe(1);
|
|
expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:executed']).toBe(1);
|
|
});
|
|
|
|
it('slows global task refreshes during active provisioning', async () => {
|
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
|
useStore.setState({
|
|
fetchAllTasks: fetchAllTasksSpy,
|
|
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
|
|
provisioningRuns: {
|
|
'run-1': {
|
|
runId: 'run-1',
|
|
teamName: 'my-team',
|
|
state: 'spawning',
|
|
message: 'Spawning',
|
|
startedAt: '2026-05-03T00:00:00.000Z',
|
|
updatedAt: '2026-05-03T00:00:00.000Z',
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
fetchAllTasksSpy.mockClear();
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(4999);
|
|
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => {
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
|
|
const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta');
|
|
|
|
// Emit a lead-message event
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
|
|
|
// Should NOT trigger fetchTeams
|
|
await vi.advanceTimersByTimeAsync(2100);
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
|
|
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('lead-message refreshes visible graph tabs even when the team is not selected', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'other-team',
|
|
selectedTeamData: {
|
|
teamName: 'other-team',
|
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
|
activeTabId: 'g1',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => {
|
|
useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000);
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
|
activeTabId: 't1',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
|
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
|
|
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team');
|
|
});
|
|
|
|
it('lead-message does not refresh hidden inactive teams without pending replies', async () => {
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
|
activeTabId: 't1',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
|
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalledWith('other-team');
|
|
expect(refreshMemberActivityMetaSpy).not.toHaveBeenCalledWith('other-team');
|
|
});
|
|
|
|
it('member-spawn refreshes spawn statuses without forcing structural refresh', async () => {
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
|
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('inbox/config/process do not refresh member spawn statuses by default', async () => {
|
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'inbox', teamName: 'my-team' });
|
|
hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' });
|
|
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lead-message does not call fetchAllTasks', async () => {
|
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
|
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(2100);
|
|
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => {
|
|
useStore.getState().syncTeamPendingReplyRefresh('other-team', 'tab-hidden', true, 60_000);
|
|
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
|
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
|
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
|
|
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team');
|
|
});
|
|
|
|
it('log-source-change refreshes only task change presence', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const refreshTeamChangePresenceSpy = vi.spyOn(state, 'refreshTeamChangePresence');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(399);
|
|
expect(refreshTeamChangePresenceSpy).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('log-source-change refreshes visible graph tab change presence for non-selected teams', async () => {
|
|
useStore.setState({
|
|
selectedTeamName: 'other-team',
|
|
selectedTeamData: {
|
|
teamName: 'other-team',
|
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
teamDataCacheByName: {
|
|
'my-team': {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
},
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
|
activeTabId: 'g1',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
const refreshTeamChangePresenceSpy = vi.spyOn(useStore.getState(), 'refreshTeamChangePresence');
|
|
|
|
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
|
|
});
|
|
|
|
it('keeps background polling disabled for unknown in-progress tasks', async () => {
|
|
const invalidateTaskChangePresence = vi.fn();
|
|
const checkTaskHasChanges = vi.fn(async () => undefined);
|
|
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [
|
|
{
|
|
id: 'task-1',
|
|
owner: 'alice',
|
|
status: 'in_progress',
|
|
createdAt: '2026-03-01T10:00:00.000Z',
|
|
updatedAt: '2026-03-01T10:00:00.000Z',
|
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
|
historyEvents: [],
|
|
reviewState: 'none',
|
|
changePresence: 'unknown',
|
|
},
|
|
{
|
|
id: 'task-2',
|
|
owner: 'alice',
|
|
status: 'in_progress',
|
|
createdAt: '2026-03-01T10:00:00.000Z',
|
|
updatedAt: '2026-03-01T10:00:00.000Z',
|
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
|
historyEvents: [],
|
|
reviewState: 'none',
|
|
changePresence: 'unknown',
|
|
},
|
|
],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
invalidateTaskChangePresence,
|
|
checkTaskHasChanges,
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(checkTaskHasChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps background polling disabled for visible non-selected graph teams', async () => {
|
|
const invalidateTaskChangePresence = vi.fn();
|
|
const checkTaskHasChanges = vi.fn(async () => undefined);
|
|
|
|
useStore.setState({
|
|
selectedTeamName: 'other-team',
|
|
selectedTeamData: {
|
|
teamName: 'other-team',
|
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
teamDataCacheByName: {
|
|
'my-team': {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [
|
|
{
|
|
id: 'task-1',
|
|
owner: 'alice',
|
|
status: 'in_progress',
|
|
createdAt: '2026-03-01T10:00:00.000Z',
|
|
updatedAt: '2026-03-01T10:00:00.000Z',
|
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
|
historyEvents: [],
|
|
reviewState: 'none',
|
|
changePresence: 'unknown',
|
|
},
|
|
{
|
|
id: 'task-2',
|
|
owner: 'alice',
|
|
status: 'in_progress',
|
|
createdAt: '2026-03-01T10:00:00.000Z',
|
|
updatedAt: '2026-03-01T10:00:00.000Z',
|
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
|
historyEvents: [],
|
|
reviewState: 'none',
|
|
changePresence: 'unknown',
|
|
},
|
|
],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
},
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
|
activeTabId: 'g1',
|
|
},
|
|
],
|
|
},
|
|
invalidateTaskChangePresence,
|
|
checkTaskHasChanges,
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(20_000);
|
|
expect(checkTaskHasChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('per-team throttling: busy team does not block another visible team', async () => {
|
|
// Add a second visible team tab
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 0.5,
|
|
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
|
activeTabId: 't1',
|
|
},
|
|
{
|
|
id: 'p2',
|
|
widthFraction: 0.5,
|
|
tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
|
|
activeTabId: 't2',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
|
|
|
|
// Fire rapid events for my-team (throttled)
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
|
|
|
// Fire event for other-team — should NOT be blocked by my-team's throttle
|
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
// Both teams should get exactly 1 refresh each
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2);
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
|
|
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
|
|
});
|
|
|
|
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
|
|
const setChangePresenceTrackingSpy = vi.mocked(api.teams.setChangePresenceTracking);
|
|
setChangePresenceTrackingSpy.mockClear();
|
|
|
|
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
|
|
|
useStore.setState({
|
|
selectedTeamName: 'my-team',
|
|
selectedTeamData: {
|
|
teamName: 'my-team',
|
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
|
tasks: [],
|
|
members: [],
|
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
processes: [],
|
|
},
|
|
} as never);
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
|
|
|
useStore.setState({
|
|
selectedTeamName: 'other-team',
|
|
selectedTeamData: null,
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
|
|
activeTabId: 't2',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('tracks visible team tabs for tool activity and disables tracking when tab disappears', async () => {
|
|
const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking);
|
|
setToolActivityTrackingSpy.mockClear();
|
|
|
|
cleanup?.();
|
|
cleanup = initializeNotificationListeners();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true);
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
|
},
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
|
});
|
|
|
|
it('tracks visible graph tabs for tool activity and disables tracking when graph tab disappears', async () => {
|
|
const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking);
|
|
setToolActivityTrackingSpy.mockClear();
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [
|
|
{
|
|
id: 'p1',
|
|
widthFraction: 1,
|
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
|
activeTabId: 'g1',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
cleanup?.();
|
|
cleanup = initializeNotificationListeners();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true);
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
|
},
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
|
});
|
|
|
|
it('tracks visible team tabs for task log activity and disables tracking when tab disappears', async () => {
|
|
const setTaskLogStreamTrackingSpy = vi.mocked(api.teams.setTaskLogStreamTracking);
|
|
setTaskLogStreamTrackingSpy.mockClear();
|
|
|
|
cleanup?.();
|
|
cleanup = initializeNotificationListeners();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', true);
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
|
},
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
|
});
|
|
|
|
it('pulses task log activity only for real log signals and clears it after inactivity', async () => {
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-change-only',
|
|
taskSignalKind: 'change',
|
|
});
|
|
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
|
|
|
|
useStore.setState({ currentRuntimeRunIdByTeam: { 'my-team': 'run-current' } } as never);
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
runId: 'run-old',
|
|
taskId: 'task-stale',
|
|
taskSignalKind: 'log',
|
|
});
|
|
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
|
|
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
runId: 'run-current',
|
|
taskId: 'task-live',
|
|
taskSignalKind: 'log',
|
|
});
|
|
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
|
|
'task-live': true,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(3499);
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
|
|
'task-live': true,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
|
|
});
|
|
|
|
it('pulses visible task log activity without refreshing team data for explicit log signals', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-live',
|
|
taskSignalKind: 'log',
|
|
}
|
|
);
|
|
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
|
|
'task-live': true,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes visible team data for task change freshness without pulsing live log activity', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-completed',
|
|
taskSignalKind: 'change',
|
|
}
|
|
);
|
|
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
});
|
|
|
|
it('keeps the bounded team data refresh for legacy task log change events', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-live',
|
|
detail: 'opencode-runtime-task-event:start',
|
|
}
|
|
);
|
|
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
|
|
'task-live': true,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
|
});
|
|
|
|
it('skips the bounded task log refresh if the team is hidden before execution', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
|
|
hoisted.onTeamChangeCb?.(
|
|
{},
|
|
{
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-live',
|
|
taskSignalKind: 'log',
|
|
}
|
|
);
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
|
},
|
|
} as never);
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('extends task log activity pulse on repeated log signals and ignores hidden teams', async () => {
|
|
const state = useStore.getState();
|
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
|
const activitySnapshots: Array<Record<string, true> | undefined> = [];
|
|
const unsubscribeActivitySnapshots = useStore.subscribe((nextState, prevState) => {
|
|
if (nextState.activeTaskLogActivityByTeam !== prevState.activeTaskLogActivityByTeam) {
|
|
activitySnapshots.push(nextState.activeTaskLogActivityByTeam['my-team']);
|
|
}
|
|
});
|
|
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-live',
|
|
taskSignalKind: 'log',
|
|
});
|
|
|
|
expect(activitySnapshots).toEqual([{ 'task-live': true }]);
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-live',
|
|
taskSignalKind: 'log',
|
|
});
|
|
|
|
expect(activitySnapshots).toEqual([{ 'task-live': true }]);
|
|
|
|
await vi.advanceTimersByTimeAsync(3499);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
|
|
'task-live': true,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
|
|
expect(activitySnapshots).toEqual([{ 'task-live': true }, undefined]);
|
|
|
|
useStore.setState({
|
|
paneLayout: {
|
|
focusedPaneId: 'p1',
|
|
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
|
},
|
|
} as never);
|
|
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'task-log-change',
|
|
teamName: 'my-team',
|
|
taskId: 'task-hidden',
|
|
taskSignalKind: 'log',
|
|
});
|
|
|
|
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
|
|
|
|
await vi.advanceTimersByTimeAsync(800);
|
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
|
unsubscribeActivitySnapshots();
|
|
});
|
|
|
|
it('applies targeted tool resets without clearing sibling tools', async () => {
|
|
useStore.setState({
|
|
activeToolsByTeam: {
|
|
'my-team': {
|
|
alice: {
|
|
'tool-a': {
|
|
memberName: 'alice',
|
|
toolUseId: 'tool-a',
|
|
toolName: 'Read',
|
|
startedAt: '2026-03-28T10:00:00.000Z',
|
|
state: 'running',
|
|
source: 'runtime',
|
|
},
|
|
'tool-b': {
|
|
memberName: 'alice',
|
|
toolUseId: 'tool-b',
|
|
toolName: 'Bash',
|
|
startedAt: '2026-03-28T10:00:01.000Z',
|
|
state: 'running',
|
|
source: 'runtime',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
hoisted.onTeamChangeCb?.({}, {
|
|
type: 'tool-activity',
|
|
teamName: 'my-team',
|
|
detail: JSON.stringify({
|
|
action: 'reset',
|
|
memberName: 'alice',
|
|
toolUseIds: ['tool-a'],
|
|
}),
|
|
});
|
|
|
|
expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-a']).toBeUndefined();
|
|
expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-b']).toBeDefined();
|
|
});
|
|
});
|