feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
348
test/http/diff.test.ts
Normal file
348
test/http/diff.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* test/http/diff.test.ts (N-diff-be, B1) — read-only git diff parsing + getDiff.
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Pure parsers (parseNumstat / parseUnifiedDiff) fed canned git output — the
|
||||
* deterministic core (AC-B1.5: rename/new/delete/binary/empty/untracked, +/-
|
||||
* consistent with `git diff --numstat`; never throws; <script> stays text).
|
||||
* 2. getDiff against a REAL throwaway git repo in os.tmpdir (AC-B1.1, L3).
|
||||
*/
|
||||
|
||||
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 {
|
||||
parseNumstat,
|
||||
parseUnifiedDiff,
|
||||
getDiff,
|
||||
type NumstatEntry,
|
||||
type GetDiffOptions,
|
||||
} from '../../src/http/diff.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const LIMITS: GetDiffOptions['cfg'] = {
|
||||
diffTimeoutMs: 5000,
|
||||
diffMaxBytes: 2 * 1024 * 1024,
|
||||
diffMaxFiles: 300,
|
||||
}
|
||||
|
||||
// ── parseNumstat ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseNumstat', () => {
|
||||
it('parses a basic added/removed line', () => {
|
||||
const m = parseNumstat('2\t1\tfoo.txt\n')
|
||||
expect(m.get('foo.txt')).toEqual<NumstatEntry>({ added: 2, removed: 1, binary: false })
|
||||
})
|
||||
|
||||
it('flags binary files (-\\t-) with zero counts', () => {
|
||||
const m = parseNumstat('-\t-\timg.png\n')
|
||||
expect(m.get('img.png')).toEqual<NumstatEntry>({ added: 0, removed: 0, binary: true })
|
||||
})
|
||||
|
||||
it('keys a plain-arrow rename under both old and new paths', () => {
|
||||
const m = parseNumstat('1\t1\told.txt => new.txt\n')
|
||||
const entry = { added: 1, removed: 1, binary: false }
|
||||
expect(m.get('old.txt')).toEqual(entry)
|
||||
expect(m.get('new.txt')).toEqual(entry)
|
||||
})
|
||||
|
||||
it('expands a braced rename path (common prefix/suffix)', () => {
|
||||
const m = parseNumstat('3\t4\tsrc/{a => b}/file.js\n')
|
||||
expect(m.has('src/a/file.js')).toBe(true)
|
||||
expect(m.has('src/b/file.js')).toBe(true)
|
||||
expect(m.get('src/b/file.js')).toEqual({ added: 3, removed: 4, binary: false })
|
||||
})
|
||||
|
||||
it('collapses double slashes when a brace side is empty', () => {
|
||||
const m = parseNumstat('1\t0\tsrc/{ => sub}/file.txt\n')
|
||||
expect(m.has('src/file.txt')).toBe(true)
|
||||
expect(m.has('src/sub/file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns an empty map for empty input', () => {
|
||||
expect(parseNumstat('').size).toBe(0)
|
||||
})
|
||||
|
||||
it('skips malformed lines without throwing', () => {
|
||||
const m = parseNumstat('garbage\n2\t1\tok.txt\n\n')
|
||||
expect(m.size).toBe(1)
|
||||
expect(m.get('ok.txt')).toEqual({ added: 2, removed: 1, binary: false })
|
||||
})
|
||||
})
|
||||
|
||||
// ── parseUnifiedDiff ─────────────────────────────────────────────────────────
|
||||
|
||||
const MODIFIED = [
|
||||
'diff --git a/foo.txt b/foo.txt',
|
||||
'index e69de29..d95f3ad 100644',
|
||||
'--- a/foo.txt',
|
||||
'+++ b/foo.txt',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' keep',
|
||||
'-old',
|
||||
'+new',
|
||||
'+added',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
describe('parseUnifiedDiff', () => {
|
||||
it('returns [] for empty / non-diff input', () => {
|
||||
expect(parseUnifiedDiff('')).toEqual([])
|
||||
expect(parseUnifiedDiff('not a diff at all')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a modified file into one hunk with typed lines', () => {
|
||||
const [file] = parseUnifiedDiff(MODIFIED)
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.oldPath).toBe('foo.txt')
|
||||
expect(file?.newPath).toBe('foo.txt')
|
||||
expect(file?.status).toBe('modified')
|
||||
expect(file?.binary).toBe(false)
|
||||
expect(file?.hunks).toHaveLength(1)
|
||||
expect(file?.hunks[0]?.header).toBe('@@ -1,2 +1,3 @@')
|
||||
expect(file?.hunks[0]?.lines).toEqual([
|
||||
{ kind: 'context', text: 'keep' },
|
||||
{ kind: 'removed', text: 'old' },
|
||||
{ kind: 'added', text: 'new' },
|
||||
{ kind: 'added', text: 'added' },
|
||||
])
|
||||
})
|
||||
|
||||
it('counts +/- from the hunk when no numstat is supplied', () => {
|
||||
const [file] = parseUnifiedDiff(MODIFIED)
|
||||
expect(file?.added).toBe(2)
|
||||
expect(file?.removed).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers numstat counts over hunk counts', () => {
|
||||
const numstat = parseNumstat('99\t88\tfoo.txt\n')
|
||||
const [file] = parseUnifiedDiff(MODIFIED, numstat)
|
||||
expect(file?.added).toBe(99)
|
||||
expect(file?.removed).toBe(88)
|
||||
})
|
||||
|
||||
it('detects an added file via /dev/null source', () => {
|
||||
const patch = [
|
||||
'diff --git a/new.txt b/new.txt',
|
||||
'new file mode 100644',
|
||||
'index 0000000..1234567',
|
||||
'--- /dev/null',
|
||||
'+++ b/new.txt',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+line one',
|
||||
'+line two',
|
||||
'',
|
||||
].join('\n')
|
||||
const [file] = parseUnifiedDiff(patch)
|
||||
expect(file?.status).toBe('added')
|
||||
expect(file?.newPath).toBe('new.txt')
|
||||
expect(file?.added).toBe(2)
|
||||
expect(file?.removed).toBe(0)
|
||||
})
|
||||
|
||||
it('detects a deleted file via /dev/null target', () => {
|
||||
const patch = [
|
||||
'diff --git a/del.txt b/del.txt',
|
||||
'deleted file mode 100644',
|
||||
'index 1234567..0000000',
|
||||
'--- a/del.txt',
|
||||
'+++ /dev/null',
|
||||
'@@ -1,2 +0,0 @@',
|
||||
'-line one',
|
||||
'-line two',
|
||||
'',
|
||||
].join('\n')
|
||||
const [file] = parseUnifiedDiff(patch)
|
||||
expect(file?.status).toBe('deleted')
|
||||
expect(file?.oldPath).toBe('del.txt')
|
||||
expect(file?.removed).toBe(2)
|
||||
})
|
||||
|
||||
it('detects a rename and maps numstat by the new path', () => {
|
||||
const patch = [
|
||||
'diff --git a/old/name.txt b/new/name.txt',
|
||||
'similarity index 95%',
|
||||
'rename from old/name.txt',
|
||||
'rename to new/name.txt',
|
||||
'index 111..222 100644',
|
||||
'--- a/old/name.txt',
|
||||
'+++ b/new/name.txt',
|
||||
'@@ -1 +1 @@',
|
||||
'-hello',
|
||||
'+hello world',
|
||||
'',
|
||||
].join('\n')
|
||||
const numstat = parseNumstat('1\t1\t{old => new}/name.txt\n')
|
||||
const [file] = parseUnifiedDiff(patch, numstat)
|
||||
expect(file?.status).toBe('renamed')
|
||||
expect(file?.oldPath).toBe('old/name.txt')
|
||||
expect(file?.newPath).toBe('new/name.txt')
|
||||
expect(file?.added).toBe(1)
|
||||
expect(file?.removed).toBe(1)
|
||||
})
|
||||
|
||||
it('detects a binary file (Binary files … differ + numstat -\\t-)', () => {
|
||||
const patch = [
|
||||
'diff --git a/img.png b/img.png',
|
||||
'index 111..222 100644',
|
||||
'Binary files a/img.png and b/img.png differ',
|
||||
'',
|
||||
].join('\n')
|
||||
const numstat = parseNumstat('-\t-\timg.png\n')
|
||||
const [file] = parseUnifiedDiff(patch, numstat)
|
||||
expect(file?.status).toBe('binary')
|
||||
expect(file?.binary).toBe(true)
|
||||
expect(file?.hunks).toEqual([])
|
||||
expect(file?.added).toBe(0)
|
||||
expect(file?.removed).toBe(0)
|
||||
})
|
||||
|
||||
it('parses multiple hunks and a no-newline meta marker', () => {
|
||||
const patch = [
|
||||
'diff --git a/m.txt b/m.txt',
|
||||
'index 111..222 100644',
|
||||
'--- a/m.txt',
|
||||
'+++ b/m.txt',
|
||||
'@@ -1,2 +1,2 @@',
|
||||
' a',
|
||||
'-b',
|
||||
'+B',
|
||||
'@@ -10,2 +10,3 @@',
|
||||
' x',
|
||||
'+y',
|
||||
'\\ No newline at end of file',
|
||||
'',
|
||||
].join('\n')
|
||||
const [file] = parseUnifiedDiff(patch)
|
||||
expect(file?.hunks).toHaveLength(2)
|
||||
const meta = file?.hunks[1]?.lines.find((l) => l.kind === 'meta')
|
||||
expect(meta?.text).toContain('No newline')
|
||||
})
|
||||
|
||||
it('classifies an unrecognised hunk line as context (never throws)', () => {
|
||||
const patch = [
|
||||
'diff --git a/x.txt b/x.txt',
|
||||
'--- a/x.txt',
|
||||
'+++ b/x.txt',
|
||||
'@@ -1 +1 @@',
|
||||
'?weird line',
|
||||
'',
|
||||
].join('\n')
|
||||
const [file] = parseUnifiedDiff(patch)
|
||||
expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'context', text: '?weird line' })
|
||||
})
|
||||
|
||||
it('preserves <script>/ANSI/backtick content verbatim as text (AC-B1.4)', () => {
|
||||
const payload = '<script>alert(1)</script> `cmd` [31mred'
|
||||
const patch = [
|
||||
'diff --git a/x.txt b/x.txt',
|
||||
'--- a/x.txt',
|
||||
'+++ b/x.txt',
|
||||
'@@ -0,0 +1 @@',
|
||||
'+' + payload,
|
||||
'',
|
||||
].join('\n')
|
||||
const [file] = parseUnifiedDiff(patch)
|
||||
expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'added', text: payload })
|
||||
})
|
||||
|
||||
it('parses several files in one patch', () => {
|
||||
const patch = MODIFIED + [
|
||||
'diff --git a/bar.txt b/bar.txt',
|
||||
'--- a/bar.txt',
|
||||
'+++ b/bar.txt',
|
||||
'@@ -1 +1 @@',
|
||||
'-one',
|
||||
'+two',
|
||||
'',
|
||||
].join('\n')
|
||||
const files = parseUnifiedDiff(patch)
|
||||
expect(files.map((f) => f.newPath)).toEqual(['foo.txt', 'bar.txt'])
|
||||
})
|
||||
})
|
||||
|
||||
// ── getDiff (integration against a real git repo) ────────────────────────────
|
||||
|
||||
async function git(cwd: string, ...args: string[]): Promise<void> {
|
||||
await execFileAsync('git', args, { cwd })
|
||||
}
|
||||
|
||||
describe('getDiff (real git repo)', () => {
|
||||
let repo: string
|
||||
|
||||
beforeAll(async () => {
|
||||
repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-diff-'))
|
||||
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')
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n')
|
||||
await git(repo, 'add', '.')
|
||||
await git(repo, 'commit', '-q', '-m', 'init')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns structured working-tree diff with numstat-consistent counts', async () => {
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\nTWO\nthree\nfour\n')
|
||||
const result = await getDiff(repo, { staged: false, cfg: LIMITS })
|
||||
expect(result.staged).toBe(false)
|
||||
expect(result.truncated).toBe(false)
|
||||
const file = result.files.find((f) => f.newPath === 'tracked.txt')
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.status).toBe('modified')
|
||||
// changed line two + added line four = 2 added, 1 removed
|
||||
expect(file?.added).toBe(2)
|
||||
expect(file?.removed).toBe(1)
|
||||
// revert for later tests
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n')
|
||||
})
|
||||
|
||||
it('lists untracked files via porcelain (L3, working tree only)', async () => {
|
||||
await fs.writeFile(path.join(repo, 'fresh.txt'), 'brand new\n')
|
||||
const result = await getDiff(repo, { staged: false, cfg: LIMITS })
|
||||
const untracked = result.files.find((f) => f.newPath === 'fresh.txt')
|
||||
expect(untracked?.status).toBe('untracked')
|
||||
await fs.rm(path.join(repo, 'fresh.txt'))
|
||||
})
|
||||
|
||||
it('does not include untracked files in the staged diff', async () => {
|
||||
await fs.writeFile(path.join(repo, 'staged-only.txt'), 'queued\n')
|
||||
await git(repo, 'add', 'staged-only.txt')
|
||||
const result = await getDiff(repo, { staged: true, cfg: LIMITS })
|
||||
expect(result.staged).toBe(true)
|
||||
const file = result.files.find((f) => f.newPath === 'staged-only.txt')
|
||||
expect(file?.status).toBe('added')
|
||||
expect(result.files.every((f) => f.status !== 'untracked')).toBe(true)
|
||||
await git(repo, 'reset', '-q')
|
||||
await fs.rm(path.join(repo, 'staged-only.txt'))
|
||||
})
|
||||
|
||||
it('marks truncated when the file count exceeds diffMaxFiles', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await fs.writeFile(path.join(repo, `gen-${i}.txt`), `content ${i}\n`)
|
||||
}
|
||||
const result = await getDiff(repo, {
|
||||
staged: false,
|
||||
cfg: { ...LIMITS, diffMaxFiles: 2 },
|
||||
})
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.files.length).toBeLessThanOrEqual(2)
|
||||
for (let i = 0; i < 5; i++) await fs.rm(path.join(repo, `gen-${i}.txt`))
|
||||
})
|
||||
|
||||
it('never throws for a non-existent / non-git path (returns empty)', async () => {
|
||||
const result = await getDiff(path.join(os.tmpdir(), 'definitely-not-here-xyz'), {
|
||||
staged: false,
|
||||
cfg: LIMITS,
|
||||
})
|
||||
expect(result.files).toEqual([])
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
})
|
||||
373
test/http/statusline.test.ts
Normal file
373
test/http/statusline.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* test/http/statusline.test.ts — TDD tests for parseStatusLine (N-statusline-be).
|
||||
*
|
||||
* Acceptance: AC-B2.6 — full/missing/dirty/empty bodies; never throw; ≥80% coverage.
|
||||
* Security: SEC-M7 — unknown + narrowing, string length limits.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { parseStatusLine } from '../../src/http/statusline.js'
|
||||
import type { StatusTelemetry } from '../../src/types.js'
|
||||
|
||||
// Representative full Claude Code statusLine JSON body.
|
||||
// Field mapping (R0 → StatusTelemetry): context_window.used_percentage→contextUsedPct,
|
||||
// cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
|
||||
// model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate.
|
||||
const FULL_BODY = {
|
||||
context_window: { used_percentage: 42.5 },
|
||||
cost: { total_cost_usd: 0.05, total_lines_added: 100, total_lines_removed: 50 },
|
||||
model: { display_name: 'Claude 3.5 Sonnet' },
|
||||
effort: { level: 'normal' },
|
||||
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
|
||||
rate_limits: { fiveHourPct: 10, sevenDayPct: 20 },
|
||||
}
|
||||
|
||||
const FIXED_NOW = 1_700_000_000_000
|
||||
|
||||
describe('parseStatusLine — null for non-objects', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(parseStatusLine(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(parseStatusLine(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a string', () => {
|
||||
expect(parseStatusLine('hello')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a number', () => {
|
||||
expect(parseStatusLine(42)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a boolean', () => {
|
||||
expect(parseStatusLine(true)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an array (typeof is "object" but is not a plain object)', () => {
|
||||
expect(parseStatusLine([])).toBeNull()
|
||||
expect(parseStatusLine([1, 2, 3])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — full valid body', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('maps all fields correctly from a fully-populated body', () => {
|
||||
const result = parseStatusLine(FULL_BODY)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const t = result as StatusTelemetry
|
||||
|
||||
expect(t.contextUsedPct).toBe(42.5)
|
||||
expect(t.costUsd).toBe(0.05)
|
||||
expect(t.linesAdded).toBe(100)
|
||||
expect(t.linesRemoved).toBe(50)
|
||||
expect(t.model).toBe('Claude 3.5 Sonnet')
|
||||
expect(t.effort).toBe('normal')
|
||||
expect(t.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' })
|
||||
expect(t.rate).toEqual({ fiveHourPct: 10, sevenDayPct: 20 })
|
||||
expect(t.at).toBe(FIXED_NOW)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — empty/minimal body', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns a StatusTelemetry with only at for an empty object', () => {
|
||||
const result = parseStatusLine({})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.at).toBe(FIXED_NOW)
|
||||
expect(result?.contextUsedPct).toBeUndefined()
|
||||
expect(result?.costUsd).toBeUndefined()
|
||||
expect(result?.linesAdded).toBeUndefined()
|
||||
expect(result?.linesRemoved).toBeUndefined()
|
||||
expect(result?.model).toBeUndefined()
|
||||
expect(result?.effort).toBeUndefined()
|
||||
expect(result?.pr).toBeUndefined()
|
||||
expect(result?.rate).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — at timestamp', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sets at to Date.now() on each call', () => {
|
||||
const t1 = 1000
|
||||
const t2 = 2000
|
||||
vi.spyOn(Date, 'now').mockReturnValueOnce(t1).mockReturnValueOnce(t2)
|
||||
|
||||
expect(parseStatusLine({})?.at).toBe(t1)
|
||||
expect(parseStatusLine({})?.at).toBe(t2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — dirty number fields', () => {
|
||||
it('omits contextUsedPct when used_percentage is NaN', () => {
|
||||
expect(
|
||||
parseStatusLine({ context_window: { used_percentage: NaN } })?.contextUsedPct,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits contextUsedPct when used_percentage is Infinity', () => {
|
||||
expect(
|
||||
parseStatusLine({ context_window: { used_percentage: Infinity } })?.contextUsedPct,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits contextUsedPct when used_percentage is a string', () => {
|
||||
expect(
|
||||
parseStatusLine({ context_window: { used_percentage: '42' } })?.contextUsedPct,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits costUsd when total_cost_usd is Infinity', () => {
|
||||
expect(parseStatusLine({ cost: { total_cost_usd: Infinity } })?.costUsd).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits costUsd when total_cost_usd is NaN', () => {
|
||||
expect(parseStatusLine({ cost: { total_cost_usd: NaN } })?.costUsd).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits linesAdded when total_lines_added is a string', () => {
|
||||
expect(parseStatusLine({ cost: { total_lines_added: 'bad' } })?.linesAdded).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits linesRemoved when total_lines_removed is null', () => {
|
||||
expect(parseStatusLine({ cost: { total_lines_removed: null } })?.linesRemoved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses partial cost fields independently', () => {
|
||||
const result = parseStatusLine({ cost: { total_cost_usd: 1.5 } })
|
||||
|
||||
expect(result?.costUsd).toBe(1.5)
|
||||
expect(result?.linesAdded).toBeUndefined()
|
||||
expect(result?.linesRemoved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — context_window not an object', () => {
|
||||
it('omits contextUsedPct when context_window is a string', () => {
|
||||
expect(parseStatusLine({ context_window: 'bad' })?.contextUsedPct).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits contextUsedPct when context_window is null', () => {
|
||||
expect(parseStatusLine({ context_window: null })?.contextUsedPct).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits contextUsedPct when context_window is a number', () => {
|
||||
expect(parseStatusLine({ context_window: 42 })?.contextUsedPct).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — cost not an object', () => {
|
||||
it('omits all cost fields when cost is a string', () => {
|
||||
const result = parseStatusLine({ cost: 'bad' })
|
||||
|
||||
expect(result?.costUsd).toBeUndefined()
|
||||
expect(result?.linesAdded).toBeUndefined()
|
||||
expect(result?.linesRemoved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits all cost fields when cost is null', () => {
|
||||
const result = parseStatusLine({ cost: null })
|
||||
|
||||
expect(result?.costUsd).toBeUndefined()
|
||||
expect(result?.linesAdded).toBeUndefined()
|
||||
expect(result?.linesRemoved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — dirty string fields (SEC-M7 length caps)', () => {
|
||||
it('omits model when display_name exceeds 200 characters', () => {
|
||||
expect(
|
||||
parseStatusLine({ model: { display_name: 'a'.repeat(201) } })?.model,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts model at exactly 200 characters', () => {
|
||||
const model200 = 'a'.repeat(200)
|
||||
expect(parseStatusLine({ model: { display_name: model200 } })?.model).toBe(model200)
|
||||
})
|
||||
|
||||
it('omits model when model field is not an object', () => {
|
||||
expect(parseStatusLine({ model: 'bad' })?.model).toBeUndefined()
|
||||
expect(parseStatusLine({ model: null })?.model).toBeUndefined()
|
||||
expect(parseStatusLine({ model: 42 })?.model).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits effort when level is a number', () => {
|
||||
expect(parseStatusLine({ effort: { level: 42 } })?.effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits effort when level exceeds 100 characters', () => {
|
||||
expect(
|
||||
parseStatusLine({ effort: { level: 'x'.repeat(101) } })?.effort,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts effort at exactly 100 characters', () => {
|
||||
const effort100 = 'e'.repeat(100)
|
||||
expect(parseStatusLine({ effort: { level: effort100 } })?.effort).toBe(effort100)
|
||||
})
|
||||
|
||||
it('omits effort when effort field is not an object', () => {
|
||||
expect(parseStatusLine({ effort: 42 })?.effort).toBeUndefined()
|
||||
expect(parseStatusLine({ effort: null })?.effort).toBeUndefined()
|
||||
expect(parseStatusLine({ effort: [] })?.effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — pr parsing', () => {
|
||||
it('parses pr with all fields including optional reviewState', () => {
|
||||
const result = parseStatusLine({
|
||||
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
|
||||
})
|
||||
|
||||
expect(result?.pr).toEqual({
|
||||
number: 42,
|
||||
url: 'https://github.com/foo/bar/pull/42',
|
||||
reviewState: 'pending',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses pr without optional reviewState', () => {
|
||||
const result = parseStatusLine({ pr: { number: 5, url: 'https://github.com/pr/5' } })
|
||||
|
||||
expect(result?.pr).toBeDefined()
|
||||
expect(result?.pr?.number).toBe(5)
|
||||
expect(result?.pr?.url).toBe('https://github.com/pr/5')
|
||||
expect(result?.pr?.reviewState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr when pr.number is missing', () => {
|
||||
expect(parseStatusLine({ pr: { url: 'https://example.com' } })?.pr).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr when pr.url is missing', () => {
|
||||
expect(parseStatusLine({ pr: { number: 1 } })?.pr).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr when both required fields are missing', () => {
|
||||
expect(parseStatusLine({ pr: {} })?.pr).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr when pr is not an object', () => {
|
||||
expect(parseStatusLine({ pr: 'not-an-object' })?.pr).toBeUndefined()
|
||||
expect(parseStatusLine({ pr: null })?.pr).toBeUndefined()
|
||||
expect(parseStatusLine({ pr: 42 })?.pr).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr when pr.url exceeds 2000 characters', () => {
|
||||
const longUrl = 'https://example.com/' + 'x'.repeat(1990)
|
||||
expect(parseStatusLine({ pr: { number: 1, url: longUrl } })?.pr).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr.reviewState but keeps pr when reviewState exceeds 100 characters', () => {
|
||||
const result = parseStatusLine({
|
||||
pr: {
|
||||
number: 1,
|
||||
url: 'https://github.com/pr/1',
|
||||
reviewState: 'x'.repeat(101),
|
||||
},
|
||||
})
|
||||
|
||||
expect(result?.pr).toBeDefined()
|
||||
expect(result?.pr?.number).toBe(1)
|
||||
expect(result?.pr?.url).toBe('https://github.com/pr/1')
|
||||
expect(result?.pr?.reviewState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits pr when pr.number is NaN', () => {
|
||||
expect(parseStatusLine({ pr: { number: NaN, url: 'https://github.com' } })?.pr).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — rate_limits parsing', () => {
|
||||
it('parses rate_limits with both fields', () => {
|
||||
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25, sevenDayPct: 50 } })
|
||||
|
||||
expect(result?.rate).toEqual({ fiveHourPct: 25, sevenDayPct: 50 })
|
||||
})
|
||||
|
||||
it('parses rate_limits with only fiveHourPct', () => {
|
||||
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25 } })
|
||||
|
||||
expect(result?.rate?.fiveHourPct).toBe(25)
|
||||
expect(result?.rate?.sevenDayPct).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses rate_limits with only sevenDayPct', () => {
|
||||
const result = parseStatusLine({ rate_limits: { sevenDayPct: 75 } })
|
||||
|
||||
expect(result?.rate?.fiveHourPct).toBeUndefined()
|
||||
expect(result?.rate?.sevenDayPct).toBe(75)
|
||||
})
|
||||
|
||||
it('omits rate when rate_limits is not an object', () => {
|
||||
expect(parseStatusLine({ rate_limits: 'bad' })?.rate).toBeUndefined()
|
||||
expect(parseStatusLine({ rate_limits: null })?.rate).toBeUndefined()
|
||||
expect(parseStatusLine({ rate_limits: 42 })?.rate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns rate object with undefined sub-fields when rate_limits values are invalid', () => {
|
||||
const result = parseStatusLine({ rate_limits: { fiveHourPct: NaN, sevenDayPct: Infinity } })
|
||||
|
||||
// rate_limits IS a valid object, so rate is returned; but both sub-fields are invalid
|
||||
expect(result?.rate).toBeDefined()
|
||||
expect(result?.rate?.fiveHourPct).toBeUndefined()
|
||||
expect(result?.rate?.sevenDayPct).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits invalid sub-fields and keeps valid ones', () => {
|
||||
const result = parseStatusLine({
|
||||
rate_limits: { fiveHourPct: 'not-a-number', sevenDayPct: 80 },
|
||||
})
|
||||
|
||||
expect(result?.rate?.fiveHourPct).toBeUndefined()
|
||||
expect(result?.rate?.sevenDayPct).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStatusLine — never throw (SEC-M7)', () => {
|
||||
it('never throws for wildly malformed bodies', () => {
|
||||
const malformed: unknown[] = [
|
||||
null,
|
||||
undefined,
|
||||
42,
|
||||
'string',
|
||||
true,
|
||||
[],
|
||||
{ context_window: 'not-an-object' },
|
||||
{ context_window: { used_percentage: {} } },
|
||||
{ cost: null },
|
||||
{ model: 42 },
|
||||
{ effort: [] },
|
||||
{ pr: 'not-an-object' },
|
||||
{ pr: { number: 'bad', url: {} } },
|
||||
{ rate_limits: 'bad' },
|
||||
{ rate_limits: { fiveHourPct: Infinity, sevenDayPct: -Infinity } },
|
||||
{ pr: { number: NaN, url: '' } },
|
||||
{ context_window: { used_percentage: null } },
|
||||
{ cost: { total_cost_usd: {}, total_lines_added: [], total_lines_removed: undefined } },
|
||||
]
|
||||
|
||||
for (const body of malformed) {
|
||||
expect(() => parseStatusLine(body)).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
205
test/http/worktrees-create.test.ts
Normal file
205
test/http/worktrees-create.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* test/http/worktrees-create.test.ts — B3 git worktree creation (N-worktree).
|
||||
*
|
||||
* Covers the three new exports of src/http/worktrees.ts:
|
||||
* - validateBranchName (pure: git-ref subset rejection rules, SEC-H2)
|
||||
* - sanitizeBranchForDir (pure: branch → safe single dir segment)
|
||||
* - computeWorktreeDir (realpath-based containment, SEC-H3/M2)
|
||||
* - createWorktree (execFile, no shell, structured result, SEC-M10)
|
||||
*
|
||||
* Pure functions are unit-tested; createWorktree runs against real temporary
|
||||
* git repos (no network) — endpoint wiring is exercised separately in
|
||||
* T-server-wire's integration suite.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
validateBranchName,
|
||||
sanitizeBranchForDir,
|
||||
computeWorktreeDir,
|
||||
createWorktree,
|
||||
} from '../../src/http/worktrees.js'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
const TIMEOUT_MS = 10000
|
||||
|
||||
let gitAvailable = false
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await execFileP('git', ['--version'])
|
||||
gitAvailable = true
|
||||
} catch {
|
||||
gitAvailable = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Create a temp git repo with one commit so `git worktree add -b` can run. */
|
||||
async function makeRepo(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-repo-'))
|
||||
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir })
|
||||
await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir })
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── validateBranchName ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('validateBranchName', () => {
|
||||
it('accepts ordinary branch names', () => {
|
||||
for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) {
|
||||
expect(validateBranchName(ok)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty and over-long names', () => {
|
||||
expect(validateBranchName('')).toBe(false)
|
||||
expect(validateBranchName('a'.repeat(251))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a leading dash (would be parsed as a git flag, SEC-H2)', () => {
|
||||
expect(validateBranchName('-rf')).toBe(false)
|
||||
expect(validateBranchName('-b')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects path traversal and slash edge cases', () => {
|
||||
for (const bad of ['../x', 'foo..bar', '/leading', 'trailing/', 'double//slash']) {
|
||||
expect(validateBranchName(bad)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a trailing .lock', () => {
|
||||
expect(validateBranchName('foo.lock')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects whitespace and control characters', () => {
|
||||
expect(validateBranchName('has space')).toBe(false)
|
||||
expect(validateBranchName('tab\tx')).toBe(false)
|
||||
expect(validateBranchName('ctrl\x01x')).toBe(false)
|
||||
expect(validateBranchName('null\x00x')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects git-forbidden punctuation ~^:?*[\\ and @{', () => {
|
||||
for (const bad of ['a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'back\\slash', 'foo@{1}']) {
|
||||
expect(validateBranchName(bad)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── sanitizeBranchForDir ────────────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeBranchForDir', () => {
|
||||
it('replaces slashes with dashes', () => {
|
||||
expect(sanitizeBranchForDir('feature/login')).toBe('feature-login')
|
||||
expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c')
|
||||
})
|
||||
|
||||
it('trims leading/trailing dashes', () => {
|
||||
expect(sanitizeBranchForDir('/foo/')).toBe('foo')
|
||||
expect(sanitizeBranchForDir('-foo-')).toBe('foo')
|
||||
})
|
||||
|
||||
it('replaces filesystem-unsafe characters with a dash', () => {
|
||||
expect(sanitizeBranchForDir('a b')).toBe('a-b')
|
||||
expect(sanitizeBranchForDir('weird$name')).toBe('weird-name')
|
||||
})
|
||||
|
||||
it('preserves safe characters (alnum, dot, underscore, dash)', () => {
|
||||
expect(sanitizeBranchForDir('v1.2.3_rc')).toBe('v1.2.3_rc')
|
||||
})
|
||||
})
|
||||
|
||||
// ── computeWorktreeDir ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeWorktreeDir', () => {
|
||||
it('places the worktree under an explicit root', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-'))
|
||||
const dir = await computeWorktreeDir('/repo', 'feature', root)
|
||||
expect(dir).toBe(path.join(root, 'feature'))
|
||||
})
|
||||
|
||||
it('defaults the base to <repo>-worktrees alongside the repo', async () => {
|
||||
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-defbase-'))
|
||||
const dir = await computeWorktreeDir(repo, 'foo')
|
||||
expect(dir).toBe(path.join(path.dirname(repo), path.basename(repo) + '-worktrees', 'foo'))
|
||||
})
|
||||
|
||||
it('throws when the sanitized segment resolves outside the root (.., M2)', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-esc-'))
|
||||
await expect(computeWorktreeDir('/repo', '..', root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a symlinked candidate that escapes the root after realpath (AC-B3.4)', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-symroot-'))
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-outside-'))
|
||||
// Pre-plant a symlink at the predicted candidate path pointing outside root.
|
||||
await fs.symlink(outside, path.join(root, 'feature'))
|
||||
await expect(computeWorktreeDir('/repo', 'feature', root)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── createWorktree (real temp repos) ────────────────────────────────────────────
|
||||
|
||||
describe('createWorktree', () => {
|
||||
it('creates a new worktree + branch and returns its path', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await createWorktree(repo, 'feature/new', { timeoutMs: TIMEOUT_MS })
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.branch).toBe('feature/new')
|
||||
expect(res.path).toBeDefined()
|
||||
const stat = await fs.stat(res.path as string)
|
||||
expect(stat.isDirectory()).toBe(true)
|
||||
const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd: repo })
|
||||
expect(stdout).toContain(res.path as string)
|
||||
expect(stdout).toContain('branch refs/heads/feature/new')
|
||||
})
|
||||
|
||||
it('rejects an invalid branch name with 400 and never runs git', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const res = await createWorktree(repo, '../evil', { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
// No worktree was created (still just the main one).
|
||||
const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo })
|
||||
expect(stdout.trim().split('\n')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns 404 for a non-git directory', async () => {
|
||||
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-plain-'))
|
||||
const res = await createWorktree(plain, 'feature', { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 404 })
|
||||
})
|
||||
|
||||
it('returns 404 for a non-absolute repo path', async () => {
|
||||
const res = await createWorktree('relative/path', 'feature', { timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 404 })
|
||||
})
|
||||
|
||||
it('returns 409 when the branch already exists', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const first = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS })
|
||||
expect(first.ok).toBe(true)
|
||||
const second = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS })
|
||||
expect(second).toMatchObject({ ok: false, status: 409 })
|
||||
expect(second.error).toBeDefined()
|
||||
// Error message must not leak raw git stderr (SEC-M10).
|
||||
expect(second.error).not.toContain('fatal:')
|
||||
})
|
||||
|
||||
it('returns 400 when the computed dir escapes a symlinked root (M2)', async () => {
|
||||
if (!gitAvailable) return
|
||||
const repo = await makeRepo()
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-croot-'))
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-cout-'))
|
||||
await fs.symlink(outside, path.join(root, 'feature'))
|
||||
const res = await createWorktree(repo, 'feature', { worktreeRoot: root, timeoutMs: TIMEOUT_MS })
|
||||
expect(res).toMatchObject({ ok: false, status: 400 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user