feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)

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).
This commit is contained in:
Yaojia Wang
2026-07-12 21:27:20 +02:00
parent 7551f8a4b2
commit 1dd12b035a
30 changed files with 1911 additions and 8 deletions

View File

@@ -595,6 +595,34 @@ describe('loadConfig — v0.7 B1 diff + B2 statusline', () => {
})
})
// ── W3 quick-wins (b) cost budget guard ───────────────────────────────────────
describe('loadConfig — W3 COST_BUDGET_USD', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('defaults costBudgetUsd to 0 (disabled) when unset', () => {
expect(loadConfig({}).costBudgetUsd).toBe(0)
})
it('parses a float dollar value', () => {
expect(loadConfig({ COST_BUDGET_USD: '5.50' }).costBudgetUsd).toBe(5.5)
})
it('accepts an explicit 0 (disabled)', () => {
expect(loadConfig({ COST_BUDGET_USD: '0' }).costBudgetUsd).toBe(0)
})
it('throws for a non-numeric value', () => {
expect(() => loadConfig({ COST_BUDGET_USD: 'abc' })).toThrow(/COST_BUDGET_USD/)
})
it('throws for a negative value', () => {
expect(() => loadConfig({ COST_BUDGET_USD: '-1' })).toThrow(/COST_BUDGET_USD/)
})
})
describe('loadConfig — v0.7 B3 worktree', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})

165
test/digest.test.ts Normal file
View File

