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:
136
test/integration/projects-log-endpoint.test.ts
Normal file
136
test/integration/projects-log-endpoint.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user