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