@@ -0,0 +1,165 @@
// @vitest-environment jsdom
/**
* test/digest.test.ts (W3 quick-wins c) — reconnect digest banner (public/digest.ts).
*
* Pure helpers (normalizeDigest / digestSummary / renderDigestBanner) + the
* mountDigest wiring with a mocked fetch: fetches with the stored last-seen,
* shows a banner only when something happened, dismiss advances last-seen + hides,
* and a fetch failure shows no banner (best-effort). localStorage comes from the
* shared jsdom polyfill (vitest.config setupFiles).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { DigestResult } from '../src/types.js'
import {
normalizeDigest,
digestSummary,
digestHighlightCount,
renderDigestBanner,
getLastSeen,
setLastSeen,
mountDigest,
} from '../public/digest.js'
function makeDigest(over: Partial<DigestResult> = {}): DigestResult {
return {
since: 0,
generatedAt: 5000,
total: 0,
finished: 0,
needsInput: 0,
stuck: 0,
working: 0,
totalCostUsd: 0,
sessions: [],
...over,
}
}
/** Install a fetch mock returning `body` (or rejecting when `body` is null). */
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
}
beforeEach(() => {
localStorage.clear()
document.body.innerHTML = ''
})
afterEach(() => {
vi.unstubAllGlobals()
})
// ── pure helpers ──────────────────────────────────────────────────────────────
describe('normalizeDigest', () => {
it('returns null for a non-object / missing generatedAt', () => {
expect(normalizeDigest(null)).toBeNull()
expect(normalizeDigest('x')).toBeNull()
expect(normalizeDigest({ total: 1 })).toBeNull()
})
it('coerces a well-formed response', () => {
const d = normalizeDigest(makeDigest({ finished: 2, totalCostUsd: 1.5 }))
expect(d?.finished).toBe(2)
expect(d?.totalCostUsd).toBe(1.5)
expect(Array.isArray(d?.sessions)).toBe(true)
})
it('defaults missing numeric counts to 0 and non-array sessions to []', () => {
const d = normalizeDigest({ generatedAt: 10 })
expect(d?.finished).toBe(0)
expect(d?.sessions).toEqual([])
})
})
describe('digestSummary / digestHighlightCount', () => {
it('counts finished + needsInput + stuck', () => {
expect(digestHighlightCount(makeDigest({ finished: 2, needsInput: 1, stuck: 1, working: 9 }))).toBe(4)
})
it('builds a compact summary of only the non-zero buckets', () => {
const s = digestSummary(makeDigest({ finished: 2, stuck: 1 }))
expect(s).toContain('2 finished')
expect(s).toContain('1 stuck')
expect(s).not.toContain('waiting')
})
})
describe('renderDigestBanner', () => {
it('returns null when nothing worth showing', () => {
expect(renderDigestBanner(makeDigest({ working: 3 }), () => {})).toBeNull()
})
it('renders a dismissible banner and wires the × button', () => {
const onDismiss = vi.fn()
const banner = renderDigestBanner(makeDigest({ finished: 1 }), onDismiss)
expect(banner?.className).toContain('wya-banner')
;(banner?.querySelector('.wya-dismiss') as HTMLButtonElement).click()
expect(onDismiss).toHaveBeenCalled()
})
it('renders an attacker-influenced summary as inert text (SEC-H5)', () => {
const banner = renderDigestBanner(makeDigest({ finished: 1 }), () => {})
expect(banner?.querySelectorAll('script').length).toBe(0)
})
})
describe('getLastSeen / setLastSeen', () => {
it('defaults to 0 and round-trips a value', () => {
expect(getLastSeen()).toBe(0)
setLastSeen(1234)
expect(getLastSeen()).toBe(1234)
})
})
// ── mountDigest (wiring) ──────────────────────────────────────────────────────
describe('mountDigest', () => {
it('fetches with the stored last-seen watermark', async () => {
setLastSeen(500)
const fetchFn = mockFetch(makeDigest({ finished: 1 }))
await mountDigest(document.body)
expect(fetchFn).toHaveBeenCalledWith('/digest?since=500')
})
it('shows a banner when something happened', async () => {
mockFetch(makeDigest({ finished: 1 }))
await mountDigest(document.body)
expect(document.body.querySelector('.wya-banner')).not.toBeNull()
})
it('shows NO banner when nothing happened', async () => {
mockFetch(makeDigest({ working: 2 }))
await mountDigest(document.body)
expect(document.body.querySelector('.wya-banner')).toBeNull()
})
it('advances last-seen to generatedAt after a fetch', async () => {
mockFetch(makeDigest({ generatedAt: 8888, finished: 1 }))
await mountDigest(document.body)
expect(getLastSeen()).toBe(8888)
})
it('dismiss updates last-seen and removes the banner', async () => {
mockFetch(makeDigest({ generatedAt: 7777, needsInput: 1 }))
await mountDigest(document.body)
const banner = document.body.querySelector('.wya-banner')
expect(banner).not.toBeNull()
;(banner!.querySelector('.wya-dismiss') as HTMLButtonElement).click()
expect(document.body.querySelector('.wya-banner')).toBeNull()
expect(getLastSeen()).toBe(7777)
})
it('shows no banner on a fetch failure (best-effort)', async () => {
mockFetch(null)
const banner = await mountDigest(document.body)
expect(banner).toBeNull()
expect(document.body.querySelector('.wya-banner')).toBeNull()
})
})

151
test/git-log.test.ts Normal file
View File

@@ -0,0 +1,151 @@
// @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()
})
})

