Files
openchamber/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts
T

249 lines
8.8 KiB
TypeScript
Raw Normal View History

2026-06-03 14:36:59 +03:00
import { beforeEach, describe, expect, mock, test } from 'bun:test';
2026-07-19 00:00:31 +03:00
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
2026-06-03 14:36:59 +03:00
const bootstrapStatusCalls: string[] = [];
2026-07-19 00:00:31 +03:00
let bootstrapStatusResult: GitWorktreeBootstrapStatus = {
status: 'ready',
error: null,
updatedAt: 1,
};
2026-07-19 00:00:31 +03:00
let getBootstrapStatus = (): Promise<GitWorktreeBootstrapStatus> => Promise.resolve(bootstrapStatusResult);
const toastErrors: Array<{ title: string; description?: string }> = [];
mock.module('@/components/ui', () => ({
toast: {
error: (title: string, options?: { description?: string }) => {
toastErrors.push({ title, description: options?.description });
},
},
}));
mock.module('@/lib/i18n', () => ({
formatMessage: (_dictionary: Record<string, string>, key: string) => key,
useI18nStore: {
getState: () => ({ dictionary: {} }),
},
}));
2026-06-03 14:36:59 +03:00
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: () => ({
git: {
worktree: {
bootstrapStatus: (directory: string) => {
bootstrapStatusCalls.push(directory);
2026-07-19 00:00:31 +03:00
return getBootstrapStatus();
2026-06-03 14:36:59 +03:00
},
},
},
}),
}));
mock.module('@/lib/gitApiHttp', () => ({
getGitWorktreeBootstrapStatus: (directory: string) => {
bootstrapStatusCalls.push(directory);
2026-07-19 00:00:31 +03:00
return getBootstrapStatus();
2026-06-03 14:36:59 +03:00
},
}));
const {
clearWorktreeBootstrapState,
getWorktreeBootstrapState,
2026-06-03 14:36:59 +03:00
markWorktreeBootstrapPending,
2026-07-19 00:00:31 +03:00
setWorktreeBootstrapState,
startWorktreeBootstrapWatcher,
2026-06-03 14:36:59 +03:00
waitForWorktreeBootstrap,
2026-07-19 00:00:31 +03:00
waitForWorktreeGitReady,
2026-06-03 14:36:59 +03:00
} = await import('./worktreeBootstrap');
const waitFor = async (predicate: () => boolean): Promise<void> => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error('Timed out waiting for condition');
};
2026-06-03 14:36:59 +03:00
describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
beforeEach(() => {
bootstrapStatusCalls.length = 0;
toastErrors.length = 0;
2026-06-03 14:36:59 +03:00
bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 };
2026-07-19 00:00:31 +03:00
getBootstrapStatus = () => Promise.resolve(bootstrapStatusResult);
2026-06-03 14:36:59 +03:00
clearWorktreeBootstrapState('/repo');
clearWorktreeBootstrapState('/repo-wt');
});
test('does not poll directories that were not marked pending', async () => {
await waitForWorktreeBootstrap('/repo');
expect(bootstrapStatusCalls).toEqual([]);
});
test('polls when the directory was explicitly marked pending', async () => {
markWorktreeBootstrapPending('/repo-wt');
await waitForWorktreeBootstrap('/repo-wt');
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
});
2026-07-19 00:00:31 +03:00
test('git-ready wait does not wait for setup-ready', async () => {
setWorktreeBootstrapState('/repo-wt', {
status: 'pending',
phase: 'git-ready',
error: null,
updatedAt: 1,
});
await waitForWorktreeGitReady('/repo-wt');
expect(bootstrapStatusCalls).toEqual([]);
});
test('git-ready wait remains compatible with ready responses that omit phase', async () => {
markWorktreeBootstrapPending('/repo-wt');
await waitForWorktreeGitReady('/repo-wt');
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
expect(getWorktreeBootstrapState('/repo-wt')).toEqual(bootstrapStatusResult);
});
test('dedupes concurrent waiters for the same phase', async () => {
let resolveStatus!: (status: GitWorktreeBootstrapStatus) => void;
getBootstrapStatus = () => new Promise((resolve) => {
resolveStatus = resolve;
});
markWorktreeBootstrapPending('/repo-wt');
const first = waitForWorktreeGitReady('/repo-wt');
const second = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => bootstrapStatusCalls.length === 1);
resolveStatus({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
await Promise.all([first, second]);
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
});
test('does not let a cleared waiter restore stale state or remove a replacement waiter', async () => {
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
getBootstrapStatus = () => new Promise((resolve) => {
statusResolvers.push(resolve);
});
markWorktreeBootstrapPending('/repo-wt');
const staleWaiter = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => statusResolvers.length === 1);
clearWorktreeBootstrapState('/repo-wt');
markWorktreeBootstrapPending('/repo-wt');
const replacementWaiter = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => statusResolvers.length === 2);
statusResolvers[0]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
await expect(staleWaiter).rejects.toThrow('cancelled');
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('directory-created');
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
await replacementWaiter;
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
expect(bootstrapStatusCalls).toEqual(['/repo-wt', '/repo-wt']);
});
test('does not regress a newer phase when concurrent polls resolve out of order', async () => {
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
getBootstrapStatus = () => new Promise((resolve) => {
statusResolvers.push(resolve);
});
markWorktreeBootstrapPending('/repo-wt');
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
await waitFor(() => statusResolvers.length === 1);
const gitReadyWaiter = waitForWorktreeGitReady('/repo-wt');
await waitFor(() => statusResolvers.length === 2);
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
await gitReadyWaiter;
statusResolvers[0]({ status: 'pending', phase: 'directory-created', error: null, updatedAt: 2 });
await Promise.resolve();
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
clearWorktreeBootstrapState('/repo-wt');
});
test('full bootstrap wait continues through git-ready until setup-ready', async () => {
setWorktreeBootstrapState('/repo-wt', {
status: 'pending',
phase: 'git-ready',
error: null,
updatedAt: 1,
});
bootstrapStatusResult = {
status: 'ready',
phase: 'setup-ready',
error: null,
updatedAt: 2,
};
await waitForWorktreeBootstrap('/repo-wt');
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
});
test('background watcher polls pending worktrees without blocking', async () => {
markWorktreeBootstrapPending('/repo-wt');
const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
startWorktreeBootstrapWatcher('/repo-wt', {
pollIntervalMs: 0,
onReady: (status) => readyStatuses.push(status),
});
await waitFor(() => readyStatuses.length === 1);
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
expect(readyStatuses.map((status) => status.status)).toEqual(['ready']);
expect(toastErrors).toEqual([]);
});
test('background watcher shows a toast when bootstrap fails', async () => {
bootstrapStatusResult = { status: 'failed', error: 'setup failed', updatedAt: 2 };
markWorktreeBootstrapPending('/repo-wt');
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 0 });
await waitFor(() => toastErrors.length === 1);
expect(toastErrors).toEqual([{ title: 'worktree.bootstrap.toast.failed', description: 'setup failed' }]);
});
test('background watcher marks failed and toasts when bootstrap times out', async () => {
bootstrapStatusResult = { status: 'pending', error: null, updatedAt: 2 };
markWorktreeBootstrapPending('/repo-wt');
const failedStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
startWorktreeBootstrapWatcher('/repo-wt', {
timeoutMs: 0,
pollIntervalMs: 0,
onFailed: (status) => failedStatuses.push(status),
});
await waitFor(() => toastErrors.length === 1);
expect(getWorktreeBootstrapState('/repo-wt')?.status).toBe('failed');
expect(failedStatuses.map((status) => status.status)).toEqual(['failed']);
expect(toastErrors).toEqual([{
title: 'worktree.bootstrap.toast.failed',
description: 'worktree.bootstrap.toast.timeoutDescription',
}]);
});
test('background watcher is deduped per directory', async () => {
bootstrapStatusResult = { status: 'pending', error: null, updatedAt: 2 };
markWorktreeBootstrapPending('/repo-wt');
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
await waitFor(() => bootstrapStatusCalls.length === 1);
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
clearWorktreeBootstrapState('/repo-wt');
});
2026-06-03 14:36:59 +03:00
});