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 = {
|
2026-06-06 23:22:16 +03:00
|
|
|
status: 'ready',
|
|
|
|
|
error: null,
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
};
|
2026-07-19 00:00:31 +03:00
|
|
|
let getBootstrapStatus = (): Promise<GitWorktreeBootstrapStatus> => Promise.resolve(bootstrapStatusResult);
|
2026-06-06 23:22:16 +03:00
|
|
|
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,
|
2026-06-06 23:22:16 +03:00
|
|
|
getWorktreeBootstrapState,
|
2026-06-03 14:36:59 +03:00
|
|
|
markWorktreeBootstrapPending,
|
2026-07-19 00:00:31 +03:00
|
|
|
setWorktreeBootstrapState,
|
2026-06-06 23:22:16 +03:00
|
|
|
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');
|
|
|
|
|
|
2026-06-06 23:22:16 +03:00
|
|
|
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;
|
2026-06-06 23:22:16 +03:00
|
|
|
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-06-06 23:22:16 +03:00
|
|
|
|
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']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-06 23:22:16 +03:00
|
|
|
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
|
|
|
});
|