// @vitest-environment jsdom /** * test/diff.test.ts — N-diff-ui: diff viewer render-only frontend module * * Covers: normalizeDiffResult, fetchDiff, renderDiffFile, renderDiff, * mountDiffViewer (working/staged toggle, truncated warning, empty state). * * Security: SEC-H4 — all diff content via textContent, zero innerHTML. * Accept: AC-B1.3, AC-B1.4 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { DiffResult, DiffFile, DiffLine, DiffHunk } from '../src/types.js' // ── module import ───────────────────────────────────────────────────────────── const { normalizeDiffResult, fetchDiff, renderDiffFile, renderDiff, mountDiffViewer, } = await import('../public/diff.js') // ── helpers ─────────────────────────────────────────────────────────────────── function makeLine(kind: DiffLine['kind'], text: string): DiffLine { return { kind, text } } function makeHunk(header: string, lines: DiffLine[] = []): DiffHunk { return { header, lines } } function makeFile(over: Partial = {}): DiffFile { return { oldPath: 'src/foo.ts', newPath: 'src/foo.ts', status: 'modified', added: 2, removed: 1, binary: false, hunks: [makeHunk('@@ -1,3 +1,4 @@', [ makeLine('context', ' line1'), makeLine('removed', '-old'), makeLine('added', '+new'), makeLine('added', '+extra'), ])], ...over, } } function makeResult(over: Partial = {}): DiffResult { return { files: [makeFile()], staged: false, truncated: false, ...over, } } beforeEach(() => { vi.restoreAllMocks() }) afterEach(() => { vi.restoreAllMocks() }) // ── normalizeDiffResult ─────────────────────────────────────────────────────── describe('normalizeDiffResult', () => { it('returns null for null input', () => { expect(normalizeDiffResult(null)).toBeNull() }) it('returns null for non-object input', () => { expect(normalizeDiffResult('string')).toBeNull() expect(normalizeDiffResult(42)).toBeNull() expect(normalizeDiffResult(undefined)).toBeNull() }) it('returns null when files is not an array', () => { expect(normalizeDiffResult({ files: 'bad', staged: false, truncated: false })).toBeNull() }) it('returns null when staged is not a boolean', () => { expect(normalizeDiffResult({ files: [], staged: 'yes', truncated: false })).toBeNull() }) it('returns null when truncated is not a boolean', () => { expect(normalizeDiffResult({ files: [], staged: false, truncated: 'yes' })).toBeNull() }) it('returns a valid DiffResult for a well-formed object', () => { const raw = makeResult() const result = normalizeDiffResult(raw) expect(result).not.toBeNull() expect(result?.staged).toBe(false) expect(result?.truncated).toBe(false) expect(result?.files).toHaveLength(1) }) it('filters out invalid file entries (keeps only valid ones)', () => { const raw = { files: [ makeFile(), { not: 'a file' }, null, ], staged: false, truncated: false, } const result = normalizeDiffResult(raw) expect(result?.files).toHaveLength(1) }) it('accepts an empty files array (empty diff)', () => { const result = normalizeDiffResult({ files: [], staged: false, truncated: false }) expect(result).not.toBeNull() expect(result?.files).toHaveLength(0) }) it('returns DiffResult with staged=true when input has staged=true', () => { const result = normalizeDiffResult({ files: [], staged: true, truncated: false }) expect(result?.staged).toBe(true) }) it('returns DiffResult with truncated=true when input has truncated=true', () => { const result = normalizeDiffResult({ files: [], staged: false, truncated: true }) expect(result?.truncated).toBe(true) }) it('passes through an optional base revision (FR-B1.9)', () => { const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 'main' }) expect(result).not.toBeNull() expect(result?.base).toBe('main') }) it('leaves base undefined when the payload omits it', () => { const result = normalizeDiffResult({ files: [], staged: false, truncated: false }) expect(result).not.toBeNull() expect(result?.base).toBeUndefined() }) it('ignores a non-string base (coerces to undefined)', () => { const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 123 }) expect(result).not.toBeNull() expect(result?.base).toBeUndefined() }) }) // ── fetchDiff ───────────────────────────────────────────────────────────────── describe('fetchDiff', () => { it('fetches from /projects/diff with correct query params (working tree)', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ staged: false }), })) vi.stubGlobal('fetch', mockFetch) const result = await fetchDiff('/some/repo', { staged: false }) expect(mockFetch).toHaveBeenCalledOnce() const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain('/projects/diff') expect(url).toContain(encodeURIComponent('/some/repo')) expect(url).toContain('staged=false') expect(result).not.toBeNull() }) it('fetches with staged=true when staged is true', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ staged: true }), })) vi.stubGlobal('fetch', mockFetch) await fetchDiff('/repo', { staged: true }) const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain('staged=true') }) it('defaults to staged=false when called with no options', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() })) vi.stubGlobal('fetch', mockFetch) await fetchDiff('/repo') const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain('staged=false') }) it('builds a &base= URL (omitting staged) when base is set (FR-B1.9)', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ base: 'main' }), })) vi.stubGlobal('fetch', mockFetch) const result = await fetchDiff('/repo', { base: 'main' }) const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain('base=main') expect(url).not.toContain('staged=') expect(result?.base).toBe('main') }) it('URL-encodes a base containing slashes (feature/x)', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() })) vi.stubGlobal('fetch', mockFetch) await fetchDiff('/repo', { base: 'feature/x' }) const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain(`base=${encodeURIComponent('feature/x')}`) }) it('returns null on non-ok response', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) }))) const result = await fetchDiff('/repo', { staged: false }) expect(result).toBeNull() }) it('returns null on fetch error (network failure)', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') })) const result = await fetchDiff('/repo', { staged: false }) expect(result).toBeNull() }) it('returns null when server returns invalid structure', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ not: 'a diff result' }), }))) const result = await fetchDiff('/repo', { staged: false }) expect(result).toBeNull() }) }) // ── renderDiffFile ──────────────────────────────────────────────────────────── describe('renderDiffFile', () => { it('renders the file path as plain text (newPath)', () => { const file = makeFile({ newPath: 'src/component.ts' }) const el = renderDiffFile(file) expect(el.textContent).toContain('src/component.ts') }) it('renders +/- stats badge with correct counts', () => { const file = makeFile({ added: 5, removed: 3 }) const el = renderDiffFile(file) const text = el.textContent ?? '' expect(text).toContain('+5') expect(text).toContain('-3') }) it('renders hunk header as text', () => { const file = makeFile({ hunks: [makeHunk('@@ -1,3 +1,4 @@', [])], }) const el = renderDiffFile(file) expect(el.textContent).toContain('@@ -1,3 +1,4 @@') }) it('renders added/removed/context lines as plain text (SEC-H4)', () => { const file = makeFile({ hunks: [makeHunk('@@ -1 +1 @@', [ makeLine('added', '+new line'), makeLine('removed', '-old line'), makeLine('context', ' ctx line'), ])], }) const el = renderDiffFile(file) const text = el.textContent ?? '' expect(text).toContain('+new line') expect(text).toContain('-old line') expect(text).toContain(' ctx line') }) it('renders ' const file = makeFile({ hunks: [makeHunk('@@ -1 +1 @@', [ makeLine('added', xssLine), ])], }) const el = renderDiffFile(file) // The text should be present expect(el.textContent).toContain(xssLine) // But no actual ')])], })], }) const el = renderDiff(result) expect(el.textContent).toContain('') expect(el.querySelectorAll('script').length).toBe(0) }) it('returns an HTMLElement', () => { expect(renderDiff(makeResult())).toBeInstanceOf(HTMLElement) }) }) // ── mountDiffViewer ─────────────────────────────────────────────────────────── describe('mountDiffViewer', () => { function makeContainer(): HTMLDivElement { return document.createElement('div') } it('renders into the container on mount', async () => { const result = makeResult() vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => result, }))) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) // let async fetch settle expect(container.children.length).toBeGreaterThan(0) handle.destroy() }) it('shows working tree by default (staged=false)', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ staged: false }), })) vi.stubGlobal('fetch', mockFetch) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain('staged=false') handle.destroy() }) it('can switch to staged view', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ staged: true }), })) vi.stubGlobal('fetch', mockFetch) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) // Switch to staged handle.showStaged() await new Promise((r) => setTimeout(r, 0)) // Should have fetched twice: once working, once staged expect(mockFetch).toHaveBeenCalledTimes(2) const secondUrl = mockFetch.mock.calls[1]?.[0] as string expect(secondUrl).toContain('staged=true') handle.destroy() }) it('can switch back to working view', async () => { const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult(), })) vi.stubGlobal('fetch', mockFetch) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) handle.showStaged() await new Promise((r) => setTimeout(r, 0)) handle.showWorking() await new Promise((r) => setTimeout(r, 0)) expect(mockFetch).toHaveBeenCalledTimes(3) const thirdUrl = mockFetch.mock.calls[2]?.[0] as string expect(thirdUrl).toContain('staged=false') handle.destroy() }) it('calls onClose when the close action fires', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult(), }))) const onClose = vi.fn() const container = makeContainer() const handle = mountDiffViewer(container, '/repo', { onClose }) await new Promise((r) => setTimeout(r, 0)) handle.close() expect(onClose).toHaveBeenCalledOnce() handle.destroy() }) it('shows error/empty state when fetch fails (does not throw)', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') })) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) // Should not throw and should render something expect(container.children.length).toBeGreaterThan(0) handle.destroy() }) it('destroy removes content from container', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult(), }))) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) handle.destroy() expect(container.children.length).toBe(0) }) it('handles empty diff gracefully (no throw)', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult({ files: [] }), }))) const container = makeContainer() const handle = mountDiffViewer(container, '/some/repo', {}) await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined() handle.destroy() }) // ── FR-B1.9: base-branch picker ───────────────────────────────────────────── it('renders NO base picker when bases is absent (backward-compatible)', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() }))) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', {}) await new Promise((r) => setTimeout(r, 0)) expect(container.querySelector('.df-base-select')).toBeNull() handle.destroy() }) it('renders NO base picker when bases is empty', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() }))) const container = makeContainer() const handle = mountDiffViewer(container, '/repo', { bases: [] }) await new Promise((r) => setTimeout(r, 0)) expect(container.querySelector('.df-base-select')).toBeNull() handle.destroy() }) it('renders a