114
test/http/digest.test.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* test/http/digest.test.ts (W3 quick-wins c) — buildDigest read-side aggregate.
*
* Pure over an injected LiveSessionInfo[] (mirrors buildProjects injection):
* counts finished (idle & lastOutputAt > since), needsInput (waiting), stuck,
* working; sums telemetry.costUsd; empty → zeroes; future `since` → 0 finished;
* bad `since` clamps to 0.
*/
import { describe, it, expect } from 'vitest'
import type { LiveSessionInfo, ClaudeStatus } from '../../src/types.js'
import { buildDigest, clampSince } from '../../src/http/digest.js'
function live(over: Partial<LiveSessionInfo> & { id: string; status: ClaudeStatus }): LiveSessionInfo {
return {
createdAt: 1000,
clientCount: 0,
exited: false,
cwd: null,
cols: 80,
rows: 24,
...over,
}
}
// ── clampSince ────────────────────────────────────────────────────────────────
describe('clampSince', () => {
it('passes through a finite non-negative number', () => {
expect(clampSince(1234)).toBe(1234)
expect(clampSince(0)).toBe(0)
})
it('clamps NaN / negative / non-numeric / undefined to 0', () => {
expect(clampSince(NaN)).toBe(0)
expect(clampSince(-5)).toBe(0)
expect(clampSince('abc')).toBe(0)
expect(clampSince(undefined)).toBe(0)
})
it('parses a numeric string', () => {
expect(clampSince('42')).toBe(42)
})
})
// ── buildDigest ───────────────────────────────────────────────────────────────
describe('buildDigest', () => {
it('returns an all-zero aggregate for an empty list', () => {
const d = buildDigest([], 0)
expect(d).toMatchObject({
since: 0,
total: 0,
finished: 0,
needsInput: 0,
stuck: 0,
working: 0,
totalCostUsd: 0,
sessions: [],
})
expect(typeof d.generatedAt).toBe('number')
})
it('counts finished (idle + output after since), needsInput, stuck, working', () => {
const sessions: LiveSessionInfo[] = [
live({ id: 'a', status: 'idle', lastOutputAt: 5000 }), // finished (5000 > 100)
live({ id: 'b', status: 'waiting' }), // needsInput
live({ id: 'c', status: 'stuck' }), // stuck
live({ id: 'd', status: 'working' }), // working
live({ id: 'e', status: 'idle', lastOutputAt: 50 }), // idle but stale (50 < 100) → not finished
]
const d = buildDigest(sessions, 100)
expect(d.total).toBe(5)
expect(d.finished).toBe(1)
expect(d.needsInput).toBe(1)
expect(d.stuck).toBe(1)
expect(d.working).toBe(1)
})
it('does not count an idle session with no lastOutputAt as finished', () => {
const d = buildDigest([live({ id: 'a', status: 'idle' })], 0)
expect(d.finished).toBe(0)
})
it('treats a future `since` as "nothing new" (0 finished)', () => {
const d = buildDigest([live({ id: 'a', status: 'idle', lastOutputAt: 1000 })], 999_999_999_999)
expect(d.finished).toBe(0)
})
it('sums telemetry.costUsd across sessions', () => {
const sessions: LiveSessionInfo[] = [
live({ id: 'a', status: 'idle', telemetry: { at: 1, costUsd: 1.5 } }),
live({ id: 'b', status: 'working', telemetry: { at: 1, costUsd: 2.25 } }),
live({ id: 'c', status: 'working' }), // no telemetry → contributes 0
]
const d = buildDigest(sessions, 0)
expect(d.totalCostUsd).toBeCloseTo(3.75)
})
it('projects per-session flags + title (last cwd segment)', () => {
const d = buildDigest([live({ id: 'a', status: 'waiting', cwd: '/home/u/my-repo' })], 0)
const row = d.sessions[0]
expect(row?.id).toBe('a')
expect(row?.title).toBe('my-repo')
expect(row?.needsInput).toBe(true)
expect(row?.finished).toBe(false)
expect(row?.stuck).toBe(false)
})
it('clamps a bad `since` to 0', () => {
const d = buildDigest([], NaN as unknown as number)
expect(d.since).toBe(0)
})
})

