/** * @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(); });