// @vitest-environment jsdom // // A file can be staged and then edited again. Git calls that "MM": two rows, // one per group. These tests drive the renderer with such a payload and check // that Diff follows the row you clicked, while Original and Actual do not. import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import React from 'react' vi.mock('../src/renderer/src/terminals', () => { let n = 0 return { Terminal: () => null, lid: () => ++n } }) import { App } from '../src/renderer/src/App' import { ProjectProvider } from '../src/renderer/src/project' import { installBridge, removeBridge } from './stub-bridge' const HEAD = 'a\nb\nc\n' const INDEX = 'a\nSTAGED\nc\n' const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n' /** Exactly what git-service returns for porcelain "MM". */ function stubBridge(): void { installBridge( [ { path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX }, { path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK }, ], { 'demo.txt': DISK }, ) } beforeAll(() => { globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {} }) beforeEach(stubBridge) afterEach(() => { cleanup() localStorage.clear() removeBridge() }) /** Row text of the read-only view currently on screen. */ function viewText(c: HTMLElement): string { return Array.from(c.querySelectorAll('.editor-wrap .ln-row')).map((el) => el.textContent ?? '').join('\n') } /** The two panes of the full-screen Diff, as line text. */ function splitText(c: HTMLElement, pane: 'left' | 'right'): string[] { return Array.from(c.querySelectorAll(`.split-pane.${pane} .ln-row`)).map((el) => (el.textContent ?? '').replace(/^\d*/, '')) } function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] { const heads = Array.from(c.querySelectorAll('.git-group')) const head = heads.find((h) => h.textContent?.startsWith(label))! const rows: HTMLElement[] = [] for (let el = head.nextElementSibling; el; el = el.nextElementSibling) { if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break if (el.classList.contains('git-row')) rows.push(el as HTMLElement) } return rows } async function boot(): Promise { const c = render().container await waitFor(() => { if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready') }) return c } /** Open one git row, then lift its pair into the full-screen Diff. */ async function openSplit(c: HTMLElement, label: 'Staged Changes' | 'Changes'): Promise { fireEvent.click(group(c, label)[0]) await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) fireEvent.click(Array.from(c.querySelectorAll('.seg button')).find((b) => b.textContent === 'Diff')!) await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy()) } describe('a file that is staged and then edited again', () => { it('shows up in both groups', async () => { const c = await boot() expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt']) expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt']) }) it('Diff on the staged row compares HEAD with the staged copy', async () => { const c = await boot() await openSplit(c, 'Staged Changes') expect(splitText(c, 'left')).toEqual(['a', 'b', 'c']) expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c']) // The later edit is not part of what is staged, so neither side may show it. expect(splitText(c, 'right').join('\n')).not.toContain('AFTER-STAGING') }) it('Diff on the unstaged row compares the staged copy with disk', async () => { const c = await boot() await openSplit(c, 'Changes') // 'b' was already replaced before staging, so this half must not mention it. expect(splitText(c, 'left')).toEqual(['a', 'STAGED', 'c', '']) expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING']) }) it('the hover panel behind Actual always reaches back to HEAD', async () => { const c = await boot() fireEvent.click(group(c, 'Changes')[0]) const scroll = await waitFor(() => { const el = c.querySelector('.ce-scroll') if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready') return el }) // Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20. fireEvent.mouseMove(scroll, { clientY: 10 + 20 + 5 }) const peek = await waitFor(() => { const p = c.querySelector('.peek') if (!p) throw new Error('peek not ready') return p }) const removed = Array.from(peek.querySelectorAll('.ln-row.del')) expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b']) fireEvent.mouseLeave(scroll) await waitFor(() => expect(c.querySelector('.peek')).toBeNull()) }) it('labels which pair the diff is comparing', async () => { const c = await boot() await openSplit(c, 'Staged Changes') expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('HEAD → staged') await openSplit(c, 'Changes') expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('staged → actual') }) it('Original stays HEAD and Actual stays the file on disk, from either row', async () => { const c = await boot() for (const label of ['Staged Changes', 'Changes'] as const) { fireEvent.click(group(c, label)[0]) await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) const original = Array.from(c.querySelectorAll('.seg button')).find((b) => b.textContent === 'Original')! fireEvent.click(original) await waitFor(() => expect(viewText(c)).toContain('b')) expect(viewText(c)).not.toContain('AFTER-STAGING') const actual = Array.from(c.querySelectorAll('.seg button')).find((b) => b.textContent === 'Actual')! fireEvent.click(actual) await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy()) expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK) } }) it('only the row you opened is highlighted', async () => { const c = await boot() fireEvent.click(group(c, 'Changes')[0]) await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy()) expect(c.querySelectorAll('.git-row.active')).toHaveLength(1) expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true) }) })