// @vitest-environment jsdom /** * test/git-log.test.ts (W3 quick-wins d) — recent-commit list (public/git-log.ts). * * Pure normalize/render + the mountGitLog wiring with a mocked fetch. Security: * commit subjects are attacker-influenced, so a subject containing an payload must appear verbatim as text (no HTML injection). A fetch * failure degrades to an inert message (best-effort, never throws). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { GitLogResult } from '../src/types.js' import { normalizeGitLog, renderGitLog, fetchGitLog, mountGitLog, } from '../public/git-log.js' function makeLog(over: Partial = {}): GitLogResult { return { commits: [ { hash: 'abc1234', at: Date.now() - 3600_000, subject: 'first commit' }, { hash: 'def5678', at: Date.now() - 7200_000, subject: 'second commit' }, ], truncated: false, ...over, } } function mockFetch(body: unknown, ok = true): ReturnType { const fn = vi.fn(async () => { if (body === null) throw new Error('network down') return { ok, json: async () => body } as Response }) vi.stubGlobal('fetch', fn) return fn } afterEach(() => { vi.unstubAllGlobals() }) // ── normalizeGitLog ─────────────────────────────────────────────────────────── describe('normalizeGitLog', () => { it('returns null for a non-object / missing commits array', () => { expect(normalizeGitLog(null)).toBeNull() expect(normalizeGitLog({ truncated: true })).toBeNull() }) it('keeps well-formed commits and drops malformed ones', () => { const out = normalizeGitLog({ commits: [ { hash: 'h1', at: 1000, subject: 'ok' }, { hash: 'h2', at: 'nope', subject: 'bad-at' }, { hash: 5, at: 1, subject: 'bad-hash' }, { at: 1, subject: 'missing-hash' }, ], truncated: true, }) expect(out?.commits.map((c) => c.hash)).toEqual(['h1']) expect(out?.truncated).toBe(true) }) }) // ── renderGitLog ────────────────────────────────────────────────────────────── describe('renderGitLog', () => { it('renders one row per commit with hash / time / subject', () => { const host = document.createElement('div') renderGitLog(host, makeLog()) const rows = host.querySelectorAll('.proj-commit-row') expect(rows).toHaveLength(2) expect(rows[0]?.querySelector('.proj-commit-hash')?.textContent).toBe('abc1234') expect(rows[0]?.querySelector('.proj-commit-subject')?.textContent).toBe('first commit') }) it('renders an empty note when there are no commits', () => { const host = document.createElement('div') renderGitLog(host, makeLog({ commits: [] })) expect(host.querySelector('.proj-empty')).not.toBeNull() expect(host.querySelector('.proj-commit-row')).toBeNull() }) it('shows a truncation note when truncated', () => { const host = document.createElement('div') renderGitLog(host, makeLog({ truncated: true })) expect(host.querySelector('.proj-commit-more')).not.toBeNull() }) it('renders a subject with an HTML payload as inert text (SEC-H5)', () => { const host = document.createElement('div') const xss = '' renderGitLog(host, makeLog({ commits: [{ hash: 'h1', at: Date.now(), subject: xss }] })) const subj = host.querySelector('.proj-commit-subject') expect(subj?.textContent).toBe(xss) // verbatim text expect(host.querySelectorAll('img').length).toBe(0) // no element injected }) }) // ── fetchGitLog / mountGitLog ───────────────────────────────────────────────── describe('fetchGitLog', () => { it('requests /projects/log with the encoded repo path', async () => { const fetchFn = mockFetch(makeLog()) await fetchGitLog('/home/u/my repo') expect(fetchFn).toHaveBeenCalledWith('/projects/log?path=%2Fhome%2Fu%2Fmy%20repo') }) it('returns null on a fetch failure (best-effort)', async () => { mockFetch(null) expect(await fetchGitLog('/x')).toBeNull() }) it('returns null on a non-ok response', async () => { mockFetch({}, false) expect(await fetchGitLog('/x')).toBeNull() }) }) describe('mountGitLog', () => { it('shows a loading placeholder, then swaps in commit rows', async () => { mockFetch(makeLog()) const host = document.createElement('div') mountGitLog(host, '/repo') expect(host.querySelector('.proj-commitlog-loading')).not.toBeNull() await vi.waitFor(() => { expect(host.querySelector('.proj-commit-row')).not.toBeNull() }) }) it('degrades to an inert message on a fetch failure', async () => { mockFetch(null) const host = document.createElement('div') mountGitLog(host, '/repo') await vi.waitFor(() => { expect(host.querySelector('.proj-empty')).not.toBeNull() }) }) it('destroy() clears the container and cancels the swap', async () => { mockFetch(makeLog()) const host = document.createElement('div') const handle = mountGitLog(host, '/repo') handle.destroy() // give the async swap a chance — it must not repopulate after destroy await new Promise((r) => setTimeout(r, 0)) expect(host.querySelector('.proj-commit-row')).toBeNull() }) }) /* ── w6/G4 unpushed rows + the upstream boundary ───────────────────────────── */ describe('renderGitLog — unpushed boundary (w6 G4)', () => { function entry(hash: string, subject: string, unpushed?: boolean) { return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) } } it('marks unpushed rows and draws the boundary exactly once, after the last one', () => { const host = document.createElement('div') renderGitLog(host, { commits: [ entry('aaa1111', 'newest local', true), entry('bbb2222', 'older local', true), entry('ccc3333', 'already pushed'), entry('ddd4444', 'also pushed'), ], truncated: false, upstream: 'origin/develop', }) expect(host.querySelectorAll('.proj-commit-unpushed').length).toBe(2) const boundaries = host.querySelectorAll('.proj-commit-boundary') expect(boundaries.length).toBe(1) expect(boundaries[0]!.textContent).toContain('origin/develop') const rows = Array.from(host.querySelectorAll('.proj-commit-row, .proj-commit-boundary')) expect(rows[2]!.classList.contains('proj-commit-boundary')).toBe(true) }) it('draws no boundary when nothing is unpushed', () => { const host = document.createElement('div') renderGitLog(host, { commits: [entry('aaa1111', 'pushed')], truncated: false, upstream: 'origin/main', }) expect(host.querySelector('.proj-commit-boundary')).toBeNull() expect(host.querySelector('.proj-commit-unpushed')).toBeNull() }) it('draws no boundary without an upstream, even if rows claim to be unpushed', () => { const host = document.createElement('div') renderGitLog(host, { commits: [entry('aaa1111', 'local', true)], truncated: false }) expect(host.querySelector('.proj-commit-boundary')).toBeNull() }) it('treats a non-true `unpushed` from the server as pushed', () => { const norm = normalizeGitLog({ commits: [{ hash: 'aaa1111', at: 1, subject: 's', unpushed: 'yes' }], truncated: false, }) expect(norm!.commits[0]!.unpushed).toBeUndefined() }) it('keeps a string upstream and drops a junk one', () => { expect(normalizeGitLog({ commits: [], truncated: false, upstream: 'origin/x' })!.upstream).toBe( 'origin/x', ) expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined() }) }) describe('renderGitLog — unpushed count beside the heading (w6 design)', () => { function entry(hash: string, subject: string, unpushed?: boolean) { return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) } } it('states the count so it is readable before scanning the rows', () => { const host = document.createElement('div') renderGitLog(host, { commits: [entry('a1', 'x', true), entry('b2', 'y', true), entry('c3', 'z')], truncated: false, upstream: 'origin/develop', }) expect(host.querySelector('.proj-commitlog-count')!.textContent).toContain('2') }) it('omits the count when nothing is unpushed', () => { const host = document.createElement('div') renderGitLog(host, { commits: [entry('a1', 'x')], truncated: false, upstream: 'origin/m' }) expect(host.querySelector('.proj-commitlog-count')).toBeNull() }) })