Files
web-terminal/test/http/diff.test.ts
Yaojia Wang d6809c65c4 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.
2026-06-30 17:42:18 +02:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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` red'
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)
})
})