/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
import type { Mock } from 'vitest';
import { render, act, waitFor } from '@testing-library/react';
import React from 'react';
import type { BranchItem, CleanupTarget } from '../../types.js';
import { Window } from 'happy-dom';
import * as useGitDataModule from '../../hooks/useGitData.js';
import * as useScreenStateModule from '../../hooks/useScreenState.js';
import * as WorktreeManagerScreenModule from '../../components/screens/WorktreeManagerScreen.js';
import * as BranchCreatorScreenModule from '../../components/screens/BranchCreatorScreen.js';
import * as BranchListScreenModule from '../../components/screens/BranchListScreen.js';
import * as worktreeModule from '../../../worktree.js';
import * as gitModule from '../../../git.js';
import { App } from '../../components/App.js';
const navigateToMock = vi.fn();
const goBackMock = vi.fn();
const resetMock = vi.fn();
const worktreeScreenProps: any[] = [];
const branchCreatorProps: any[] = [];
const branchListProps: any[] = [];
const originalUseGitData = useGitDataModule.useGitData;
const originalUseScreenState = useScreenStateModule.useScreenState;
const originalWorktreeManagerScreen = WorktreeManagerScreenModule.WorktreeManagerScreen;
const originalBranchCreatorScreen = BranchCreatorScreenModule.BranchCreatorScreen;
const originalBranchListScreen = BranchListScreenModule.BranchListScreen;
const originalGetMergedPRWorktrees = worktreeModule.getMergedPRWorktrees;
const originalGenerateWorktreePath = worktreeModule.generateWorktreePath;
const originalCreateWorktree = worktreeModule.createWorktree;
const originalRemoveWorktree = worktreeModule.removeWorktree;
const originalGetRepositoryRoot = gitModule.getRepositoryRoot;
const originalDeleteBranch = gitModule.deleteBranch;
const useGitDataSpy = vi.spyOn(useGitDataModule, 'useGitData');
const useScreenStateSpy = vi.spyOn(useScreenStateModule, 'useScreenState');
const worktreeManagerScreenSpy = vi.spyOn(WorktreeManagerScreenModule, 'WorktreeManagerScreen');
const branchCreatorScreenSpy = vi.spyOn(BranchCreatorScreenModule, 'BranchCreatorScreen');
const branchListScreenSpy = vi.spyOn(BranchListScreenModule, 'BranchListScreen');
const getMergedPRWorktreesSpy = vi.spyOn(worktreeModule, 'getMergedPRWorktrees');
const generateWorktreePathSpy = vi.spyOn(worktreeModule, 'generateWorktreePath');
const createWorktreeSpy = vi.spyOn(worktreeModule, 'createWorktree');
const removeWorktreeSpy = vi.spyOn(worktreeModule, 'removeWorktree');
const getRepositoryRootSpy = vi.spyOn(gitModule, 'getRepositoryRoot');
const deleteBranchSpy = vi.spyOn(gitModule, 'deleteBranch');
describe('App shortcuts integration', () => {
beforeEach(() => {
if (typeof globalThis.document === 'undefined') {
const window = new Window();
globalThis.window = window as any;
globalThis.document = window.document as any;
}
worktreeScreenProps.length = 0;
branchCreatorProps.length = 0;
branchListProps.length = 0;
navigateToMock.mockClear();
goBackMock.mockClear();
resetMock.mockClear();
useGitDataSpy.mockImplementation(() => ({
branches: [],
worktrees: [
{
branch: 'feature/existing',
path: '/worktrees/feature-existing',
isAccessible: true,
},
],
loading: false,
error: null,
refresh: vi.fn(),
lastUpdated: null,
}));
useScreenStateSpy.mockImplementation(() => ({
currentScreen: 'worktree-manager',
navigateTo: navigateToMock,
goBack: goBackMock,
reset: resetMock,
}));
worktreeManagerScreenSpy.mockImplementation((props: any) => {
worktreeScreenProps.push(props);
return React.createElement(originalWorktreeManagerScreen, props);
});
branchCreatorScreenSpy.mockImplementation((props: any) => {
branchCreatorProps.push(props);
return React.createElement(originalBranchCreatorScreen, props);
});
branchListScreenSpy.mockImplementation((props: any) => {
branchListProps.push(props);
return React.createElement(originalBranchListScreen, props);
});
getMergedPRWorktreesSpy.mockResolvedValue([
{
branch: 'feature/add-new-feature',
cleanupType: 'worktree-and-branch',
pullRequest: {
number: 123,
title: 'Add new feature',
branch: 'feature/add-new-feature',
mergedAt: '2025-01-20T10:00:00Z',
author: 'user1',
},
worktreePath: '/worktrees/feature-add-new-feature',
hasUncommittedChanges: false,
hasUnpushedCommits: false,
hasRemoteBranch: true,
isAccessible: true,
},
{
branch: 'hotfix/urgent-fix',
cleanupType: 'worktree-and-branch',
pullRequest: {
number: 456,
title: 'Urgent fix',
branch: 'hotfix/urgent-fix',
mergedAt: '2025-01-21T09:00:00Z',
author: 'user2',
},
worktreePath: '/worktrees/hotfix-urgent-fix',
hasUncommittedChanges: true,
hasUnpushedCommits: false,
hasRemoteBranch: true,
isAccessible: true,
},
] as CleanupTarget[]);
generateWorktreePathSpy.mockResolvedValue('/worktrees/new-branch');
createWorktreeSpy.mockResolvedValue(undefined);
removeWorktreeSpy.mockResolvedValue(undefined);
getRepositoryRootSpy.mockResolvedValue('/repo');
deleteBranchSpy.mockResolvedValue(undefined);
});
afterEach(() => {
useGitDataSpy.mockReset();
useScreenStateSpy.mockReset();
worktreeManagerScreenSpy.mockReset();
branchCreatorScreenSpy.mockReset();
branchListScreenSpy.mockReset();
getMergedPRWorktreesSpy.mockReset();
generateWorktreePathSpy.mockReset();
createWorktreeSpy.mockReset();
removeWorktreeSpy.mockReset();
getRepositoryRootSpy.mockReset();
deleteBranchSpy.mockReset();
useGitDataSpy.mockImplementation(originalUseGitData);
useScreenStateSpy.mockImplementation(originalUseScreenState);
worktreeManagerScreenSpy.mockImplementation(originalWorktreeManagerScreen as any);
branchCreatorScreenSpy.mockImplementation(originalBranchCreatorScreen as any);
branchListScreenSpy.mockImplementation(originalBranchListScreen as any);
getMergedPRWorktreesSpy.mockImplementation(originalGetMergedPRWorktrees as any);
generateWorktreePathSpy.mockImplementation(originalGenerateWorktreePath as any);
createWorktreeSpy.mockImplementation(originalCreateWorktree as any);
removeWorktreeSpy.mockImplementation(originalRemoveWorktree as any);
getRepositoryRootSpy.mockImplementation(originalGetRepositoryRoot as any);
deleteBranchSpy.mockImplementation(originalDeleteBranch as any);
});
it('navigates to AI tool selector when worktree is selected', () => {
const onExit = vi.fn();
render();
expect(worktreeScreenProps).not.toHaveLength(0);
const { onSelect, worktrees } = worktreeScreenProps[0];
expect(worktrees).toHaveLength(1);
onSelect(worktrees[0]);
expect(navigateToMock).toHaveBeenCalledWith('ai-tool-selector');
});
it('creates new worktree when branch creator submits', async () => {
const onExit = vi.fn();
// Update screen state mock to branch-creator for this test
useScreenStateSpy.mockReturnValue({
currentScreen: 'branch-creator',
navigateTo: navigateToMock,
goBack: goBackMock,
reset: resetMock,
});
render();
expect(branchCreatorProps).not.toHaveLength(0);
const { onCreate } = branchCreatorProps[0];
await act(async () => {
await onCreate('feature/new-branch');
});
expect(createWorktreeSpy).toHaveBeenCalledWith(
expect.objectContaining({
branchName: 'feature/new-branch',
isNewBranch: true,
})
);
expect(navigateToMock).toHaveBeenCalledWith('ai-tool-selector');
});
it('displays per-branch cleanup indicators and waits before clearing results', async () => {
vi.useFakeTimers();
try {
const onExit = vi.fn();
let resolveRemoveWorktree: (() => void) | undefined;
let resolveDeleteBranch: (() => void) | undefined;
removeWorktreeSpy.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRemoveWorktree = resolve;
})
);
deleteBranchSpy.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDeleteBranch = resolve;
})
);
useScreenStateSpy.mockReturnValue({
currentScreen: 'branch-list',
navigateTo: navigateToMock,
goBack: goBackMock,
reset: resetMock,
});
render();
expect(branchListProps).not.toHaveLength(0);
const initialProps = branchListProps.at(-1);
expect(initialProps).toBeDefined();
if (!initialProps) {
throw new Error('BranchListScreen props missing');
}
act(() => {
initialProps.onCleanupCommand?.();
});
await act(async () => {
await Promise.resolve();
});
let latestProps = branchListProps.at(-1);
expect(latestProps?.cleanupUI?.inputLocked).toBe(true);
expect(latestProps?.cleanupUI?.footerMessage?.text).toBeTruthy();
expect(latestProps?.cleanupUI?.indicators).toMatchObject({
'feature/add-new-feature': expect.objectContaining({ icon: expect.stringMatching(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧/) }),
'hotfix/urgent-fix': expect.objectContaining({ icon: '⏳' }),
});
resolveRemoveWorktree?.();
await act(async () => {
await Promise.resolve();
});
resolveDeleteBranch?.();
expect(removeWorktreeSpy).toHaveBeenCalledWith(
'/worktrees/feature-add-new-feature',
true
);
expect(deleteBranchSpy).toHaveBeenCalledWith('feature/add-new-feature', true);
// Flush state updates after processing first target
await act(async () => {
await Promise.resolve();
});
latestProps = branchListProps.at(-1);
expect(latestProps?.cleanupUI?.indicators).toMatchObject({
'feature/add-new-feature': { icon: '✅' },
'hotfix/urgent-fix': { icon: '⏭️' },
});
expect(latestProps?.cleanupUI?.inputLocked).toBe(false);
// Advance 3 seconds to allow UI to clear
await act(async () => {
vi.advanceTimersByTime(3000);
await Promise.resolve();
});
latestProps = branchListProps.at(-1);
expect(latestProps?.cleanupUI?.indicators).toEqual({});
expect(latestProps?.cleanupUI?.inputLocked).toBe(false);
expect(latestProps?.branches?.some((branch: BranchItem) => branch.name === 'feature/add-new-feature')).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
afterAll(() => {
useGitDataSpy.mockRestore();
useScreenStateSpy.mockRestore();
worktreeManagerScreenSpy.mockRestore();
branchCreatorScreenSpy.mockRestore();
branchListScreenSpy.mockRestore();
getMergedPRWorktreesSpy.mockRestore();
generateWorktreePathSpy.mockRestore();
createWorktreeSpy.mockRestore();
removeWorktreeSpy.mockRestore();
getRepositoryRootSpy.mockRestore();
deleteBranchSpy.mockRestore();
});