Files
web-terminal/test/git-log.test.ts
Yaojia Wang 042c4cd4a9 fix(projects): pin the worktree actions right and put the count on the heading
Two layout defects the screenshots caught that the DOM tests could not.

1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the
   file, the original flex rule later. Same specificity, so the later copy won
   and the Open button sat against the text instead of at the card's right
   edge. Folded the grid into the ORIGINAL rule and left a note where the
   duplicate was. One rule per selector; a second declaration of a layout
   property is a silent override waiting to happen.

2. The unpushed count rendered as its own line under "Recent commits". The
   design has it ON the heading, because it qualifies that heading rather than
   making a separate statement. renderGitLog now takes the heading element and
   writes the badge there, clearing any previous badge first so a re-render
   cannot stack duplicates.

Both were invisible to the existing assertions: they checked that the elements
exist and carry the right text, which was true in each case. Added a test for
the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and
is verified by screenshot, not by the suite.
2026-07-29 18:29:53 +02:00

266 lines
9.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('puts the count ON the heading when one is supplied, not on its own line', () => {
const host = document.createElement('div')
const label = document.createElement('div')
label.textContent = 'Recent commits'
renderGitLog(
host,
{
commits: [entry('a1', 'x', true), entry('b2', 'y', true), entry('c3', 'z')],
truncated: false,
upstream: 'origin/develop',
},
label,
)
expect(label.querySelector('.proj-commitlog-count')!.textContent).toContain('2')
expect(host.querySelector('.proj-commitlog-count')).toBeNull()
})
it('does not stack duplicate counts across re-renders', () => {
const host = document.createElement('div')
const label = document.createElement('div')
const log = {
commits: [entry('a1', 'x', true)],
truncated: false,
upstream: 'origin/develop',
}
renderGitLog(host, log, label)
renderGitLog(host, log, label)
expect(label.querySelectorAll('.proj-commitlog-count').length).toBe(1)
})
it('falls back to the list container when no heading is given', () => {
const host = document.createElement('div')
renderGitLog(host, {
commits: [entry('a1', 'x', true)],
truncated: false,
upstream: 'origin/develop',
})
expect(host.querySelector('.proj-commitlog-count')).not.toBeNull()
})
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()
})
})