338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
cloneLaunchIoGovernorPayload,
|
|
LaunchIoGovernor,
|
|
} from '../../../../src/main/services/team/LaunchIoGovernor';
|
|
import type { GlobalTask, TeamProvisioningProgress, TeamSummary } from '../../../../src/shared/types';
|
|
|
|
function team(teamName: string): TeamSummary {
|
|
return { teamName, displayName: teamName } as TeamSummary;
|
|
}
|
|
|
|
function task(id: string): GlobalTask {
|
|
return { id, teamName: 'team-a', subject: id } as GlobalTask;
|
|
}
|
|
|
|
function progress(teamName: string, state: string): TeamProvisioningProgress {
|
|
return {
|
|
runId: `run-${teamName}`,
|
|
teamName,
|
|
state,
|
|
message: state,
|
|
startedAt: '2026-05-02T00:00:00.000Z',
|
|
updatedAt: '2026-05-02T00:00:00.000Z',
|
|
} as TeamProvisioningProgress;
|
|
}
|
|
|
|
function createDeferred<T>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (error: unknown) => void;
|
|
} {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe('LaunchIoGovernor', () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('runs fresh and caches success when there is no launch pressure', async () => {
|
|
const governor = new LaunchIoGovernor();
|
|
const loadFresh = vi.fn(async () => [team('fresh')]);
|
|
|
|
const result = await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
|
|
expect(result).toEqual([team('fresh')]);
|
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns bounded stale cache under active launch pressure and schedules no duplicate fresh read', async () => {
|
|
vi.useFakeTimers();
|
|
let now = 0;
|
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
|
const loadFresh = vi.fn(async () => [team('old')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
loadFresh.mockResolvedValue([team('new')]);
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
const result = await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
|
|
expect(result).toEqual([team('old')]);
|
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
|
|
|
now += 99;
|
|
await vi.advanceTimersByTimeAsync(99);
|
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('isolates cached payload from caller-side mutations', async () => {
|
|
const governor = new LaunchIoGovernor();
|
|
const loadFresh = vi.fn(async () => [team('old')]);
|
|
|
|
const first = await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
first[0]!.displayName = 'mutated';
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
const second = await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
|
|
expect(second).toEqual([team('old')]);
|
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('runs one fresh read and coalesces callers when pressure has no cache', async () => {
|
|
const governor = new LaunchIoGovernor();
|
|
const deferred = createDeferred<TeamSummary[]>();
|
|
const loadFresh = vi.fn(() => deferred.promise);
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
const first = governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
const second = governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
|
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
|
deferred.resolve([team('fresh')]);
|
|
await expect(Promise.all([first, second])).resolves.toEqual([[team('fresh')], [team('fresh')]]);
|
|
});
|
|
|
|
it('does not serve cache beyond max stale age during launch pressure', async () => {
|
|
let now = 0;
|
|
const governor = new LaunchIoGovernor({ now: () => now, maxStaleAgeMs: 100 });
|
|
const loadFresh = vi.fn(async () => [team('old')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
now = 101;
|
|
loadFresh.mockResolvedValue([team('new')]);
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
|
|
await expect(
|
|
governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
})
|
|
).resolves.toEqual([team('new')]);
|
|
expect(loadFresh).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not cache an in-flight result when a dirty generation arrives before it resolves', async () => {
|
|
const governor = new LaunchIoGovernor();
|
|
const deferred = createDeferred<TeamSummary[]>();
|
|
const loadFresh = vi.fn(() => deferred.promise);
|
|
|
|
const first = governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
deferred.resolve([team('stale-inflight')]);
|
|
await expect(first).resolves.toEqual([team('stale-inflight')]);
|
|
|
|
loadFresh.mockResolvedValue([team('fresh-after-dirty')]);
|
|
await expect(
|
|
governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
})
|
|
).resolves.toEqual([team('fresh-after-dirty')]);
|
|
expect(loadFresh).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('marks config and task changes dirty for the correct summary operations', async () => {
|
|
const governor = new LaunchIoGovernor();
|
|
const loadTeams = vi.fn(async () => [team('team-old')]);
|
|
const loadTasks = vi.fn(async () => [task('task-old')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
|
|
loadTeams.mockResolvedValue([team('team-new')]);
|
|
loadTasks.mockResolvedValue([task('task-new')]);
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
governor.noteTeamChange({ type: 'task', teamName: 'team-a', detail: 'task.json' });
|
|
|
|
await expect(
|
|
governor.runSummaryOperation('teams:list', loadTeams, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
})
|
|
).resolves.toEqual([team('team-old')]);
|
|
await expect(
|
|
governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
})
|
|
).resolves.toEqual([task('task-old')]);
|
|
expect(loadTeams).toHaveBeenCalledTimes(1);
|
|
expect(loadTasks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not start background refresh for dirty events outside launch pressure', async () => {
|
|
vi.useFakeTimers();
|
|
const governor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
|
const loadTeams = vi.fn(async () => [team('old')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
loadTeams.mockResolvedValue([team('new')]);
|
|
|
|
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await flushMicrotasks();
|
|
expect(loadTeams).toHaveBeenCalledTimes(1);
|
|
|
|
await expect(
|
|
governor.runSummaryOperation('teams:list', loadTeams, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
})
|
|
).resolves.toEqual([team('new')]);
|
|
expect(loadTeams).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not mark global tasks dirty from launch intent alone', async () => {
|
|
vi.useFakeTimers();
|
|
let now = 0;
|
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
|
const loadTeams = vi.fn(async () => [team('old-team')]);
|
|
const loadTasks = vi.fn(async () => [task('old-task')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
loadTeams.mockResolvedValue([team('new-team')]);
|
|
loadTasks.mockResolvedValue([task('new-task')]);
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
await governor.runSummaryOperation('teams:list', loadTeams, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
|
|
|
now += 100;
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
await flushMicrotasks();
|
|
|
|
expect(loadTeams).toHaveBeenCalledTimes(2);
|
|
expect(loadTasks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps quiet window after terminal progress and flushes dirty cache once timer expires', async () => {
|
|
vi.useFakeTimers();
|
|
let now = 0;
|
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
|
const loadFresh = vi.fn(async () => [team('old')]);
|
|
const loadTasks = vi.fn(async () => [task('old')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
loadFresh.mockResolvedValue([team('new')]);
|
|
loadTasks.mockResolvedValue([task('new')]);
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
await governor.runSummaryOperation('teams:getAllTasks', loadTasks, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
|
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
|
|
|
now += 99;
|
|
await vi.advanceTimersByTimeAsync(99);
|
|
await flushMicrotasks();
|
|
expect(loadFresh).toHaveBeenCalledTimes(1);
|
|
expect(loadTasks).toHaveBeenCalledTimes(1);
|
|
|
|
now += 1;
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await flushMicrotasks();
|
|
expect(loadFresh).toHaveBeenCalledTimes(2);
|
|
expect(loadTasks).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps launch pressure until all concurrent launches reach terminal states', () => {
|
|
let now = 0;
|
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 });
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
governor.noteLaunchIntent('team-b', 'launch');
|
|
governor.noteProvisioningProgress(progress('team-a', 'failed'));
|
|
expect(governor.hasLaunchPressureForTests()).toBe(true);
|
|
|
|
governor.noteProvisioningProgress(progress('team-b', 'ready'));
|
|
expect(governor.hasLaunchPressureForTests()).toBe(true);
|
|
|
|
now += 100;
|
|
expect(governor.hasLaunchPressureForTests()).toBe(false);
|
|
});
|
|
|
|
it('preserves old cache and dirty state when a deferred refresh fails', async () => {
|
|
vi.useFakeTimers();
|
|
let now = 0;
|
|
const logger = { warn: vi.fn() };
|
|
const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100, logger });
|
|
const loadFresh = vi.fn(async () => [team('old')]);
|
|
|
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
loadFresh.mockRejectedValueOnce(new Error('worker timeout'));
|
|
|
|
governor.noteLaunchIntent('team-a', 'launch');
|
|
governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' });
|
|
await governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
});
|
|
governor.noteProvisioningProgress(progress('team-a', 'ready'));
|
|
|
|
now += 100;
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
await flushMicrotasks();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('deferred refresh failed'));
|
|
governor.noteLaunchIntent('team-b', 'launch');
|
|
await expect(
|
|
governor.runSummaryOperation('teams:list', loadFresh, {
|
|
clone: cloneLaunchIoGovernorPayload,
|
|
})
|
|
).resolves.toEqual([team('old')]);
|
|
});
|
|
});
|