The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
846 lines
31 KiB
TypeScript
846 lines
31 KiB
TypeScript
// @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,
|
|
isGitOpResult,
|
|
postStage,
|
|
postCommit,
|
|
postPush,
|
|
} = 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)
|
|
})
|
|
|
|
it('passes through an optional base revision (FR-B1.9)', () => {
|
|
const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 'main' })
|
|
expect(result).not.toBeNull()
|
|
expect(result?.base).toBe('main')
|
|
})
|
|
|
|
it('leaves base undefined when the payload omits it', () => {
|
|
const result = normalizeDiffResult({ files: [], staged: false, truncated: false })
|
|
expect(result).not.toBeNull()
|
|
expect(result?.base).toBeUndefined()
|
|
})
|
|
|
|
it('ignores a non-string base (coerces to undefined)', () => {
|
|
const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 123 })
|
|
expect(result).not.toBeNull()
|
|
expect(result?.base).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ── 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', { staged: 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', { staged: true })
|
|
const url = mockFetch.mock.calls[0]?.[0] as string
|
|
expect(url).toContain('staged=true')
|
|
})
|
|
|
|
it('defaults to staged=false when called with no options', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
await fetchDiff('/repo')
|
|
const url = mockFetch.mock.calls[0]?.[0] as string
|
|
expect(url).toContain('staged=false')
|
|
})
|
|
|
|
it('builds a &base=<rev> URL (omitting staged) when base is set (FR-B1.9)', async () => {
|
|
const mockFetch = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => makeResult({ base: 'main' }),
|
|
}))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const result = await fetchDiff('/repo', { base: 'main' })
|
|
const url = mockFetch.mock.calls[0]?.[0] as string
|
|
expect(url).toContain('base=main')
|
|
expect(url).not.toContain('staged=')
|
|
expect(result?.base).toBe('main')
|
|
})
|
|
|
|
it('URL-encodes a base containing slashes (feature/x)', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
await fetchDiff('/repo', { base: 'feature/x' })
|
|
const url = mockFetch.mock.calls[0]?.[0] as string
|
|
expect(url).toContain(`base=${encodeURIComponent('feature/x')}`)
|
|
})
|
|
|
|
it('returns null on non-ok response', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
|
|
const result = await fetchDiff('/repo', { staged: 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', { staged: 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', { staged: 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()
|
|
})
|
|
|
|
// ── FR-B1.9: base-branch picker ─────────────────────────────────────────────
|
|
|
|
it('renders NO base picker when bases is absent (backward-compatible)', 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))
|
|
expect(container.querySelector('.df-base-select')).toBeNull()
|
|
handle.destroy()
|
|
})
|
|
|
|
it('renders NO base picker when bases is empty', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', { bases: [] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
expect(container.querySelector('.df-base-select')).toBeNull()
|
|
handle.destroy()
|
|
})
|
|
|
|
it('renders a <select> with "Working tree" + one option per base', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', { bases: ['main', 'dev'] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const select = container.querySelector('.df-base-select') as HTMLSelectElement | null
|
|
expect(select).not.toBeNull()
|
|
const labels = Array.from(select!.options).map((o) => o.textContent)
|
|
expect(labels).toEqual(['Working tree', 'main', 'dev'])
|
|
handle.destroy()
|
|
})
|
|
|
|
it('selecting a base fetches with base=<rev> and disables the Working/Staged tabs', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ base: 'main' }) }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', { bases: ['main', 'dev'] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const select = container.querySelector('.df-base-select') as HTMLSelectElement
|
|
select.value = 'main'
|
|
select.dispatchEvent(new Event('change'))
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const lastUrl = mockFetch.mock.calls.at(-1)?.[0] as string
|
|
expect(lastUrl).toContain('base=main')
|
|
expect(lastUrl).not.toContain('staged=')
|
|
|
|
const workingBtn = container.querySelector('.df-tab') as HTMLButtonElement
|
|
const stagedBtn = container.querySelectorAll('.df-tab')[1] as HTMLButtonElement
|
|
expect(workingBtn.disabled).toBe(true)
|
|
expect(stagedBtn.disabled).toBe(true)
|
|
handle.destroy()
|
|
})
|
|
|
|
it('selecting "Working tree" restores a staged-mode fetch and re-enables tabs', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const select = container.querySelector('.df-base-select') as HTMLSelectElement
|
|
// Enter base mode …
|
|
select.value = 'main'
|
|
select.dispatchEvent(new Event('change'))
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
// … then back to Working tree.
|
|
select.value = ''
|
|
select.dispatchEvent(new Event('change'))
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const lastUrl = mockFetch.mock.calls.at(-1)?.[0] as string
|
|
expect(lastUrl).toContain('staged=false')
|
|
expect(lastUrl).not.toContain('base=')
|
|
|
|
const workingBtn = container.querySelector('.df-tab') as HTMLButtonElement
|
|
expect(workingBtn.disabled).toBe(false)
|
|
handle.destroy()
|
|
})
|
|
})
|
|
|
|
// ── W4 git-write POST helpers ─────────────────────────────────────────────────
|
|
|
|
describe('isGitOpResult', () => {
|
|
it('accepts an object with a boolean ok, rejects everything else', () => {
|
|
expect(isGitOpResult({ ok: true })).toBe(true)
|
|
expect(isGitOpResult({ ok: false, error: 'x' })).toBe(true)
|
|
expect(isGitOpResult(null)).toBe(false)
|
|
expect(isGitOpResult({ ok: 'yes' })).toBe(false)
|
|
expect(isGitOpResult('nope')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('postStage / postCommit / postPush', () => {
|
|
it('postStage POSTs the path/files/stage body to /projects/git/stage', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, staged: true, count: 1 }) }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const r = await postStage('/repo', ['a.ts', 'b.ts'], true)
|
|
expect(r).toMatchObject({ ok: true, staged: true, count: 1 })
|
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/projects/git/stage')
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', files: ['a.ts', 'b.ts'], stage: true })
|
|
})
|
|
|
|
it('postCommit POSTs the message to /projects/git/commit', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, commit: 'abc1234' }) }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const r = await postCommit('/repo', 'hello')
|
|
expect(r).toMatchObject({ ok: true, commit: 'abc1234' })
|
|
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('/projects/git/commit')
|
|
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', message: 'hello' })
|
|
})
|
|
|
|
it('postPush POSTs the path to /projects/git/push', async () => {
|
|
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }))
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const r = await postPush('/repo')
|
|
expect(r).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
|
|
expect((mockFetch.mock.calls[0] as [string])[0]).toBe('/projects/git/push')
|
|
})
|
|
|
|
it('synthesizes a failure {ok:false,status} when the body is not a GitOpResult', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 429, json: async () => ({}) })))
|
|
const r = await postStage('/repo', ['a'], true)
|
|
expect(r).toMatchObject({ ok: false, status: 429 })
|
|
})
|
|
|
|
it('returns null on a network error', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
|
|
expect(await postPush('/repo')).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ── W4 mountDiffViewer: stage toggles + commit/push bar ───────────────────────
|
|
|
|
describe('mountDiffViewer — W4 stage / commit / push', () => {
|
|
function makeContainer(): HTMLDivElement {
|
|
return document.createElement('div')
|
|
}
|
|
|
|
/** A fetch mock that routes GET /projects/diff → a diff, and each POST git route
|
|
* → a caller-supplied response (defaults to ok:true). */
|
|
function routingFetch(gitResponses: Record<string, unknown> = {}): ReturnType<typeof vi.fn> {
|
|
return vi.fn(async (url: string, init?: RequestInit) => {
|
|
if (init?.method === 'POST') {
|
|
const body = gitResponses[url] ?? { ok: true }
|
|
return { ok: true, status: 200, json: async () => body }
|
|
}
|
|
return { ok: true, status: 200, json: async () => makeResult({ staged: false }) }
|
|
})
|
|
}
|
|
|
|
it('renders a "Stage" button on each file row in the working view', async () => {
|
|
vi.stubGlobal('fetch', routingFetch())
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', {})
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const btn = container.querySelector('.df-file-stage')
|
|
expect(btn).not.toBeNull()
|
|
expect(btn?.textContent).toBe('Stage')
|
|
handle.destroy()
|
|
})
|
|
|
|
it('labels the toggle "Unstage" in the staged view', async () => {
|
|
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
|
|
if (init?.method === 'POST') return { ok: true, status: 200, json: async () => ({ ok: true }) }
|
|
return { ok: true, status: 200, json: async () => makeResult({ staged: true }) }
|
|
})
|
|
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))
|
|
|
|
expect(container.querySelector('.df-file-stage')?.textContent).toBe('Unstage')
|
|
handle.destroy()
|
|
})
|
|
|
|
it('clicking Stage POSTs {path,files,stage:true} then re-fetches the diff', async () => {
|
|
const mockFetch = routingFetch()
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', {})
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
const initialCalls = mockFetch.mock.calls.length
|
|
|
|
;(container.querySelector('.df-file-stage') as HTMLButtonElement).click()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const post = mockFetch.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST')
|
|
expect(post?.[0]).toBe('/projects/git/stage')
|
|
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toMatchObject({
|
|
path: '/repo',
|
|
files: ['src/foo.ts'],
|
|
stage: true,
|
|
})
|
|
// Re-fetched the diff after the successful stage.
|
|
expect(mockFetch.mock.calls.length).toBeGreaterThan(initialCalls + 1)
|
|
handle.destroy()
|
|
})
|
|
|
|
it('disables Commit until the message is non-empty, then POSTs the commit', async () => {
|
|
const mockFetch = routingFetch({ '/projects/git/commit': { ok: true, commit: 'deadbee' } })
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', {})
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const commitBtn = container.querySelector('.df-commit-btn') as HTMLButtonElement
|
|
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
|
|
expect(commitBtn.disabled).toBe(true)
|
|
|
|
msg.value = 'phone commit'
|
|
msg.dispatchEvent(new Event('input'))
|
|
expect(commitBtn.disabled).toBe(false)
|
|
|
|
commitBtn.click()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const post = mockFetch.mock.calls.find((c) => c[0] === '/projects/git/commit')
|
|
expect(post).toBeDefined()
|
|
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toEqual({ path: '/repo', message: 'phone commit' })
|
|
// Message cleared on success.
|
|
expect(msg.value).toBe('')
|
|
handle.destroy()
|
|
})
|
|
|
|
it('shows a commit failure via textContent with zero innerHTML injection (SEC-H4)', async () => {
|
|
const evil = '<script>alert(1)</script>'
|
|
const mockFetch = routingFetch({ '/projects/git/commit': { ok: false, status: 409, error: evil } })
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', {})
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
|
|
msg.value = 'x'
|
|
msg.dispatchEvent(new Event('input'))
|
|
;(container.querySelector('.df-commit-btn') as HTMLButtonElement).click()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const status = container.querySelector('.df-op-status') as HTMLElement
|
|
expect(status.textContent).toContain(evil)
|
|
expect(container.querySelectorAll('script').length).toBe(0)
|
|
handle.destroy()
|
|
})
|
|
|
|
it('Push POSTs /projects/git/push and reflects busy/disabled while in flight', async () => {
|
|
let resolvePush: (v: unknown) => void = () => undefined
|
|
const pending = new Promise((r) => {
|
|
resolvePush = r
|
|
})
|
|
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
|
|
if (init?.method === 'POST' && url === '/projects/git/push') {
|
|
await pending
|
|
return { ok: true, status: 200, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }
|
|
}
|
|
return { ok: true, status: 200, json: async () => makeResult() }
|
|
})
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', {})
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const pushBtn = container.querySelector('.df-push-btn') as HTMLButtonElement
|
|
pushBtn.click()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
// In flight → disabled.
|
|
expect(pushBtn.disabled).toBe(true)
|
|
|
|
resolvePush(undefined)
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
expect(pushBtn.disabled).toBe(false)
|
|
expect((container.querySelector('.df-op-status') as HTMLElement).textContent).toContain('main')
|
|
handle.destroy()
|
|
})
|
|
|
|
it('hides the commit bar in base-compare mode', async () => {
|
|
vi.stubGlobal('fetch', routingFetch())
|
|
const container = makeContainer()
|
|
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const bar = container.querySelector('.df-commitbar') as HTMLElement
|
|
expect(bar.style.display).toBe('') // visible in working mode
|
|
|
|
const select = container.querySelector('.df-base-select') as HTMLSelectElement
|
|
select.value = 'main'
|
|
select.dispatchEvent(new Event('change'))
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
expect(bar.style.display).toBe('none') // hidden in base mode
|
|
// No stage buttons in base mode either.
|
|
expect(container.querySelector('.df-file-stage')).toBeNull()
|
|
handle.destroy()
|
|
})
|
|
})
|