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:
483
test/diff.test.ts
Normal file
483
test/diff.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/diff.test.ts — N-diff-ui: diff viewer render-only frontend module
|
||||
*
|
||||
* Covers: normalizeDiffResult, fetchDiff, renderDiffFile, renderDiff,
|
||||
* mountDiffViewer (working/staged toggle, truncated warning, empty state).
|
||||
*
|
||||
* Security: SEC-H4 — all diff content via textContent, zero innerHTML.
|
||||
* Accept: AC-B1.3, AC-B1.4
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { DiffResult, DiffFile, DiffLine, DiffHunk } from '../src/types.js'
|
||||
|
||||
// ── module import ─────────────────────────────────────────────────────────────
|
||||
const {
|
||||
normalizeDiffResult,
|
||||
fetchDiff,
|
||||
renderDiffFile,
|
||||
renderDiff,
|
||||
mountDiffViewer,
|
||||
} = await import('../public/diff.js')
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeLine(kind: DiffLine['kind'], text: string): DiffLine {
|
||||
return { kind, text }
|
||||
}
|
||||
|
||||
function makeHunk(header: string, lines: DiffLine[] = []): DiffHunk {
|
||||
return { header, lines }
|
||||
}
|
||||
|
||||
function makeFile(over: Partial<DiffFile> = {}): DiffFile {
|
||||
return {
|
||||
oldPath: 'src/foo.ts',
|
||||
newPath: 'src/foo.ts',
|
||||
status: 'modified',
|
||||
added: 2,
|
||||
removed: 1,
|
||||
binary: false,
|
||||
hunks: [makeHunk('@@ -1,3 +1,4 @@', [
|
||||
makeLine('context', ' line1'),
|
||||
makeLine('removed', '-old'),
|
||||
makeLine('added', '+new'),
|
||||
makeLine('added', '+extra'),
|
||||
])],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function makeResult(over: Partial<DiffResult> = {}): DiffResult {
|
||||
return {
|
||||
files: [makeFile()],
|
||||
staged: false,
|
||||
truncated: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ── normalizeDiffResult ───────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeDiffResult', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(normalizeDiffResult(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-object input', () => {
|
||||
expect(normalizeDiffResult('string')).toBeNull()
|
||||
expect(normalizeDiffResult(42)).toBeNull()
|
||||
expect(normalizeDiffResult(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when files is not an array', () => {
|
||||
expect(normalizeDiffResult({ files: 'bad', staged: false, truncated: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when staged is not a boolean', () => {
|
||||
expect(normalizeDiffResult({ files: [], staged: 'yes', truncated: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when truncated is not a boolean', () => {
|
||||
expect(normalizeDiffResult({ files: [], staged: false, truncated: 'yes' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a valid DiffResult for a well-formed object', () => {
|
||||
const raw = makeResult()
|
||||
const result = normalizeDiffResult(raw)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.staged).toBe(false)
|
||||
expect(result?.truncated).toBe(false)
|
||||
expect(result?.files).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters out invalid file entries (keeps only valid ones)', () => {
|
||||
const raw = {
|
||||
files: [
|
||||
makeFile(),
|
||||
{ not: 'a file' },
|
||||
null,
|
||||
],
|
||||
staged: false,
|
||||
truncated: false,
|
||||
}
|
||||
const result = normalizeDiffResult(raw)
|
||||
expect(result?.files).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts an empty files array (empty diff)', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: false, truncated: false })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.files).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns DiffResult with staged=true when input has staged=true', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: true, truncated: false })
|
||||
expect(result?.staged).toBe(true)
|
||||
})
|
||||
|
||||
it('returns DiffResult with truncated=true when input has truncated=true', () => {
|
||||
const result = normalizeDiffResult({ files: [], staged: false, truncated: true })
|
||||
expect(result?.truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── fetchDiff ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fetchDiff', () => {
|
||||
it('fetches from /projects/diff with correct query params (working tree)', async () => {
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult({ staged: false }),
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const result = await fetchDiff('/some/repo', false)
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('/projects/diff')
|
||||
expect(url).toContain(encodeURIComponent('/some/repo'))
|
||||
expect(url).toContain('staged=false')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('fetches with staged=true when staged is true', async () => {
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult({ staged: true }),
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
await fetchDiff('/repo', true)
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('staged=true')
|
||||
})
|
||||
|
||||
it('returns null on non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
|
||||
const result = await fetchDiff('/repo', false)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on fetch error (network failure)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
|
||||
const result = await fetchDiff('/repo', false)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when server returns invalid structure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ not: 'a diff result' }),
|
||||
})))
|
||||
const result = await fetchDiff('/repo', false)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── renderDiffFile ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderDiffFile', () => {
|
||||
it('renders the file path as plain text (newPath)', () => {
|
||||
const file = makeFile({ newPath: 'src/component.ts' })
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent).toContain('src/component.ts')
|
||||
})
|
||||
|
||||
it('renders +/- stats badge with correct counts', () => {
|
||||
const file = makeFile({ added: 5, removed: 3 })
|
||||
const el = renderDiffFile(file)
|
||||
const text = el.textContent ?? ''
|
||||
expect(text).toContain('+5')
|
||||
expect(text).toContain('-3')
|
||||
})
|
||||
|
||||
it('renders hunk header as text', () => {
|
||||
const file = makeFile({
|
||||
hunks: [makeHunk('@@ -1,3 +1,4 @@', [])],
|
||||
})
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent).toContain('@@ -1,3 +1,4 @@')
|
||||
})
|
||||
|
||||
it('renders added/removed/context lines as plain text (SEC-H4)', () => {
|
||||
const file = makeFile({
|
||||
hunks: [makeHunk('@@ -1 +1 @@', [
|
||||
makeLine('added', '+new line'),
|
||||
makeLine('removed', '-old line'),
|
||||
makeLine('context', ' ctx line'),
|
||||
])],
|
||||
})
|
||||
const el = renderDiffFile(file)
|
||||
const text = el.textContent ?? ''
|
||||
expect(text).toContain('+new line')
|
||||
expect(text).toContain('-old line')
|
||||
expect(text).toContain(' ctx line')
|
||||
})
|
||||
|
||||
it('renders <script> content as plain text — not injected as HTML (SEC-H4)', () => {
|
||||
const xssLine = '<script>alert(1)</script>'
|
||||
const file = makeFile({
|
||||
hunks: [makeHunk('@@ -1 +1 @@', [
|
||||
makeLine('added', xssLine),
|
||||
])],
|
||||
})
|
||||
const el = renderDiffFile(file)
|
||||
// The text should be present
|
||||
expect(el.textContent).toContain(xssLine)
|
||||
// But no actual <script> element should be injected
|
||||
expect(el.querySelectorAll('script').length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders & as literal text (not HTML entity expanded) (SEC-H4)', () => {
|
||||
const file = makeFile({
|
||||
hunks: [makeHunk('@@ -1 +1 @@', [
|
||||
makeLine('context', ' a & b && c'),
|
||||
])],
|
||||
})
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent).toContain('a & b && c')
|
||||
// innerHTML should NOT contain unescaped &
|
||||
expect(el.innerHTML).not.toContain(' a & b ')
|
||||
})
|
||||
|
||||
it('renders ANSI escape sequences as plain text (SEC-H4)', () => {
|
||||
const ansi = '\x1b[32m+green\x1b[0m'
|
||||
const file = makeFile({
|
||||
hunks: [makeHunk('@@ -1 +1 @@', [makeLine('added', ansi)])],
|
||||
})
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent).toContain(ansi)
|
||||
})
|
||||
|
||||
it('renders a binary file indicator (no hunks)', () => {
|
||||
const file = makeFile({ binary: true, hunks: [] })
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent?.toLowerCase()).toContain('binary')
|
||||
})
|
||||
|
||||
it('renders status for renamed file (shows old + new path)', () => {
|
||||
const file = makeFile({
|
||||
oldPath: 'src/old.ts',
|
||||
newPath: 'src/new.ts',
|
||||
status: 'renamed',
|
||||
hunks: [],
|
||||
})
|
||||
const el = renderDiffFile(file)
|
||||
const text = el.textContent ?? ''
|
||||
expect(text).toContain('src/old.ts')
|
||||
expect(text).toContain('src/new.ts')
|
||||
})
|
||||
|
||||
it('renders deleted file with status indicator', () => {
|
||||
const file = makeFile({ status: 'deleted', hunks: [] })
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent?.toLowerCase()).toContain('deleted')
|
||||
})
|
||||
|
||||
it('renders added (new) file with status indicator', () => {
|
||||
const file = makeFile({ status: 'added', hunks: [] })
|
||||
const el = renderDiffFile(file)
|
||||
expect(el.textContent?.toLowerCase()).toContain('added')
|
||||
})
|
||||
|
||||
it('returns an HTMLElement (not null)', () => {
|
||||
expect(renderDiffFile(makeFile())).toBeInstanceOf(HTMLElement)
|
||||
})
|
||||
})
|
||||
|
||||
// ── renderDiff ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderDiff', () => {
|
||||
it('renders each file from the result', () => {
|
||||
const result = makeResult({
|
||||
files: [
|
||||
makeFile({ newPath: 'a.ts' }),
|
||||
makeFile({ newPath: 'b.ts' }),
|
||||
],
|
||||
})
|
||||
const el = renderDiff(result)
|
||||
expect(el.textContent).toContain('a.ts')
|
||||
expect(el.textContent).toContain('b.ts')
|
||||
})
|
||||
|
||||
it('shows empty state message when no files changed', () => {
|
||||
const result = makeResult({ files: [] })
|
||||
const el = renderDiff(result)
|
||||
// Should show some empty-state text
|
||||
const text = el.textContent?.toLowerCase() ?? ''
|
||||
expect(text).toMatch(/no (changes|diff|files)/i)
|
||||
})
|
||||
|
||||
it('shows truncated warning when result.truncated is true', () => {
|
||||
const result = makeResult({ truncated: true })
|
||||
const el = renderDiff(result)
|
||||
const text = el.textContent?.toLowerCase() ?? ''
|
||||
expect(text).toMatch(/truncated|too large|large/i)
|
||||
})
|
||||
|
||||
it('does NOT show truncated warning when result.truncated is false', () => {
|
||||
const result = makeResult({ truncated: false })
|
||||
const el = renderDiff(result)
|
||||
const text = el.textContent?.toLowerCase() ?? ''
|
||||
expect(text).not.toMatch(/truncated/i)
|
||||
})
|
||||
|
||||
it('renders <script> in diff content as text not HTML (SEC-H4)', () => {
|
||||
const result = makeResult({
|
||||
files: [makeFile({
|
||||
hunks: [makeHunk('@@ @@', [makeLine('added', '<script>evil()</script>')])],
|
||||
})],
|
||||
})
|
||||
const el = renderDiff(result)
|
||||
expect(el.textContent).toContain('<script>evil()</script>')
|
||||
expect(el.querySelectorAll('script').length).toBe(0)
|
||||
})
|
||||
|
||||
it('returns an HTMLElement', () => {
|
||||
expect(renderDiff(makeResult())).toBeInstanceOf(HTMLElement)
|
||||
})
|
||||
})
|
||||
|
||||
// ── mountDiffViewer ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('mountDiffViewer', () => {
|
||||
function makeContainer(): HTMLDivElement {
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
it('renders into the container on mount', async () => {
|
||||
const result = makeResult()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => result,
|
||||
})))
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0)) // let async fetch settle
|
||||
expect(container.children.length).toBeGreaterThan(0)
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('shows working tree by default (staged=false)', async () => {
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult({ staged: false }),
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain('staged=false')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('can switch to staged view', async () => {
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult({ staged: true }),
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// Switch to staged
|
||||
handle.showStaged()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// Should have fetched twice: once working, once staged
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
const secondUrl = mockFetch.mock.calls[1]?.[0] as string
|
||||
expect(secondUrl).toContain('staged=true')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('can switch back to working view', async () => {
|
||||
const mockFetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult(),
|
||||
}))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
handle.showStaged()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
handle.showWorking()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
const thirdUrl = mockFetch.mock.calls[2]?.[0] as string
|
||||
expect(thirdUrl).toContain('staged=false')
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('calls onClose when the close action fires', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult(),
|
||||
})))
|
||||
|
||||
const onClose = vi.fn()
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', { onClose })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
handle.close()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('shows error/empty state when fetch fails (does not throw)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// Should not throw and should render something
|
||||
expect(container.children.length).toBeGreaterThan(0)
|
||||
handle.destroy()
|
||||
})
|
||||
|
||||
it('destroy removes content from container', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult(),
|
||||
})))
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/repo', {})
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
handle.destroy()
|
||||
expect(container.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('handles empty diff gracefully (no throw)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => makeResult({ files: [] }),
|
||||
})))
|
||||
|
||||
const container = makeContainer()
|
||||
const handle = mountDiffViewer(container, '/some/repo', {})
|
||||
await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined()
|
||||
handle.destroy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user