/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act, render } from '@testing-library/react';
import { render as inkRender } from 'ink-testing-library';
import React from 'react';
import { BranchListScreen } from '../../../components/screens/BranchListScreen.js';
import type { BranchInfo, BranchItem, Statistics } from '../../../types.js';
import { formatBranchItem } from '../../../utils/branchFormatter.js';
import stringWidth from 'string-width';
import { Window } from 'happy-dom';
const stripAnsi = (value: string): string => value.replace(/\u001b\[[0-9;]*m/g, '');
const stripControlSequences = (value: string): string =>
value.replace(/\u001b\[([0-9;?]*)([A-Za-z])/g, (_, params, command) => {
if (command === 'C') {
const count = Number(params || '1');
return ' '.repeat(Number.isNaN(count) ? 0 : count);
}
return '';
});
describe('BranchListScreen', () => {
beforeEach(() => {
vi.useFakeTimers();
// Setup happy-dom
const window = new Window();
globalThis.window = window as any;
globalThis.document = window.document as any;
});
afterEach(() => {
vi.useRealTimers();
});
const mockBranches: BranchItem[] = [
{
name: 'main',
type: 'local',
branchType: 'main',
isCurrent: true,
icons: ['⚡', '⭐'],
hasChanges: false,
label: '⚡ ⭐ main',
value: 'main',
latestCommitTimestamp: 1_700_000_000,
},
{
name: 'feature/test',
type: 'local',
branchType: 'feature',
isCurrent: false,
icons: ['✨'],
hasChanges: false,
label: '✨ feature/test',
value: 'feature/test',
latestCommitTimestamp: 1_699_000_000,
},
];
const mockStats: Statistics = {
localCount: 2,
remoteCount: 1,
worktreeCount: 0,
changesCount: 0,
lastUpdated: new Date(),
};
it('should render header with title', () => {
const onSelect = vi.fn();
const { getByText } = render(
);
expect(getByText(/Claude Worktree/i)).toBeDefined();
});
it('should render statistics', () => {
const onSelect = vi.fn();
const { container, getByText } = render(
);
expect(container.textContent).toContain('Local: 2');
expect(getByText(/Remote:/)).toBeDefined();
});
it('should render branch list', () => {
const onSelect = vi.fn();
const { getByText } = render(
);
expect(getByText(/main/)).toBeDefined();
expect(getByText(/feature\/test/)).toBeDefined();
});
it('should render footer with actions', () => {
const onSelect = vi.fn();
const { getAllByText } = render(
);
// Check for enter key (main screen doesn't have q key, exit is Ctrl+C only)
expect(getAllByText(/enter/i).length).toBeGreaterThan(0);
});
it('should handle empty branch list', () => {
const onSelect = vi.fn();
const emptyStats: Statistics = {
localCount: 0,
remoteCount: 0,
worktreeCount: 0,
changesCount: 0,
lastUpdated: new Date(),
};
const { container } = render(
);
expect(container).toBeDefined();
});
it('should display loading indicator after the configured delay', async () => {
const onSelect = vi.fn();
const { queryByText, getByText } = render(
);
await act(async () => {
if (typeof (vi as any).advanceTimersByTime === 'function') {
(vi as any).advanceTimersByTime(10);
} else {
await new Promise((resolve) => setTimeout(resolve, 10));
}
});
expect(getByText(/Git情報を読み込んでいます/i)).toBeDefined();
});
it('should display error state', () => {
const onSelect = vi.fn();
const error = new Error('Failed to load branches');
const { getByText } = render(
);
expect(getByText(/Error:/i)).toBeDefined();
expect(getByText(/Failed to load branches/i)).toBeDefined();
});
it('should use terminal height for layout calculation', () => {
const onSelect = vi.fn();
// Mock process.stdout
const originalRows = process.stdout.rows;
process.stdout.rows = 30;
const { container } = render(
);
expect(container).toBeDefined();
// Restore
process.stdout.rows = originalRows;
});
it('should display branch icons', () => {
const onSelect = vi.fn();
const { getByText } = render(
);
// Check for icons in labels
expect(getByText(/⚡/)).toBeDefined(); // main icon
expect(getByText(/⭐/)).toBeDefined(); // current icon
expect(getByText(/✨/)).toBeDefined(); // feature icon
});
it('should render latest commit timestamp for each branch', () => {
const onSelect = vi.fn();
const { container } = render(
);
const textContent = container.textContent ?? '';
const matches = textContent.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/g) ?? [];
expect(matches.length).toBe(mockBranches.length);
});
it('should highlight the selected branch with cyan background', async () => {
process.env.FORCE_COLOR = '1';
const onSelect = vi.fn();
let renderResult: ReturnType;
await act(async () => {
renderResult = inkRender(
,
{ stripAnsi: false }
);
});
const frame = renderResult!.lastFrame() ?? '';
expect(frame).toContain('\u001b[46m'); // cyan background ANSI code
});
it('should align timestamps even when unpushed icon is displayed', async () => {
process.env.FORCE_COLOR = '1';
const onSelect = vi.fn();
const originalColumns = process.stdout.columns;
process.stdout.columns = 94;
const branchInfos: BranchInfo[] = [
{
name: 'feature/update-ui',
type: 'local',
branchType: 'feature',
isCurrent: false,
hasUnpushedCommits: true,
latestCommitTimestamp: 1_700_000_000,
},
{
name: 'origin/main',
type: 'remote',
branchType: 'main',
isCurrent: false,
hasUnpushedCommits: false,
latestCommitTimestamp: 1_699_999_000,
},
{
name: 'main',
type: 'local',
branchType: 'main',
isCurrent: true,
hasUnpushedCommits: false,
latestCommitTimestamp: 1_699_998_000,
},
];
const branchesWithUnpushed: BranchItem[] = branchInfos.map((branch) =>
formatBranchItem(branch)
);
try {
let renderResult: ReturnType;
await act(async () => {
renderResult = inkRender(
,
{ stripAnsi: false }
);
});
const frame = renderResult!.lastFrame() ?? '';
const timestampLines = frame
.split('\n')
.map((line) => stripControlSequences(stripAnsi(line)))
.filter((line) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(line));
expect(timestampLines.length).toBeGreaterThanOrEqual(3);
const timestampWidths = timestampLines.map((line) => {
const match = line.match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
const index = match?.index ?? 0;
const beforeTimestamp = line.slice(0, index);
let width = 0;
for (const char of Array.from(beforeTimestamp)) {
if (char === '\u2B06' || char === '\u2601') {
width += 1;
continue;
}
width += stringWidth(char);
}
return width;
});
const uniquePositions = new Set(timestampWidths);
expect(uniquePositions.size).toBe(1);
} finally {
process.stdout.columns = originalColumns;
}
});
});