// @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' const HEAD = 'a\nb\nc\n' const INDEX = 'a\nSTAGED\nc\n' const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n' /** Minimal preload bridge: enough for the store to boot with real git rows. */ function stubBridge(): void { const noop = (): void => {} const off = (): (() => void) => noop ;(window as unknown as { helder: unknown }).helder = { platform: 'darwin', clipboard: { writeText: noop, readText: () => '' }, project: { current: async () => ({ root: '/repo', name: 'repo' }), open: async () => ({ root: '/repo', name: 'repo' }), openPath: async () => ({ root: '/repo', name: 'repo' }), recent: async () => [], }, fs: { tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }), readDir: async () => [], files: async () => ({ 'demo.txt': DISK }), read: async () => DISK, imageDataUrl: async () => '', write: async () => {}, delete: async () => {}, create: async () => {}, mkdir: async () => {}, }, shell: { reveal: noop }, notes: { read: async () => '', write: async () => {} }, git: { // Exactly what git-service now returns for porcelain "MM". load: async () => ({ branch: 'main', changes: [ { path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX }, { path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK }, ], }), stage: async () => {}, unstage: async () => {}, commit: async () => {}, push: async () => ({ ok: true, message: '' }), discard: async () => {}, }, pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off }, config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' }, recent: { get: async () => [], set: async () => {} }, search: { content: async () => [], files: async () => [] }, dialog: { unsavedClose: async () => 'cancel' }, log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} }, onProjectChanged: off, onConfigChanged: off, onRefresh: off, } } 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() delete (window as unknown as { helder?: unknown }).helder }) /** Row text of the view currently on screen. */ function viewText(c: HTMLElement): string { return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n') } 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 } 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() fireEvent.click(group(c, 'Staged Changes')[0]) await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) const text = viewText(c) expect(text).toContain('b') expect(text).toContain('STAGED') // The later edit is not part of what is staged, so it must not show here. expect(text).not.toContain('AFTER-STAGING') }) it('Diff on the unstaged row compares the staged copy with disk', async () => { const c = await boot() fireEvent.click(group(c, 'Changes')[0]) await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) const text = viewText(c) expect(text).toContain('AFTER-STAGING') // 'b' was already replaced before staging, so this half must not mention it. expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false) }) it('labels which pair the diff is comparing', async () => { const c = await boot() fireEvent.click(group(c, 'Staged Changes')[0]) await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged')) fireEvent.click(group(c, 'Changes')[0]) await waitFor(() => expect(c.querySelector('.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) }) })