// @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) }) }) // ── 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', 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', true) const url = mockFetch.mock.calls[0]?.[0] as string expect(url).toContain('staged=true') }) it('returns null on non-ok response', async () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) }))) const result = await fetchDiff('/repo', 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', 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', 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() }) })