The shipped panel was functionally right — every number matched git — but it was not the approved design. I implemented from the plan's semantics (which state is green, when to flag stale) and let the mock's layout drift, and nothing caught it: the tests asserted behaviour, and no assertion covered structure. Three deviations, now closed against docs/mockups/project-detail-git.html. 1. The sync band was compressed into one row of chips. Restored to the four labelled cells: Upstream / To push / To pull / Fetch, with display-size numerals and a footnote under each count. The footnotes are load-bearing, not decoration — "Last fetched 19d ago — this number cannot be trusted" is what tells a reader WHY the zero is suspect. Compressed to "fetched 19d ago", the causal link was left for the reader to infer, and not having to infer it is the entire point of the panel. The unverified state is a chip on the count again, so the doubt attaches to the digit. 2. The working-tree count sat inside the band. Moved to the title line beside the branch, where the design puts it: the band is about the remote, and the dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot remains as the fallback when a server sends no count. 3. Worktree rows were the old single line with the path right-aligned against the chips. Rebuilt as the two-line card the design calls for — identity and state on top, path underneath — plus the Open button, which was specified and simply missing, so a worktree row could be read but not entered. Open reuses the detail view pointed at the worktree path: a linked worktree is a project directory, so this needs no new concept and no new route. Also added the unpushed count beside the commit-list heading, so the number is readable before the eye walks the rows hunting for rails. Tests now assert structure, not just behaviour: cell captions exist, the stale footnote says why, counts render at display size, the path is outside the chip row, and Open fires with its worktree. That is the gap that let the drift through, so it is the gap that is now covered. Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
// @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 <img
|
|
* onerror> 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> = {}): 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<typeof vi.fn> {
|
|
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 = '<img src=x onerror=alert(1)>'
|
|
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()
|
|
})
|
|
})
|