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:
114
test/http/digest.test.ts
Normal file
114
test/http/digest.test.ts
Normal 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
168
test/http/git-log.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user