168
test/http/git-log.test.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* test/http/git-log.test.ts (W3 quick-wins d) — recent-commit log parsing + getGitLog.
*
* Two layers:
* 1. parseGitLog fed canned NUL-record/US-field output — the deterministic core
* (empty → []; malformed record skipped; subject with tabs/newlines intact;
* subject truncated at the cap; truncated flag when records === max).
* 2. getGitLog against a REAL throwaway git repo in os.tmpdir (newest-first,
* n-clamping + truncated flag). Mirrors test/http/diff.test.ts.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
parseGitLog,
getGitLog,
clampLogCount,
GIT_LOG_MAX,
GIT_LOG_DEFAULT,
} from '../../src/http/git-log.js'
const execFileAsync = promisify(execFile)
const US = '\x1f'
const NUL = '\x00'
/** Build one NUL-terminated record: hash US ct US subject. */
function rec(hash: string, ct: number, subject: string): string {
return `${hash}${US}${ct}${US}${subject}${NUL}`
}
// ── clampLogCount ─────────────────────────────────────────────────────────────
describe('clampLogCount', () => {
it('defaults to GIT_LOG_DEFAULT for missing / non-numeric', () => {
expect(clampLogCount(undefined)).toBe(GIT_LOG_DEFAULT)
expect(clampLogCount('abc')).toBe(GIT_LOG_DEFAULT)
expect(clampLogCount(NaN)).toBe(GIT_LOG_DEFAULT)
})
it('clamps to [1, GIT_LOG_MAX]', () => {
expect(clampLogCount(0)).toBe(1)
expect(clampLogCount(-5)).toBe(1)
expect(clampLogCount(999)).toBe(GIT_LOG_MAX)
expect(clampLogCount(GIT_LOG_MAX)).toBe(GIT_LOG_MAX)
})
it('parses a numeric string and floors it', () => {
expect(clampLogCount('3')).toBe(3)
expect(clampLogCount(3.9)).toBe(3)
})
})
// ── parseGitLog (pure) ────────────────────────────────────────────────────────
describe('parseGitLog', () => {
it('returns [] for empty stdout', () => {
expect(parseGitLog('', 20)).toEqual({ commits: [], truncated: false })
})
it('parses one record into hash / at(ms) / subject', () => {
const out = parseGitLog(rec('abc1234', 1700000000, 'first commit'), 20)
expect(out.commits).toHaveLength(1)
expect(out.commits[0]).toEqual({
hash: 'abc1234',
at: 1700000000 * 1000,
subject: 'first commit',
})
expect(out.truncated).toBe(false)
})
it('preserves subjects containing tabs and newlines (US/NUL delimiters)', () => {
const nasty = 'fix:\ttabbed\nand newlined'
const out = parseGitLog(rec('h1', 1700000000, nasty), 20)
expect(out.commits[0]?.subject).toBe(nasty)
})
it('skips a malformed record missing a field', () => {
const good = rec('h1', 1700000000, 'ok')
const bad = `h2${US}onlytwo${NUL}` // only 2 fields
const out = parseGitLog(good + bad, 20)
expect(out.commits.map((c) => c.hash)).toEqual(['h1'])
})
it('skips a record with a non-numeric timestamp', () => {
const out = parseGitLog(rec('h1', NaN as unknown as number, 'x') + rec('h2', 1700000001, 'y'), 20)
expect(out.commits.map((c) => c.hash)).toEqual(['h2'])
})
it('truncates an over-long subject at the cap', () => {
const long = 'x'.repeat(1000)
const out = parseGitLog(rec('h1', 1700000000, long), 20)
expect(out.commits[0]?.subject.length).toBe(500)
})
it('sets truncated when records === max (there may be more)', () => {
const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') + rec('h3', 3, 'c')
const out = parseGitLog(stdout, 3)
expect(out.commits).toHaveLength(3)
expect(out.truncated).toBe(true)
})
it('does NOT set truncated when fewer records than max', () => {
const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b')
const out = parseGitLog(stdout, 3)
expect(out.truncated).toBe(false)
})
})
// ── getGitLog (integration against a real git repo) ──────────────────────────
async function git(cwd: string, ...args: string[]): Promise<void> {
await execFileAsync('git', args, { cwd })
}
describe('getGitLog (real git repo)', () => {
let repo: string
beforeAll(async () => {
repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitlog-'))
await git(repo, 'init', '-q', '-b', 'main')
await git(repo, 'config', 'user.email', 'test@example.com')
await git(repo, 'config', 'user.name', 'Test')
await git(repo, 'config', 'commit.gpgsign', 'false')
for (const [file, msg] of [
['a.txt', 'first'],
['b.txt', 'second'],
['c.txt', 'third'],
]) {
await fs.writeFile(path.join(repo, file), `${file}\n`)
await git(repo, 'add', '.')
await git(repo, 'commit', '-q', '-m', msg)
}
})
afterAll(async () => {
await fs.rm(repo, { recursive: true, force: true })
})
it('returns 3 commits newest-first', async () => {
const out = await getGitLog(repo, { timeoutMs: 5000 })
expect(out.commits).toHaveLength(3)
expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first'])
expect(out.truncated).toBe(false)
// hashes are short + non-empty; at is a plausible ms timestamp
for (const c of out.commits) {
expect(c.hash.length).toBeGreaterThan(0)
expect(c.at).toBeGreaterThan(0)
}
})
it('clamps n and flags truncated when asking for fewer than exist', async () => {
const out = await getGitLog(repo, { n: 2, timeoutMs: 5000 })
expect(out.commits).toHaveLength(2)
expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second'])
expect(out.truncated).toBe(true)
})
it('returns an empty result for a non-git directory (best-effort, no throw)', async () => {
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
const out = await getGitLog(plain, { timeoutMs: 5000 })
expect(out).toEqual({ commits: [], truncated: false })
await fs.rm(plain, { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,78 @@
/**
* Integration test for GET /digest (W3 quick-wins c).
*
* Starts a real HTTP server (no live sessions) and asserts the read-only digest
* route: 200 + a well-shaped DigestResult; a malformed `since` clamps to 0.
*/
import net from 'node:net'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { DigestResult } from '../../src/types.js'
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
describe('GET /digest — integration', () => {
let port: number
let serverHandle: { close(): Promise<void> }
beforeAll(async () => {
port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
})
it('returns 200 with an all-zero DigestResult when no sessions', async () => {
const res = await fetch(`http://127.0.0.1:${port}/digest?since=0`)
expect(res.status).toBe(200)
const body = (await res.json()) as DigestResult
expect(body.since).toBe(0)
expect(body.total).toBe(0)
expect(body.finished).toBe(0)
expect(Array.isArray(body.sessions)).toBe(true)
expect(typeof body.generatedAt).toBe('number')
})
it('clamps a malformed since to 0', async () => {
const res = await fetch(`http://127.0.0.1:${port}/digest?since=not-a-number`)
expect(res.status).toBe(200)
const body = (await res.json()) as DigestResult
expect(body.since).toBe(0)
})
it('echoes a valid since watermark', async () => {
const res = await fetch(`http://127.0.0.1:${port}/digest?since=12345`)
const body = (await res.json()) as DigestResult
expect(body.since).toBe(12345)
})
})

View File

@@ -0,0 +1,136 @@
/**
* Integration test for GET /projects/log (W3 quick-wins d).
*
* Starts a real HTTP server, then asserts the recent-commit log route against a
* real temp git repo (3 commits): 200 + structured GitLogResult; missing path →
* 400; a non-git temp dir → 404 (isValidGitDir three-prong); ?n clamped.
*/
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { GitLogResult } from '../../src/types.js'
const execFileAsync = promisify(execFile)
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
async function git(cwd: string, ...args: string[]): Promise<void> {
await execFileAsync('git', args, { cwd })
}
async function gitAvailable(): Promise<boolean> {
try {
await execFileAsync('git', ['--version'])
return true
} catch {
return false
}
}
describe('GET /projects/log — integration', () => {
let port: number
let tmpRoot: string
let repoPath: string
let plainPath: string
let serverHandle: { close(): Promise<void> }
let haveGit = false
beforeAll(async () => {
haveGit = await gitAvailable()
port = await getFreePort()
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-log-test-'))
repoPath = path.join(tmpRoot, 'repo')
plainPath = path.join(tmpRoot, 'plain')
await fs.mkdir(plainPath, { recursive: true })
if (haveGit) {
await fs.mkdir(repoPath, { recursive: true })
await git(repoPath, 'init', '-q', '-b', 'main')
await git(repoPath, 'config', 'user.email', 't@t.local')
await git(repoPath, 'config', 'user.name', 'tester')
await git(repoPath, 'config', 'commit.gpgsign', 'false')
for (const [file, msg] of [['a.txt', 'first'], ['b.txt', 'second'], ['c.txt', 'third']]) {
await fs.writeFile(path.join(repoPath, file), `${file}\n`)
await git(repoPath, 'add', '.')
await git(repoPath, 'commit', '-q', '-m', msg)
}
}
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
await fs.rm(tmpRoot, { recursive: true, force: true })
})
it('returns 400 when path is missing', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects/log`)
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory (isValidGitDir three-prong)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(plainPath)}`)
expect(res.status).toBe(404)
})
it('returns 200 with the recent commits newest-first', async () => {
if (!haveGit) return
const res = await fetch(`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as GitLogResult
expect(body.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first'])
})
it('clamps ?n and flags truncated', async () => {
if (!haveGit) return
const res = await fetch(
`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}&n=2`,
)
const body = (await res.json()) as GitLogResult
expect(body.commits).toHaveLength(2)
expect(body.truncated).toBe(true)
// A huge n must be clamped to ≤ 50 (never explodes) — here only 3 commits exist.
const res2 = await fetch(
`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}&n=999`,
)
const body2 = (await res2.json()) as GitLogResult
expect(body2.commits.length).toBeLessThanOrEqual(50)
expect(body2.commits).toHaveLength(3)
})
})

View File

@@ -214,4 +214,17 @@ describe('GET /config/ui', () => {
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(await res.json()).toEqual({ allowAutoMode: true })
})
it('omits costBudgetUsd when the budget is unset (W3 b)', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
const body = (await res.json()) as Record<string, unknown>
expect(body).not.toHaveProperty('costBudgetUsd')
})
it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => {
const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' })
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5 })
})
})

View File

@@ -864,6 +864,78 @@ describe('handleStatusLine', () => {
});
});
// ── handleStatusLine — W3(b) cost-budget latch ────────────────────────────────
describe('handleStatusLine — cost-budget one-shot latch (W3 b)', () => {
const BUDGET_CFG: Config = { ...CFG, costBudgetUsd: 1 };
it('does NOT fire below the threshold and leaves the latch unset', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 0.5 });
expect(notify).not.toHaveBeenCalled();
expect(s.budgetNotified).toBe(false);
});
it('fires exactly once with (session, "budget") on crossing, then latches', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Below → nothing.
mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 0.5 });
expect(notify).not.toHaveBeenCalled();
// Crosses the threshold → fire once, latch set.
mgr.handleStatusLine(s.meta.id, { at: 2, costUsd: 1.2 });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(s, 'budget');
expect(s.budgetNotified).toBe(true);
// Further over-budget frames do NOT re-fire (latch, never re-armed).
mgr.handleStatusLine(s.meta.id, { at: 3, costUsd: 2 });
mgr.handleStatusLine(s.meta.id, { at: 4, costUsd: 5 });
expect(notify).toHaveBeenCalledTimes(1);
});
it('never fires when the budget is 0 (disabled)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, costBudgetUsd: 0 }, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 999 });
expect(notify).not.toHaveBeenCalled();
expect(s.budgetNotified).toBe(false);
});
it('does not fire when a telemetry frame carries no cost', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, { at: 1 }); // no costUsd
expect(notify).not.toHaveBeenCalled();
expect(s.budgetNotified).toBe(false);
});
it('still broadcasts telemetry even when the latch fires (existing frame is the warning)', () => {
const { service } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleStatusLine(s.meta.id, { at: 2, costUsd: 1.2 });
const telemetry = parseSent(ws).find((m) => m.type === 'telemetry');
expect(telemetry).toBeDefined();
});
});
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
describe('handleAttach — late-join replay (M3)', () => {
it('sends the current telemetry to a device joining a live session', () => {

View File

@@ -33,6 +33,7 @@ const {
toggleFav,
normalizeProject,
makeProjectCard,
makeSyncChip,
renderProjectDetail,
groupProjects,
displayLabel,
@@ -285,6 +286,65 @@ describe('normalizeProject', () => {
expect(p?.lastActiveMs).toBeUndefined()
expect(p?.isGit).toBe(false)
})
it('passes through numeric sync fields (W3 a)', () => {
const p = normalizeProject({ name: 'web', path: '/p', ahead: 2, behind: 1, lastCommitMs: 123456 })
expect(p?.ahead).toBe(2)
expect(p?.behind).toBe(1)
expect(p?.lastCommitMs).toBe(123456)
})
it('drops non-numeric sync fields (W3 a)', () => {
const p = normalizeProject({ name: 'web', path: '/p', ahead: '2', behind: null, lastCommitMs: 'x' })
expect(p?.ahead).toBeUndefined()
expect(p?.behind).toBeUndefined()
expect(p?.lastCommitMs).toBeUndefined()
})
})
/* ── makeSyncChip / sync chip on the card (W3 a) ─────────────────────────────── */
describe('makeSyncChip', () => {
it('renders ↑ahead ↓behind when there is drift', () => {
const chip = makeSyncChip(makeProject({ ahead: 2, behind: 1 }))
expect(chip).not.toBeNull()
expect(chip?.className).toContain('proj-sync')
expect(chip?.textContent).toBe('↑2 ↓1')
})
it('shows only the ahead arrow when behind is 0', () => {
const chip = makeSyncChip(makeProject({ ahead: 3, behind: 0 }))
expect(chip?.textContent).toBe('↑3')
})
it('returns null when in sync (ahead=behind=0)', () => {
expect(makeSyncChip(makeProject({ ahead: 0, behind: 0 }))).toBeNull()
})
it('returns null when ahead/behind are undefined', () => {
expect(makeSyncChip(makeProject())).toBeNull()
})
it('includes the last-commit time in the tooltip when present', () => {
const chip = makeSyncChip(makeProject({ ahead: 1, lastCommitMs: Date.now() - 3600_000 }))
expect(chip?.title).toContain('last commit')
})
})
describe('makeProjectCard — sync chip', () => {
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
it('renders the sync chip when the project has drift', () => {
const card = makeProjectCard(makeProject({ ahead: 2, behind: 1 }), new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-sync')?.textContent).toBe('↑2 ↓1')
})
it('omits the sync chip when in sync / undefined', () => {
const inSync = makeProjectCard(makeProject({ ahead: 0, behind: 0 }), new Set(), noopHooks(), () => {})
expect(inSync.querySelector('.proj-sync')).toBeNull()
const noData = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
expect(noData.querySelector('.proj-sync')).toBeNull()
})
})
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */

View File

@@ -402,6 +402,84 @@ describe('buildProjects — dirty check', () => {
})
})
// ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ──────────────
describe('buildProjects — sync fields (W3 a)', () => {
async function commit(cwd: string, file: string, msg: string): Promise<void> {
await fs.writeFile(path.join(cwd, file), `${file}\n`)
await execFileP('git', ['add', '.'], { cwd })
await execFileP('git', ['commit', '-q', '-m', msg], { cwd })
}
async function initRepo(cwd: string): Promise<void> {
await fs.mkdir(cwd, { recursive: true })
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
}
it('reports ahead/behind vs upstream and lastCommitMs when dirtyCheck is on', async () => {
if (!(await gitAvailable())) return
// Upstream repo with one commit; clone it so the clone's main tracks origin/main.
const upstream = path.join(tmp, 'upstream')
await initRepo(upstream)
await commit(upstream, 'a.txt', 'first')
const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-clone-'))
const clone = path.join(cloneRoot, 'clone')
await execFileP('git', ['clone', '-q', upstream, clone])
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })
// Diverge: 1 local commit ahead, then 1 upstream commit fetched → 1 behind.
await commit(clone, 'local.txt', 'local ahead')
await commit(upstream, 'b.txt', 'second upstream')
await execFileP('git', ['fetch', '-q'], { cwd: clone })
const cfg = makeCfg({ projectRoots: [cloneRoot], projectScanDepth: 2, projectDirtyCheck: true })
const out = await buildProjects(cfg, [])
const proj = out.find((p) => p.name === 'clone')!
expect(proj.ahead).toBe(1)
expect(proj.behind).toBe(1)
expect(typeof proj.lastCommitMs).toBe('number')
expect(proj.lastCommitMs!).toBeGreaterThan(0)
await fs.rm(cloneRoot, { recursive: true, force: true })
})
it('leaves ahead/behind undefined for a repo with no upstream (no throw)', async () => {
if (!(await gitAvailable())) return
const repo = path.join(tmp, 'noupstream')
await initRepo(repo)
await commit(repo, 'a.txt', 'only')
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true })
const out = await buildProjects(cfg, [])
const proj = out.find((p) => p.name === 'noupstream')!
expect(proj.ahead).toBeUndefined()
expect(proj.behind).toBeUndefined()
// lastCommitMs still resolves — HEAD has a commit even without an upstream.
expect(typeof proj.lastCommitMs).toBe('number')
})
it('skips sync entirely (all undefined) when projectDirtyCheck is false', async () => {
if (!(await gitAvailable())) return
const repo = path.join(tmp, 'skipsync')
await initRepo(repo)
await commit(repo, 'a.txt', 'only')
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const out = await buildProjects(cfg, [])
const proj = out.find((p) => p.name === 'skipsync')!
expect(proj.ahead).toBeUndefined()
expect(proj.behind).toBeUndefined()
expect(proj.lastCommitMs).toBeUndefined()
})
})
// ── buildProjectDetail ───────────────────────────────────────────────────────────
describe('buildProjectDetail', () => {

View File

@@ -119,6 +119,11 @@ describe('createSession', () => {
expect(s.exitCode).toBeNull();
});
it('initialises the W3(b) cost-budget latch to false', () => {
const s = newSession();
expect(s.budgetNotified).toBe(false);
});
it('THROWS (does not swallow) when spawn fails (M4)', () => {
spawnError = new Error('spawn failed: /no/such/shell ENOENT');
nextPty = createMockPty();

View File

@@ -129,6 +129,36 @@ describe('renderTelemetryGauge', () => {
expect(c.querySelector('.tg-cost')).toBeNull()
})
// ── cost budget warn (W3 b) ──
it('adds tg-cost-warn when costUsd >= costBudgetUsd', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 6 }), 30_000, 5)
expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(true)
})
it('adds tg-cost-warn exactly at the budget boundary (>=)', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 5 }), 30_000, 5)
expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(true)
})
it('does NOT add tg-cost-warn when costUsd < budget', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 3 }), 30_000, 5)
expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false)
})
it('does NOT add tg-cost-warn when budget is 0 or undefined', () => {
const c1 = makeContainer()
renderTelemetryGauge(c1, makeTelemetry({ costUsd: 6 }), 30_000, 0)
expect(c1.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false)
const c2 = makeContainer()
renderTelemetryGauge(c2, makeTelemetry({ costUsd: 6 }), 30_000)
expect(c2.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false)
})
// ── model chip ──
it('renders model chip when model is present', () => {