// @vitest-environment jsdom /** * test/worktree-form.test.ts — T-projects-ui (v0.7 Walk-away Workbench) * * Tests for the new features wired into public/projects.ts: * B1: "View Diff" toggle button → mountDiffViewer inline panel * B3: renderNewWorktreeForm — client-side branch validation + POST /projects/worktree * A4: "Activity" section — mountTimeline per running session, dispose on onBack * * Security: SEC-H4 (diff textContent), SEC-L3/H6 (error/label textContent) */ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { ProjectDetail } from '../src/types.js' // ── Stub @xterm/xterm (transitively via preview-grid.ts) ────────────────────── class FakeTerminal { open = vi.fn() dispose = vi.fn() } vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) // ── Stub diff viewer ─────────────────────────────────────────────────────────── const mockDiffHandle = { showWorking: vi.fn(), showStaged: vi.fn(), close: vi.fn(), destroy: vi.fn(), } const mockMountDiffViewer = vi.fn(() => mockDiffHandle) vi.mock('../public/diff.js', () => ({ mountDiffViewer: mockMountDiffViewer })) // ── Stub timeline ────────────────────────────────────────────────────────────── const mockTimelineHandle = { dispose: vi.fn() } const mockMountTimeline = vi.fn(() => mockTimelineHandle) vi.mock('../public/timeline.js', () => ({ mountTimeline: mockMountTimeline })) // ── Import AFTER mocks ───────────────────────────────────────────────────────── const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } = await import('../public/projects.js') /* ── Helpers ───────────────────────────────────────────────────────────────── */ function makeHooks() { return { onOpenProject: vi.fn(), onEnterSession: vi.fn() } } function makeCbs() { return { onBack: vi.fn(), onKill: vi.fn() } } function makeDetail(overrides: Partial = {}): ProjectDetail { return { name: 'my-repo', path: '/home/user/my-repo', isGit: true, branch: 'main', worktrees: [], sessions: [], hasClaudeMd: false, ...overrides, } } beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', vi.fn()) }) /* ── validateBranchNameClient ──────────────────────────────────────────────── */ describe('validateBranchNameClient', () => { it('returns null for a valid simple branch name', () => { expect(validateBranchNameClient('my-branch')).toBeNull() expect(validateBranchNameClient('feat-123')).toBeNull() expect(validateBranchNameClient('v0.7')).toBeNull() }) it('returns null for namespaced branches (feature/foo)', () => { expect(validateBranchNameClient('feature/my-feature')).toBeNull() expect(validateBranchNameClient('v0.7/walk-away')).toBeNull() expect(validateBranchNameClient('user/fix/bug-1')).toBeNull() }) it('returns an error string for an empty string', () => { const result = validateBranchNameClient('') expect(typeof result).toBe('string') expect(result).not.toBeNull() }) it('returns an error for a name longer than 250 characters', () => { expect(validateBranchNameClient('a'.repeat(251))).not.toBeNull() }) it('returns null for a name that is exactly 250 characters', () => { expect(validateBranchNameClient('a'.repeat(250))).toBeNull() }) it('returns an error for a name starting with "-"', () => { expect(validateBranchNameClient('-bad')).not.toBeNull() }) it('returns an error for a name containing ".."', () => { expect(validateBranchNameClient('feat..bar')).not.toBeNull() expect(validateBranchNameClient('..feat')).not.toBeNull() }) it('returns an error for a name ending with ".lock"', () => { expect(validateBranchNameClient('main.lock')).not.toBeNull() }) it('returns an error for a name with control characters', () => { expect(validateBranchNameClient('feat\x01bar')).not.toBeNull() expect(validateBranchNameClient('feat\x00bar')).not.toBeNull() }) it('returns an error for a name with tab', () => { expect(validateBranchNameClient('feat\tbar')).not.toBeNull() }) it('returns an error for a name with spaces', () => { expect(validateBranchNameClient('my branch')).not.toBeNull() }) it('returns an error for a name with "~"', () => { expect(validateBranchNameClient('feat~1')).not.toBeNull() }) it('returns an error for a name with "^"', () => { expect(validateBranchNameClient('feat^bar')).not.toBeNull() }) it('returns an error for a name with ":"', () => { expect(validateBranchNameClient('feat:bar')).not.toBeNull() }) it('returns an error for a name with "?"', () => { expect(validateBranchNameClient('feat?bar')).not.toBeNull() }) it('returns an error for a name with "*"', () => { expect(validateBranchNameClient('feat*bar')).not.toBeNull() }) it('returns an error for a name with "["', () => { expect(validateBranchNameClient('feat[bar')).not.toBeNull() }) it('returns an error for a name with backslash', () => { expect(validateBranchNameClient('feat\\bar')).not.toBeNull() }) it('returns an error for a name with "@{"', () => { expect(validateBranchNameClient('feat@{bar}')).not.toBeNull() expect(validateBranchNameClient('@{bar}')).not.toBeNull() }) it('returns an error for a name starting with "/"', () => { expect(validateBranchNameClient('/feat')).not.toBeNull() }) it('returns an error for a name ending with "/"', () => { expect(validateBranchNameClient('feat/')).not.toBeNull() }) it('returns an error for a name with "//"', () => { expect(validateBranchNameClient('feat//bar')).not.toBeNull() }) it('all invalid cases return a non-null string', () => { const invalids = [ '', '-bad', 'feat..bar', 'main.lock', 'feat\x01', 'feat\tbar', 'feat bar', 'feat~1', 'feat^x', 'feat:x', 'feat?x', 'feat*x', 'feat[x', 'feat\\x', 'feat@{x}', '/bad', 'bad/', 'feat//bar', ] for (const b of invalids) { const result = validateBranchNameClient(b) expect(typeof result).toBe('string') } }) }) /* ── renderNewWorktreeForm ─────────────────────────────────────────────────── */ describe('renderNewWorktreeForm', () => { it('renders a form container with a branch input and submit button', () => { const form = renderNewWorktreeForm(makeDetail(), makeHooks()) expect(form.querySelector('input')).not.toBeNull() expect(form.querySelector('button')).not.toBeNull() // Error element exists but is hidden initially const errorEl = form.querySelector('.proj-wt-error') as HTMLElement | null expect(errorEl).not.toBeNull() expect(errorEl!.style.display).toBe('none') }) it('shows a validation error when submitted with an empty branch name', () => { const hooks = makeHooks() const form = renderNewWorktreeForm(makeDetail(), hooks) ;(form.querySelector('button') as HTMLButtonElement).click() expect(fetch).not.toHaveBeenCalled() const errorEl = form.querySelector('.proj-wt-error') as HTMLElement expect(errorEl.style.display).not.toBe('none') expect(errorEl.textContent).toBeTruthy() }) it('shows a validation error for an invalid branch name (leading hyphen)', () => { const hooks = makeHooks() const form = renderNewWorktreeForm(makeDetail(), hooks) ;(form.querySelector('input') as HTMLInputElement).value = '-bad' ;(form.querySelector('button') as HTMLButtonElement).click() expect(fetch).not.toHaveBeenCalled() const errorEl = form.querySelector('.proj-wt-error') as HTMLElement expect(errorEl.style.display).not.toBe('none') }) it('POSTs to /projects/worktree with path and branch on valid input', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, path: '/home/user/my-repo-worktrees/feat', branch: 'feat' }), }) vi.stubGlobal('fetch', mockFetch) const detail = makeDetail({ path: '/home/user/my-repo' }) const form = renderNewWorktreeForm(detail, makeHooks()) ;(form.querySelector('input') as HTMLInputElement).value = 'feat' ;(form.querySelector('button') as HTMLButtonElement).click() await Promise.resolve() await Promise.resolve() expect(mockFetch).toHaveBeenCalledWith( '/projects/worktree', expect.objectContaining({ method: 'POST', // regression: the server (server.ts) + the docs contract read body.path, // NOT body.repoPath — sending repoPath 400'd every browser worktree-create. body: JSON.stringify({ path: '/home/user/my-repo', branch: 'feat' }), }), ) }) it('calls hooks.onOpenProject with the worktree path and branch on success', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, path: '/home/user/my-repo-worktrees/feat', branch: 'feat', }), }) vi.stubGlobal('fetch', mockFetch) const hooks = makeHooks() const form = renderNewWorktreeForm(makeDetail(), hooks) ;(form.querySelector('input') as HTMLInputElement).value = 'feat' ;(form.querySelector('button') as HTMLButtonElement).click() // Flush async microtasks await Promise.resolve() await Promise.resolve() expect(hooks.onOpenProject).toHaveBeenCalledWith( '/home/user/my-repo-worktrees/feat', 'feat', 'claude\r', ) }) it('falls back to repoPath/branch when response omits path/branch', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }), }) vi.stubGlobal('fetch', mockFetch) const hooks = makeHooks() const detail = makeDetail({ path: '/repo' }) const form = renderNewWorktreeForm(detail, hooks) ;(form.querySelector('input') as HTMLInputElement).value = 'feat' ;(form.querySelector('button') as HTMLButtonElement).click() await Promise.resolve() await Promise.resolve() expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r') }) it('shows server error message via textContent on HTTP failure', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({ ok: false, error: 'Branch already exists' }), }) vi.stubGlobal('fetch', mockFetch) const hooks = makeHooks() const form = renderNewWorktreeForm(makeDetail(), hooks) ;(form.querySelector('input') as HTMLInputElement).value = 'feat' ;(form.querySelector('button') as HTMLButtonElement).click() await Promise.resolve() await Promise.resolve() const errorEl = form.querySelector('.proj-wt-error') as HTMLElement expect(errorEl.style.display).not.toBe('none') expect(errorEl.textContent).toBe('Branch already exists') expect(hooks.onOpenProject).not.toHaveBeenCalled() }) it('shows a generic error on network failure (never throws)', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) vi.stubGlobal('fetch', mockFetch) const hooks = makeHooks() const form = renderNewWorktreeForm(makeDetail(), hooks) ;(form.querySelector('input') as HTMLInputElement).value = 'feat' ;(form.querySelector('button') as HTMLButtonElement).click() await Promise.resolve() await Promise.resolve() const errorEl = form.querySelector('.proj-wt-error') as HTMLElement expect(errorEl.textContent).toBeTruthy() expect(hooks.onOpenProject).not.toHaveBeenCalled() }) it('SEC-H6: error message set via textContent — ' const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({ ok: false, error: xss }), }) vi.stubGlobal('fetch', mockFetch) const form = renderNewWorktreeForm(makeDetail(), makeHooks()) ;(form.querySelector('input') as HTMLInputElement).value = 'feat' ;(form.querySelector('button') as HTMLButtonElement).click() await Promise.resolve() await Promise.resolve() const errorEl = form.querySelector('.proj-wt-error') as HTMLElement // No