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:
156
public/digest.ts
Normal file
156
public/digest.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* public/digest.ts (W3 quick-wins c) — "while you were away" reconnect banner.
|
||||||
|
*
|
||||||
|
* On (re)connect, fetch GET /digest?since=<last-seen> and, if anything happened
|
||||||
|
* while away (finished / waiting / stuck), show ONE compact dismissible banner.
|
||||||
|
* The last-seen watermark is stored per-device in localStorage and advanced to
|
||||||
|
* the digest's generatedAt after each render so it never re-nags for old news.
|
||||||
|
*
|
||||||
|
* Best-effort: any fetch/parse failure → no banner (never throws). All text is
|
||||||
|
* set via textContent (SEC-H5) — session titles are attacker-influenced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DigestResult } from '../src/types.js'
|
||||||
|
|
||||||
|
const LAST_SEEN_KEY = 'web-terminal:digest-last-seen'
|
||||||
|
|
||||||
|
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
cls?: string,
|
||||||
|
text?: string,
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag)
|
||||||
|
if (cls) node.className = cls
|
||||||
|
if (text !== undefined) node.textContent = text
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── last-seen watermark (per-device) ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Read the stored last-seen epoch-ms, or 0 (everything is new). Never throws. */
|
||||||
|
export function getLastSeen(): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LAST_SEEN_KEY)
|
||||||
|
if (raw === null) return 0
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the last-seen epoch-ms watermark. Best-effort. */
|
||||||
|
export function setLastSeen(ms: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_SEEN_KEY, String(Math.floor(ms)))
|
||||||
|
} catch {
|
||||||
|
// storage unavailable (private mode) — the banner just re-shows next time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
|
||||||
|
|
||||||
|
function num(o: Record<string, unknown>, key: string): number {
|
||||||
|
const v = o[key]
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce an untrusted GET /digest response into a DigestResult, or null. */
|
||||||
|
export function normalizeDigest(raw: unknown): DigestResult | null {
|
||||||
|
if (raw === null || typeof raw !== 'object') return null
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
if (typeof o['generatedAt'] !== 'number' || !Number.isFinite(o['generatedAt'])) return null
|
||||||
|
return {
|
||||||
|
since: num(o, 'since'),
|
||||||
|
generatedAt: o['generatedAt'],
|
||||||
|
total: num(o, 'total'),
|
||||||
|
finished: num(o, 'finished'),
|
||||||
|
needsInput: num(o, 'needsInput'),
|
||||||
|
stuck: num(o, 'stuck'),
|
||||||
|
working: num(o, 'working'),
|
||||||
|
totalCostUsd: num(o, 'totalCostUsd'),
|
||||||
|
sessions: Array.isArray(o['sessions']) ? (o['sessions'] as DigestResult['sessions']) : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── fetch ───────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Fetch the digest since `since`. null on any error (best-effort). */
|
||||||
|
export async function fetchDigest(since: number): Promise<DigestResult | null> {
|
||||||
|
try {
|
||||||
|
if (typeof fetch === 'undefined') return null
|
||||||
|
const res = await fetch(`/digest?since=${encodeURIComponent(String(since))}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return normalizeDigest(await res.json())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render (pure) ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Count of things worth surfacing (finished / waiting / stuck). */
|
||||||
|
export function digestHighlightCount(d: DigestResult): number {
|
||||||
|
return d.finished + d.needsInput + d.stuck
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact human summary, e.g. "2 finished · 1 waiting · 1 stuck". */
|
||||||
|
export function digestSummary(d: DigestResult): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (d.finished > 0) parts.push(`${d.finished} finished`)
|
||||||
|
if (d.needsInput > 0) parts.push(`${d.needsInput} waiting for input`)
|
||||||
|
if (d.stuck > 0) parts.push(`${d.stuck} stuck`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the banner element for a digest, or null when nothing is worth showing.
|
||||||
|
* `onDismiss` is wired to the × button. All text via textContent (SEC-H5).
|
||||||
|
*/
|
||||||
|
export function renderDigestBanner(d: DigestResult, onDismiss: () => void): HTMLElement | null {
|
||||||
|
if (digestHighlightCount(d) === 0) return null
|
||||||
|
|
||||||
|
const banner = el('div', 'wya-banner')
|
||||||
|
banner.setAttribute('role', 'status')
|
||||||
|
banner.append(el('span', 'wya-title', 'While you were away'))
|
||||||
|
banner.append(el('span', 'wya-summary', digestSummary(d)))
|
||||||
|
if (d.totalCostUsd > 0) {
|
||||||
|
banner.append(el('span', 'wya-cost', `$${d.totalCostUsd.toFixed(2)} total`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismiss = el('button', 'wya-dismiss', '✕')
|
||||||
|
dismiss.title = 'Dismiss'
|
||||||
|
dismiss.setAttribute('aria-label', 'Dismiss')
|
||||||
|
dismiss.addEventListener('click', onDismiss)
|
||||||
|
banner.append(dismiss)
|
||||||
|
|
||||||
|
return banner
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── mount ───────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the digest since the stored last-seen and, if anything happened, prepend
|
||||||
|
* a dismissible banner to `host`. Advances the last-seen watermark to the
|
||||||
|
* digest's generatedAt so it doesn't re-nag on the next reconnect. Best-effort:
|
||||||
|
* a fetch failure shows no banner. Returns the banner element (or null).
|
||||||
|
*/
|
||||||
|
export async function mountDigest(host: HTMLElement): Promise<HTMLElement | null> {
|
||||||
|
const since = getLastSeen()
|
||||||
|
const d = await fetchDigest(since)
|
||||||
|
if (d === null) return null // best-effort — no banner on failure
|
||||||
|
|
||||||
|
// Advance the watermark now so a refresh (without a dismiss) doesn't re-nag.
|
||||||
|
setLastSeen(d.generatedAt)
|
||||||
|
|
||||||
|
const banner = renderDigestBanner(d, () => {
|
||||||
|
setLastSeen(d.generatedAt)
|
||||||
|
banner?.remove()
|
||||||
|
})
|
||||||
|
if (banner === null) return null
|
||||||
|
|
||||||
|
host.prepend(banner)
|
||||||
|
return banner
|
||||||
|
}
|
||||||
135
public/git-log.ts
Normal file
135
public/git-log.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* public/git-log.ts (W3 quick-wins d) — render-only recent-commit list.
|
||||||
|
*
|
||||||
|
* Fetches GET /projects/log for a repo and renders each commit as an inert row.
|
||||||
|
* Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws
|
||||||
|
* and degrades to a short inert message on any failure.
|
||||||
|
*
|
||||||
|
* Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A
|
||||||
|
* commit subject is attacker-influenced (anyone who can push to a repo the host
|
||||||
|
* can read), so it appears strictly as literal text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CommitLogEntry, GitLogResult } from '../src/types.js'
|
||||||
|
|
||||||
|
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Create an element with an optional CSS class and text content. */
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
cls?: string,
|
||||||
|
text?: string,
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag)
|
||||||
|
if (cls) node.className = cls
|
||||||
|
if (text !== undefined) node.textContent = text
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */
|
||||||
|
function normalizeCommit(raw: unknown): CommitLogEntry | null {
|
||||||
|
if (raw === null || typeof raw !== 'object') return null
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null
|
||||||
|
if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null
|
||||||
|
return { hash: o['hash'], at: o['at'], subject: o['subject'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */
|
||||||
|
export function normalizeGitLog(raw: unknown): GitLogResult | null {
|
||||||
|
if (raw === null || typeof raw !== 'object') return null
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
if (!Array.isArray(o['commits'])) return null
|
||||||
|
const commits = o['commits']
|
||||||
|
.map(normalizeCommit)
|
||||||
|
.filter((c): c is CommitLogEntry => c !== null)
|
||||||
|
return { commits, truncated: o['truncated'] === true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── fetch ───────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */
|
||||||
|
export async function fetchGitLog(repoPath: string): Promise<GitLogResult | null> {
|
||||||
|
try {
|
||||||
|
if (typeof fetch === 'undefined') return null
|
||||||
|
const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return normalizeGitLog(await res.json())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */
|
||||||
|
function relTime(ms: number): string {
|
||||||
|
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||||
|
if (s < 60) return `${Math.floor(s)}s`
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
||||||
|
return `${Math.floor(s / 86400)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One commit row: short hash · relative time · subject (all inert text). */
|
||||||
|
function renderCommitRow(c: CommitLogEntry): HTMLElement {
|
||||||
|
const row = el('div', 'proj-commit-row')
|
||||||
|
row.append(el('span', 'proj-commit-hash', c.hash))
|
||||||
|
row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
|
||||||
|
row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a GitLogResult into a container (clears first). Empty → an inert note. */
|
||||||
|
export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
|
||||||
|
container.textContent = ''
|
||||||
|
if (log.commits.length === 0) {
|
||||||
|
container.append(el('div', 'proj-empty', 'No commits yet.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = el('div', 'proj-commitlog-list')
|
||||||
|
for (const c of log.commits) list.append(renderCommitRow(c))
|
||||||
|
container.append(list)
|
||||||
|
if (log.truncated) {
|
||||||
|
container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── mount ───────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Handle returned by mountGitLog for cleanup. */
|
||||||
|
export interface GitLogHandle {
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a recent-commit list into `container`: show a loading placeholder, fetch
|
||||||
|
* the log, then swap in the rows. A fetch failure degrades to a short inert
|
||||||
|
* message (never throws). destroy() removes the node and cancels the swap.
|
||||||
|
*/
|
||||||
|
export function mountGitLog(container: HTMLElement, repoPath: string): GitLogHandle {
|
||||||
|
let destroyed = false
|
||||||
|
|
||||||
|
container.textContent = ''
|
||||||
|
container.append(el('div', 'proj-commitlog-loading', 'Loading commits…'))
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const log = await fetchGitLog(repoPath)
|
||||||
|
if (destroyed) return
|
||||||
|
if (log === null) {
|
||||||
|
container.textContent = ''
|
||||||
|
container.append(el('div', 'proj-empty', 'Could not read recent commits.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderGitLog(container, log)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
destroyed = true
|
||||||
|
container.textContent = ''
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { mountShortcuts } from './shortcuts.js'
|
|||||||
import { mountShareSession } from './share.js'
|
import { mountShareSession } from './share.js'
|
||||||
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
|
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
|
||||||
import { mountGridPresets } from './grid-presets.js'
|
import { mountGridPresets } from './grid-presets.js'
|
||||||
|
import { mountDigest } from './digest.js'
|
||||||
|
|
||||||
const paneHost = document.getElementById('term')
|
const paneHost = document.getElementById('term')
|
||||||
const tabs = document.getElementById('tabs')
|
const tabs = document.getElementById('tabs')
|
||||||
@@ -118,6 +119,11 @@ mountGridPresets(toolbar, {
|
|||||||
|
|
||||||
mountQrConnect(toolbar)
|
mountQrConnect(toolbar)
|
||||||
|
|
||||||
|
// W3(c): "while you were away" reconnect digest — one compact dismissible banner
|
||||||
|
// summarising what finished / needs input / got stuck since this device's last
|
||||||
|
// visit. Best-effort (no banner on fetch failure); advances its own last-seen.
|
||||||
|
void mountDigest(document.body)
|
||||||
|
|
||||||
// PWA: register the service worker (installable + offline shell, M4).
|
// PWA: register the service worker (installable + offline shell, M4).
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
|
|||||||
@@ -188,7 +188,9 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
|||||||
*
|
*
|
||||||
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
|
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
|
||||||
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
|
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
|
||||||
* receives the class `tg-stale` so CSS can grey it out.
|
* receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is
|
||||||
|
* set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn`
|
||||||
|
* (W3 quick-wins b), mirroring the ctx>80% warn path.
|
||||||
*
|
*
|
||||||
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
|
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
|
||||||
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
|
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
|
||||||
@@ -198,6 +200,7 @@ export function renderTelemetryGauge(
|
|||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
telemetry: StatusTelemetry | null,
|
telemetry: StatusTelemetry | null,
|
||||||
staleTtlMs: number,
|
staleTtlMs: number,
|
||||||
|
costBudgetUsd?: number,
|
||||||
): void {
|
): void {
|
||||||
// Clear existing children
|
// Clear existing children
|
||||||
while (container.firstChild) container.removeChild(container.firstChild)
|
while (container.firstChild) container.removeChild(container.firstChild)
|
||||||
@@ -221,9 +224,13 @@ export function renderTelemetryGauge(
|
|||||||
container.append(bar)
|
container.append(bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cost chip
|
// Cost chip — W3(b): warn-styled once cost crosses the configured budget.
|
||||||
if (telemetry.costUsd !== undefined) {
|
if (telemetry.costUsd !== undefined) {
|
||||||
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
|
const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)
|
||||||
|
if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) {
|
||||||
|
cost.classList.add('tg-cost-warn')
|
||||||
|
}
|
||||||
|
container.append(cost)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model chip
|
// Model chip
|
||||||
|
|||||||
Binary file not shown.
107
public/style.css
107
public/style.css
@@ -1534,6 +1534,113 @@ body {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* W3(a): ahead/behind sync chip (mirrors the .proj-branch chip look). */
|
||||||
|
.proj-sync {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--amber);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */
|
||||||
|
.tg-cost-warn {
|
||||||
|
color: var(--red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* W3(d): recent-commit list in the project detail. */
|
||||||
|
.proj-commitlog {
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
.proj-commitlog-loading {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.proj-commitlog-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.proj-commit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.proj-commit-hash {
|
||||||
|
font-family: Menlo, Consolas, monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.proj-commit-time {
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.proj-commit-subject {
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.proj-commit-more {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* W3(c): "while you were away" reconnect banner (compact, dismissible top bar). */
|
||||||
|
.wya-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-bottom: 1px solid var(--accent);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.wya-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.wya-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wya-cost {
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.wya-dismiss {
|
||||||
|
flex: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.wya-dismiss:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* W3: PR + CI status chip (mirrors the .proj-branch chip look). */
|
/* W3: PR + CI status chip (mirrors the .proj-branch chip look). */
|
||||||
.proj-pr-host {
|
.proj-pr-host {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const TITLES = {
|
|||||||
'needs-input': 'Approval Needed',
|
'needs-input': 'Approval Needed',
|
||||||
done: 'Task Complete',
|
done: 'Task Complete',
|
||||||
stuck: 'Task Stuck',
|
stuck: 'Task Stuck',
|
||||||
|
budget: 'Cost Budget Reached',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ export class TabApp {
|
|||||||
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
|
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
|
||||||
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
|
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
|
||||||
private allowAutoMode = false
|
private allowAutoMode = false
|
||||||
|
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
|
||||||
|
// gauge warn-styles the cost chip once costUsd >= this budget.
|
||||||
|
private costBudgetUsd = 0
|
||||||
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
||||||
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
||||||
private timelineOpen = false
|
private timelineOpen = false
|
||||||
@@ -275,6 +278,11 @@ export class TabApp {
|
|||||||
) {
|
) {
|
||||||
this.allowAutoMode = (data as UiConfig).allowAutoMode
|
this.allowAutoMode = (data as UiConfig).allowAutoMode
|
||||||
}
|
}
|
||||||
|
// W3(b): read the cost budget (optional, present only when > 0 server-side).
|
||||||
|
const budget = (data as Record<string, unknown>)?.['costBudgetUsd']
|
||||||
|
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
|
||||||
|
this.costBudgetUsd = budget
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — leave allowAutoMode false (auto hidden) on any failure
|
// best-effort — leave allowAutoMode false (auto hidden) on any failure
|
||||||
}
|
}
|
||||||
@@ -1441,7 +1449,7 @@ export class TabApp {
|
|||||||
queue.textContent = n > 0 ? `⧗${n}` : ''
|
queue.textContent = n > 0 ? `⧗${n}` : ''
|
||||||
}
|
}
|
||||||
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
|
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
|
||||||
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
|
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS, this.costBudgetUsd) // B2 + W3(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
||||||
|
|||||||
@@ -91,6 +91,22 @@ function parseNonNegativeInt(
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a non-negative float env value (0 allowed), or the fallback when unset. */
|
||||||
|
function parseNonNegativeFloat(
|
||||||
|
raw: string | undefined,
|
||||||
|
label: string,
|
||||||
|
fallback: number,
|
||||||
|
): number {
|
||||||
|
if (raw === undefined) return fallback
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative number`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */
|
/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */
|
||||||
function parseBool(raw: string | undefined, fallback: boolean): boolean {
|
function parseBool(raw: string | undefined, fallback: boolean): boolean {
|
||||||
const v = raw?.trim().toLowerCase()
|
const v = raw?.trim().toLowerCase()
|
||||||
@@ -378,6 +394,9 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
DEFAULT_STATUSLINE_TTL_MS,
|
DEFAULT_STATUSLINE_TTL_MS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// W3 quick-wins (b) cost budget guard — dollars, float ≥ 0; 0/unset = disabled.
|
||||||
|
const costBudgetUsd = parseNonNegativeFloat(env['COST_BUDGET_USD'], 'COST_BUDGET_USD', 0)
|
||||||
|
|
||||||
// B3 git worktree creation
|
// B3 git worktree creation
|
||||||
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
|
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
|
||||||
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
|
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
|
||||||
@@ -452,6 +471,7 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
ghEnabled,
|
ghEnabled,
|
||||||
ghTimeoutMs,
|
ghTimeoutMs,
|
||||||
statuslineTtlMs,
|
statuslineTtlMs,
|
||||||
|
costBudgetUsd,
|
||||||
worktreeEnabled,
|
worktreeEnabled,
|
||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
worktreeTimeoutMs,
|
worktreeTimeoutMs,
|
||||||
|
|||||||
79
src/http/digest.ts
Normal file
79
src/http/digest.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* src/http/digest.ts (W3 quick-wins c) — "while you were away" reconnect digest.
|
||||||
|
*
|
||||||
|
* A PURE read-side aggregate over the live-session list (injected, like
|
||||||
|
* buildProjects) plus each session's in-memory telemetry/status. No new state:
|
||||||
|
* it only projects what the manager already tracks into a compact summary the FE
|
||||||
|
* shows as a banner on (re)connect.
|
||||||
|
*
|
||||||
|
* `since` is a client's last-seen epoch-ms watermark: a session counts as
|
||||||
|
* `finished` when it is idle AND produced output after `since`. A bad/absent
|
||||||
|
* `since` clamps to 0 ("everything is new").
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { DigestResult, DigestSession, LiveSessionInfo } from '../types.js'
|
||||||
|
|
||||||
|
/** Last path segment of a cwd (the session "title"), or undefined. */
|
||||||
|
function lastSegment(cwd: string | null): string | undefined {
|
||||||
|
if (cwd === null || cwd === '') return undefined
|
||||||
|
return cwd.split(path.sep).filter(Boolean).pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp `since` to a finite, non-negative number (bad/absent → 0). */
|
||||||
|
export function clampSince(since: unknown): number {
|
||||||
|
const n = typeof since === 'number' ? since : Number(since)
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project one live session into its digest row. */
|
||||||
|
function toDigestSession(s: LiveSessionInfo, since: number): DigestSession {
|
||||||
|
const title = lastSegment(s.cwd)
|
||||||
|
const costUsd = s.telemetry?.costUsd
|
||||||
|
const lastOutputAt = s.lastOutputAt
|
||||||
|
const finished = s.status === 'idle' && lastOutputAt !== undefined && lastOutputAt > since
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
...(title !== undefined ? { title } : {}),
|
||||||
|
status: s.status,
|
||||||
|
...(costUsd !== undefined ? { costUsd } : {}),
|
||||||
|
...(lastOutputAt !== undefined ? { lastOutputAt } : {}),
|
||||||
|
finished,
|
||||||
|
needsInput: s.status === 'waiting',
|
||||||
|
stuck: s.status === 'stuck',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the reconnect digest from the live-session list. Pure — the list is
|
||||||
|
* injected (e.g. `manager.list()`). Empty list → all-zero aggregate. Never throws.
|
||||||
|
*/
|
||||||
|
export function buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult {
|
||||||
|
const clampedSince = clampSince(since)
|
||||||
|
const sessions = live.map((s) => toDigestSession(s, clampedSince))
|
||||||
|
|
||||||
|
let finished = 0
|
||||||
|
let needsInput = 0
|
||||||
|
let stuck = 0
|
||||||
|
let working = 0
|
||||||
|
let totalCostUsd = 0
|
||||||
|
for (const d of sessions) {
|
||||||
|
if (d.finished) finished += 1
|
||||||
|
if (d.needsInput) needsInput += 1
|
||||||
|
if (d.stuck) stuck += 1
|
||||||
|
if (d.status === 'working') working += 1
|
||||||
|
if (d.costUsd !== undefined) totalCostUsd += d.costUsd
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
since: clampedSince,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
total: sessions.length,
|
||||||
|
finished,
|
||||||
|
needsInput,
|
||||||
|
stuck,
|
||||||
|
working,
|
||||||
|
totalCostUsd,
|
||||||
|
sessions,
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/http/git-log.ts
Normal file
109
src/http/git-log.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log.
|
||||||
|
*
|
||||||
|
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
|
||||||
|
* `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing
|
||||||
|
* lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts).
|
||||||
|
*
|
||||||
|
* Delimiter design (robust against nasty subjects): the format is
|
||||||
|
* %h %x1f %ct %x1f %s with -z (records separated by NUL)
|
||||||
|
* so a US (0x1f) field separator + a NUL (0x00) record separator can never be
|
||||||
|
* corrupted by a subject containing tabs, spaces or newlines. We never parse the
|
||||||
|
* fragile `--oneline` shape.
|
||||||
|
*
|
||||||
|
* Security (mirrors diff.ts):
|
||||||
|
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS.
|
||||||
|
* - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an
|
||||||
|
* int and clamped BEFORE reaching argv. Path→repo validation is the route's
|
||||||
|
* job (isValidGitDir, SEC-H7), exactly like /projects/diff.
|
||||||
|
* - parseGitLog NEVER throws: malformed records are skipped; getGitLog is
|
||||||
|
* best-effort and returns an empty result rather than rejecting.
|
||||||
|
* - subjects are carried verbatim and rendered as inert text (textContent) in
|
||||||
|
* the FE — never HTML.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import type { CommitLogEntry, GitLogResult } from '../types.js'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
/** Hard cap on the number of commits a single request may return (DoS bound). */
|
||||||
|
export const GIT_LOG_MAX = 50
|
||||||
|
/** Default number of commits when the caller does not specify `n`. */
|
||||||
|
export const GIT_LOG_DEFAULT = 20
|
||||||
|
/** Cap a single commit subject so a pathological message can't bloat the payload. */
|
||||||
|
const SUBJECT_MAX_LEN = 500
|
||||||
|
/** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */
|
||||||
|
const GIT_LOG_MAX_BUFFER = 1024 * 1024
|
||||||
|
/** Field separator (US, 0x1f) and record separator (NUL, 0x00). */
|
||||||
|
const FIELD_SEP = '\x1f'
|
||||||
|
const RECORD_SEP = '\x00'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric /
|
||||||
|
* missing → GIT_LOG_DEFAULT. Never throws.
|
||||||
|
*/
|
||||||
|
export function clampLogCount(n: unknown): number {
|
||||||
|
const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10)
|
||||||
|
if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT
|
||||||
|
const floored = Math.floor(parsed)
|
||||||
|
if (floored < 1) return 1
|
||||||
|
if (floored > GIT_LOG_MAX) return GIT_LOG_MAX
|
||||||
|
return floored
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[].
|
||||||
|
* Records are NUL-separated; fields are US-separated. A record missing a field
|
||||||
|
* or with a non-numeric timestamp is skipped (never throws). `max` caps the
|
||||||
|
* returned entries and drives the `truncated` flag (records === max ⇒ there may
|
||||||
|
* be more). Empty stdout → an empty, non-truncated result.
|
||||||
|
*/
|
||||||
|
export function parseGitLog(stdout: string, max: number): GitLogResult {
|
||||||
|
if (typeof stdout !== 'string' || stdout.length === 0) {
|
||||||
|
return { commits: [], truncated: false }
|
||||||
|
}
|
||||||
|
const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0)
|
||||||
|
const commits: CommitLogEntry[] = []
|
||||||
|
for (const record of records) {
|
||||||
|
const parts = record.split(FIELD_SEP)
|
||||||
|
if (parts.length < 3) continue // malformed — missing a field
|
||||||
|
const hash = (parts[0] ?? '').trim()
|
||||||
|
const secs = Number.parseInt(parts[1] ?? '', 10)
|
||||||
|
// Subject may (in theory) contain a US char; re-join the tail so it is intact.
|
||||||
|
const subject = parts.slice(2).join(FIELD_SEP)
|
||||||
|
if (hash === '' || !Number.isFinite(secs) || secs < 0) continue
|
||||||
|
commits.push({
|
||||||
|
hash,
|
||||||
|
at: secs * 1000,
|
||||||
|
subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const truncated = commits.length >= max && max > 0
|
||||||
|
return { commits: max > 0 ? commits.slice(0, max) : commits, truncated }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetGitLogOptions {
|
||||||
|
n?: number
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a repo's recent commits (newest first) as a structured GitLogResult.
|
||||||
|
* `repoPath` must already be a validated absolute git directory (route layer,
|
||||||
|
* SEC-H7). Best-effort: any git failure yields an empty result, never throws.
|
||||||
|
*/
|
||||||
|
export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise<GitLogResult> {
|
||||||
|
const n = clampLogCount(opts.n)
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
|
||||||
|
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
|
||||||
|
)
|
||||||
|
return parseGitLog(stdout, n)
|
||||||
|
} catch {
|
||||||
|
return { commits: [], truncated: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,50 @@ async function readDirty(repoPath: string): Promise<boolean | undefined> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time.
|
||||||
|
* Two read-only git calls (no shell), each bounded by timeout + maxBuffer:
|
||||||
|
* 1. `git rev-list --count --left-right @{u}...HEAD` → "<behind>\t<ahead>"
|
||||||
|
* (left = commits on @{u} not HEAD = behind; right = HEAD not @{u} = ahead).
|
||||||
|
* No upstream (`@{u}` fatal) / detached / empty repo → ahead/behind undefined.
|
||||||
|
* 2. `git log -1 --format=%ct` → HEAD commit unix seconds → lastCommitMs (×1000).
|
||||||
|
* Every field degrades to undefined independently; never throws. */
|
||||||
|
async function readSync(repoPath: string): Promise<{
|
||||||
|
ahead?: number
|
||||||
|
behind?: number
|
||||||
|
lastCommitMs?: number
|
||||||
|
}> {
|
||||||
|
const out: { ahead?: number; behind?: number; lastCommitMs?: number } = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['rev-list', '--count', '--left-right', '@{u}...HEAD'],
|
||||||
|
{ cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
|
||||||
|
)
|
||||||
|
const parts = stdout.trim().split(/\s+/)
|
||||||
|
const behind = Number.parseInt(parts[0] ?? '', 10)
|
||||||
|
const ahead = Number.parseInt(parts[1] ?? '', 10)
|
||||||
|
if (Number.isFinite(behind) && behind >= 0) out.behind = behind
|
||||||
|
if (Number.isFinite(ahead) && ahead >= 0) out.ahead = ahead
|
||||||
|
} catch {
|
||||||
|
// no upstream / detached / empty repo → leave ahead/behind undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%ct'], {
|
||||||
|
cwd: repoPath,
|
||||||
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
||||||
|
maxBuffer: GIT_STATUS_MAX_BUFFER,
|
||||||
|
})
|
||||||
|
const secs = Number.parseInt(stdout.trim(), 10)
|
||||||
|
if (Number.isFinite(secs) && secs >= 0) out.lastCommitMs = secs * 1000
|
||||||
|
} catch {
|
||||||
|
// empty repo (no commits) → leave lastCommitMs undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/** True iff `<dir>/.git` exists (file or directory). */
|
/** True iff `<dir>/.git` exists (file or directory). */
|
||||||
async function hasGitEntry(dir: string): Promise<boolean> {
|
async function hasGitEntry(dir: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +168,9 @@ interface MakeProjectArgs {
|
|||||||
readonly branch?: string
|
readonly branch?: string
|
||||||
readonly dirty?: boolean
|
readonly dirty?: boolean
|
||||||
readonly lastActiveMs?: number
|
readonly lastActiveMs?: number
|
||||||
|
readonly ahead?: number
|
||||||
|
readonly behind?: number
|
||||||
|
readonly lastCommitMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeProject(args: MakeProjectArgs): ProjectInfo {
|
function makeProject(args: MakeProjectArgs): ProjectInfo {
|
||||||
@@ -134,6 +181,9 @@ function makeProject(args: MakeProjectArgs): ProjectInfo {
|
|||||||
branch: args.branch,
|
branch: args.branch,
|
||||||
dirty: args.dirty,
|
dirty: args.dirty,
|
||||||
lastActiveMs: args.lastActiveMs,
|
lastActiveMs: args.lastActiveMs,
|
||||||
|
ahead: args.ahead,
|
||||||
|
behind: args.behind,
|
||||||
|
lastCommitMs: args.lastCommitMs,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,8 +325,11 @@ async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
|
|||||||
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
|
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
|
||||||
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
|
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
|
||||||
const branch = await readBranch(repoPath)
|
const branch = await readBranch(repoPath)
|
||||||
|
// W3(a): the sync chip (ahead/behind + last-commit) rides the same per-repo
|
||||||
|
// git budget as the dirty check — gated by projectDirtyCheck, best-effort.
|
||||||
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
|
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
|
||||||
return makeProject({ path: repoPath, isGit: true, branch, dirty })
|
const sync = cfg.projectDirtyCheck ? await readSync(repoPath) : {}
|
||||||
|
return makeProject({ path: repoPath, isGit: true, branch, dirty, ...sync })
|
||||||
})
|
})
|
||||||
const merged = await mergeHistory(repos)
|
const merged = await mergeHistory(repos)
|
||||||
return dropParentFolders(dedupByPath(merged))
|
return dropParentFolders(dedupByPath(merged))
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import { listSessions } from './http/history.js'
|
|||||||
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
||||||
import { openInEditor, openFileInEditor } from './http/editor.js'
|
import { openInEditor, openFileInEditor } from './http/editor.js'
|
||||||
import { getDiff, isPlausibleRev } from './http/diff.js'
|
import { getDiff, isPlausibleRev } from './http/diff.js'
|
||||||
|
import { getGitLog } from './http/git-log.js'
|
||||||
|
import { buildDigest, clampSince } from './http/digest.js'
|
||||||
import { getPrStatus } from './http/gh.js'
|
import { getPrStatus } from './http/gh.js'
|
||||||
import { parseStatusLine } from './http/statusline.js'
|
import { parseStatusLine } from './http/statusline.js'
|
||||||
import { createWorktree } from './http/worktrees.js'
|
import { createWorktree } from './http/worktrees.js'
|
||||||
@@ -326,6 +328,15 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
res.json(manager.list())
|
res.json(manager.list())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
|
||||||
|
// A pure read-side aggregate over manager.list() + in-memory telemetry/status;
|
||||||
|
// no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
|
||||||
|
// the client's last-seen watermark (bad/absent → 0 = "everything is new").
|
||||||
|
app.get('/digest', (req, res) => {
|
||||||
|
const since = clampSince(req.query['since'])
|
||||||
|
res.json(buildDigest(manager.list(), since))
|
||||||
|
})
|
||||||
|
|
||||||
// Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
|
// Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
|
||||||
app.get('/projects', async (_req, res) => {
|
app.get('/projects', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -820,6 +831,30 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── W3 quick-wins (d): read-only recent-commit log (no Origin guard) ──────
|
||||||
|
// Same three-prong path validation (isValidGitDir, SEC-H7) as /projects/diff.
|
||||||
|
// `?n=<int>` is clamped to [1, GIT_LOG_MAX] inside getGitLog; `path` missing →
|
||||||
|
// 400, non-git dir → 404, git failure → best-effort empty (getGitLog) → 200.
|
||||||
|
app.get('/projects/log', async (req, res) => {
|
||||||
|
const target = req.query['path']
|
||||||
|
if (typeof target !== 'string' || target === '') {
|
||||||
|
res.status(400).json({ error: 'path query parameter is required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(await isValidGitDir(target))) {
|
||||||
|
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rawN = req.query['n']
|
||||||
|
const n = typeof rawN === 'string' ? Number.parseInt(rawN, 10) : undefined
|
||||||
|
try {
|
||||||
|
res.json(await getGitLog(target, { n, timeoutMs: cfg.diffTimeoutMs }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[server] /projects/log failed:', err instanceof Error ? err.message : String(err))
|
||||||
|
res.status(500).json({ error: 'failed to read git log' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
|
// ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
|
||||||
// Out-of-band side-channel: spawns the host's `gh` CLI to read the current
|
// Out-of-band side-channel: spawns the host's `gh` CLI to read the current
|
||||||
// branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
|
// branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
|
||||||
@@ -895,7 +930,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
|
|
||||||
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
|
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
|
||||||
app.get('/config/ui', (_req, res) => {
|
app.get('/config/ui', (_req, res) => {
|
||||||
const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode }
|
const uiConfig: UiConfig = {
|
||||||
|
allowAutoMode: cfg.allowAutoMode,
|
||||||
|
// W3(b): expose the cost budget only when set (>0) so the FE can derive
|
||||||
|
// cost-overage warn styling; a non-secret number, safe over /config/ui.
|
||||||
|
...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
|
||||||
|
}
|
||||||
res.json(uiConfig)
|
res.json(uiConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -265,6 +265,29 @@ export function createSessionManager(
|
|||||||
if (session === undefined) return;
|
if (session === undefined) return;
|
||||||
session.telemetry = telemetry;
|
session.telemetry = telemetry;
|
||||||
broadcast(session, { type: 'telemetry', telemetry });
|
broadcast(session, { type: 'telemetry', telemetry });
|
||||||
|
maybeAlertBudget(session, telemetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W3 quick-wins (b): fire a one-shot cost-budget alert when a session's cost
|
||||||
|
* first crosses COST_BUDGET_USD. Mirrors the A5 stuck-latch shape but is NEVER
|
||||||
|
* re-armed (cost is monotonic) — so it fires at most once per session. Disabled
|
||||||
|
* when the budget is 0/unset or the frame carries no cost.
|
||||||
|
*
|
||||||
|
* No new ServerMessage variant: the "warning broadcast" is the telemetry frame
|
||||||
|
* already sent above (clients derive the warn from costUsd >= costBudgetUsd via
|
||||||
|
* GET /config/ui). The one distinct new action on crossing is a 'budget' push.
|
||||||
|
*/
|
||||||
|
function maybeAlertBudget(session: Session, telemetry: StatusTelemetry): void {
|
||||||
|
if (cfg.costBudgetUsd <= 0) return; // disabled
|
||||||
|
if (session.budgetNotified) return; // already alerted (latch)
|
||||||
|
const cost = telemetry.costUsd;
|
||||||
|
if (cost === undefined || cost < cfg.costBudgetUsd) return; // no crossing
|
||||||
|
|
||||||
|
session.budgetNotified = true;
|
||||||
|
void notifyService?.notify(session, 'budget').catch((err: unknown) => {
|
||||||
|
console.error('[manager] budget notification failed', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function createSession(
|
|||||||
// v0.7 Walk-away Workbench fields (T-spawn-env):
|
// v0.7 Walk-away Workbench fields (T-spawn-env):
|
||||||
timeline: Object.freeze([] as TimelineEvent[]),
|
timeline: Object.freeze([] as TimelineEvent[]),
|
||||||
stuckNotified: false, // A5: re-armed to false by each pty output
|
stuckNotified: false, // A5: re-armed to false by each pty output
|
||||||
|
budgetNotified: false, // W3(b): cost-budget one-shot latch, never re-armed
|
||||||
telemetry: null, // B2: updated by manager.handleStatusLine
|
telemetry: null, // B2: updated by manager.handleStatusLine
|
||||||
// W2: inject follow-up queue — empty at spawn; replaced wholesale by manager.
|
// W2: inject follow-up queue — empty at spawn; replaced wholesale by manager.
|
||||||
queue: Object.freeze([] as string[]),
|
queue: Object.freeze([] as string[]),
|
||||||
|
|||||||
62
src/types.ts
62
src/types.ts
@@ -66,6 +66,8 @@ export interface Config {
|
|||||||
readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
|
readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
|
||||||
// B2 statusLine telemetry
|
// B2 statusLine telemetry
|
||||||
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
|
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
|
||||||
|
// W3 quick-wins (b) cost budget guard
|
||||||
|
readonly costBudgetUsd: number; // COST_BUDGET_USD, default 0 (0/unset = disabled)
|
||||||
// B3 git worktree creation
|
// B3 git worktree creation
|
||||||
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
|
||||||
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
|
||||||
@@ -257,6 +259,10 @@ export interface Session {
|
|||||||
/** A5: true once a stuck alert fired this round; re-armed (→false) by the next
|
/** A5: true once a stuck alert fired this round; re-armed (→false) by the next
|
||||||
* pty.onData so each silent round alerts at most once. */
|
* pty.onData so each silent round alerts at most once. */
|
||||||
stuckNotified: boolean;
|
stuckNotified: boolean;
|
||||||
|
/** W3 quick-wins (b): true once the cost-budget alert fired for this session.
|
||||||
|
* One-shot latch — NEVER re-armed (cost is monotonic), so the budget push +
|
||||||
|
* warning broadcast happen at most once per session. */
|
||||||
|
budgetNotified: boolean;
|
||||||
/** B2: latest statusLine telemetry for this session; null until first report. */
|
/** B2: latest statusLine telemetry for this session; null until first report. */
|
||||||
telemetry: StatusTelemetry | null;
|
telemetry: StatusTelemetry | null;
|
||||||
/** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes
|
/** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes
|
||||||
@@ -322,6 +328,10 @@ export interface ProjectInfo {
|
|||||||
branch?: string; // current branch (git repos only)
|
branch?: string; // current branch (git repos only)
|
||||||
dirty?: boolean; // uncommitted changes (when projectDirtyCheck)
|
dirty?: boolean; // uncommitted changes (when projectDirtyCheck)
|
||||||
lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key
|
lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key
|
||||||
|
// W3 quick-wins (a) sync chip — best-effort git ahead/behind vs @{u} + last commit.
|
||||||
|
ahead?: number; // commits on HEAD not on @{u} (git rev-list, right count)
|
||||||
|
behind?: number; // commits on @{u} not on HEAD (git rev-list, left count)
|
||||||
|
lastCommitMs?: number; // git log -1 --format=%ct * 1000 (HEAD commit time)
|
||||||
sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty)
|
sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,8 +441,9 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto';
|
|||||||
|
|
||||||
/* ── A1 push notifications (§3.3, §A1) ── */
|
/* ── A1 push notifications (§3.3, §A1) ── */
|
||||||
|
|
||||||
/** The three proactive signals pushed to the phone (§3.3 / §A1). */
|
/** The proactive signals pushed to the phone (§3.3 / §A1). 'budget' (W3
|
||||||
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
|
* quick-wins b) fires once when a session's cost crosses COST_BUDGET_USD. */
|
||||||
|
export type NotifyClass = 'needs-input' | 'done' | 'stuck' | 'budget';
|
||||||
|
|
||||||
/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
|
/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
|
||||||
* (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
|
* (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
|
||||||
@@ -601,6 +612,53 @@ export interface UiPrefs {
|
|||||||
* permission mode when the server forbids it (SEC-M5). */
|
* permission mode when the server forbids it (SEC-M5). */
|
||||||
export interface UiConfig {
|
export interface UiConfig {
|
||||||
allowAutoMode: boolean;
|
allowAutoMode: boolean;
|
||||||
|
/** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the
|
||||||
|
* FE can derive cost-overage warn styling client-side; omitted when disabled. */
|
||||||
|
costBudgetUsd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */
|
||||||
|
|
||||||
|
/** One session in the "while you were away" digest — a read-side projection of a
|
||||||
|
* live session plus its latest telemetry/status. All fields derived, no new state. */
|
||||||
|
export interface DigestSession {
|
||||||
|
id: string;
|
||||||
|
title?: string; // last cwd segment
|
||||||
|
status: ClaudeStatus;
|
||||||
|
costUsd?: number; // telemetry.costUsd
|
||||||
|
lastOutputAt?: number;
|
||||||
|
finished: boolean; // status==='idle' && lastOutputAt > since
|
||||||
|
needsInput: boolean; // status==='waiting'
|
||||||
|
stuck: boolean; // status==='stuck'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /digest result — an aggregate over manager.list() since a client's
|
||||||
|
* last-seen timestamp. Pure read (no new state); empty when no sessions. */
|
||||||
|
export interface DigestResult {
|
||||||
|
since: number;
|
||||||
|
generatedAt: number;
|
||||||
|
total: number;
|
||||||
|
finished: number;
|
||||||
|
needsInput: number;
|
||||||
|
stuck: number;
|
||||||
|
working: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
sessions: DigestSession[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── W3 quick-wins (d) recent-commits log (GET /projects/log) ── */
|
||||||
|
|
||||||
|
/** One commit from `git log` (NUL-record, US-field delimited). `at` = %ct*1000. */
|
||||||
|
export interface CommitLogEntry {
|
||||||
|
hash: string;
|
||||||
|
at: number;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /projects/log result. `truncated` = more commits exist beyond the cap. */
|
||||||
|
export interface GitLogResult {
|
||||||
|
commits: CommitLogEntry[];
|
||||||
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */
|
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */
|
||||||
|
|||||||
@@ -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', () => {
|
describe('loadConfig — v0.7 B3 worktree', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockNetworkInterfaces.mockReturnValue({})
|
mockNetworkInterfaces.mockReturnValue({})
|
||||||
|
|||||||
165
test/digest.test.ts
Normal file
165
test/digest.test.ts
Normal 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
151
test/git-log.test.ts
Normal 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
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
78
test/integration/digest-endpoint.test.ts
Normal file
78
test/integration/digest-endpoint.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -214,4 +214,17 @@ describe('GET /config/ui', () => {
|
|||||||
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
|
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
|
||||||
expect(await res.json()).toEqual({ allowAutoMode: true })
|
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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) ─────
|
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
|
||||||
describe('handleAttach — late-join replay (M3)', () => {
|
describe('handleAttach — late-join replay (M3)', () => {
|
||||||
it('sends the current telemetry to a device joining a live session', () => {
|
it('sends the current telemetry to a device joining a live session', () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const {
|
|||||||
toggleFav,
|
toggleFav,
|
||||||
normalizeProject,
|
normalizeProject,
|
||||||
makeProjectCard,
|
makeProjectCard,
|
||||||
|
makeSyncChip,
|
||||||
renderProjectDetail,
|
renderProjectDetail,
|
||||||
groupProjects,
|
groupProjects,
|
||||||
displayLabel,
|
displayLabel,
|
||||||
@@ -285,6 +286,65 @@ describe('normalizeProject', () => {
|
|||||||
expect(p?.lastActiveMs).toBeUndefined()
|
expect(p?.lastActiveMs).toBeUndefined()
|
||||||
expect(p?.isGit).toBe(false)
|
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) ────────────────── */
|
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────
|
// ── buildProjectDetail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('buildProjectDetail', () => {
|
describe('buildProjectDetail', () => {
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ describe('createSession', () => {
|
|||||||
expect(s.exitCode).toBeNull();
|
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)', () => {
|
it('THROWS (does not swallow) when spawn fails (M4)', () => {
|
||||||
spawnError = new Error('spawn failed: /no/such/shell ENOENT');
|
spawnError = new Error('spawn failed: /no/such/shell ENOENT');
|
||||||
nextPty = createMockPty();
|
nextPty = createMockPty();
|
||||||
|
|||||||
@@ -129,6 +129,36 @@ describe('renderTelemetryGauge', () => {
|
|||||||
expect(c.querySelector('.tg-cost')).toBeNull()
|
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 ──
|
// ── model chip ──
|
||||||
|
|
||||||
it('renders model chip when model is present', () => {
|
it('renders model chip when model is present', () => {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export default defineConfig({
|
|||||||
'public/grid-presets.ts',
|
'public/grid-presets.ts',
|
||||||
'public/cell-monitor.ts',
|
'public/cell-monitor.ts',
|
||||||
'public/preview-grid.ts',
|
'public/preview-grid.ts',
|
||||||
|
'public/git-log.ts',
|
||||||
|
'public/digest.ts',
|
||||||
'public/title-util.ts',
|
'public/title-util.ts',
|
||||||
'public/voice-commands.ts',
|
'public/voice-commands.ts',
|
||||||
'public/voice-confirm.ts',
|
'public/voice-confirm.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user