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).
115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|