Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
152 lines
5.5 KiB
TypeScript
152 lines
5.5 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()
|
|
})
|
|
})
|