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:
551
test/worktree-form.test.ts
Normal file
551
test/worktree-form.test.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
// @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> = {}): 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 repoPath 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',
|
||||
body: JSON.stringify({ repoPath: '/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 — <script> appears as literal text', async () => {
|
||||
const xss = '<script>alert(1)</script>'
|
||||
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 <script> element in DOM — textContent was used
|
||||
expect(errorEl.querySelector('script')).toBeNull()
|
||||
expect(errorEl.textContent).toBe(xss)
|
||||
})
|
||||
|
||||
it('hides the error element when the user edits the input field', () => {
|
||||
const form = renderNewWorktreeForm(makeDetail(), makeHooks())
|
||||
// Trigger a validation error first
|
||||
;(form.querySelector('button') as HTMLButtonElement).click()
|
||||
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
|
||||
expect(errorEl.style.display).not.toBe('none')
|
||||
|
||||
// Simulate typing in the input
|
||||
const input = form.querySelector('input') as HTMLInputElement
|
||||
input.value = 'f'
|
||||
input.dispatchEvent(new Event('input'))
|
||||
expect(errorEl.style.display).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
/* ── renderProjectDetail — B1: View Diff ──────────────────────────────────── */
|
||||
|
||||
describe('renderProjectDetail — B1 View Diff', () => {
|
||||
it('shows a "View Diff" toggle button for git repos', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
expect(root.querySelector('.proj-diff-toggle')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does NOT show "View Diff" button for non-git directories', () => {
|
||||
const root = renderProjectDetail(
|
||||
makeDetail({ isGit: false, branch: undefined }),
|
||||
makeHooks(),
|
||||
makeCbs(),
|
||||
)
|
||||
expect(root.querySelector('.proj-diff-toggle')).toBeNull()
|
||||
})
|
||||
|
||||
it('diff panel is hidden initially', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
|
||||
expect(panel.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('clicking "View Diff" calls mountDiffViewer with the project path', () => {
|
||||
const detail = makeDetail({ path: '/home/user/proj', isGit: true })
|
||||
const root = renderProjectDetail(detail, makeHooks(), makeCbs())
|
||||
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
|
||||
|
||||
expect(mockMountDiffViewer).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
'/home/user/proj',
|
||||
expect.objectContaining({ onClose: expect.any(Function) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('clicking "View Diff" shows the diff panel', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
|
||||
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
|
||||
expect(panel.style.display).not.toBe('none')
|
||||
})
|
||||
|
||||
it('the onClose callback passed to mountDiffViewer hides the panel', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
|
||||
|
||||
const opts = mockMountDiffViewer.mock.calls[0]?.[2] as { onClose?: () => void }
|
||||
opts?.onClose?.()
|
||||
|
||||
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
|
||||
expect(panel.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('clicking the toggle again closes the diff panel and calls destroy()', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
const toggle = root.querySelector('.proj-diff-toggle') as HTMLButtonElement
|
||||
toggle.click() // open
|
||||
toggle.click() // close
|
||||
expect(mockDiffHandle.destroy).toHaveBeenCalled()
|
||||
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
|
||||
expect(panel.style.display).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
/* ── renderProjectDetail — B3: New Worktree Form ──────────────────────────── */
|
||||
|
||||
describe('renderProjectDetail — B3 New Worktree Form', () => {
|
||||
it('includes a worktree form for git repos', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
expect(root.querySelector('.proj-wt-form')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does NOT include a worktree form for non-git directories', () => {
|
||||
const root = renderProjectDetail(
|
||||
makeDetail({ isGit: false, branch: undefined }),
|
||||
makeHooks(),
|
||||
makeCbs(),
|
||||
)
|
||||
expect(root.querySelector('.proj-wt-form')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */
|
||||
|
||||
describe('renderProjectDetail — A4 Activity section', () => {
|
||||
const runningSess = {
|
||||
id: 'sess-1',
|
||||
title: 'claude',
|
||||
status: 'working' as const,
|
||||
clientCount: 1,
|
||||
createdAt: 1,
|
||||
exited: false,
|
||||
}
|
||||
const exitedSess = {
|
||||
id: 'sess-2',
|
||||
title: 'shell',
|
||||
status: 'idle' as const,
|
||||
clientCount: 0,
|
||||
createdAt: 2,
|
||||
exited: true,
|
||||
}
|
||||
|
||||
it('shows the activity section when at least one session is running', () => {
|
||||
const root = renderProjectDetail(
|
||||
makeDetail({ sessions: [runningSess] }),
|
||||
makeHooks(),
|
||||
makeCbs(),
|
||||
)
|
||||
expect(root.querySelector('.proj-activity-section')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('mounts a timeline for each running session', () => {
|
||||
renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), makeCbs())
|
||||
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
|
||||
})
|
||||
|
||||
it('does NOT mount timelines for exited sessions', () => {
|
||||
renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
|
||||
expect(mockMountTimeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mounts one timeline per running session', () => {
|
||||
const sess2 = { id: 'sess-3', title: 'codex', status: 'working' as const, clientCount: 1, createdAt: 3, exited: false }
|
||||
renderProjectDetail(
|
||||
makeDetail({ sessions: [runningSess, sess2] }),
|
||||
makeHooks(),
|
||||
makeCbs(),
|
||||
)
|
||||
expect(mockMountTimeline).toHaveBeenCalledTimes(2)
|
||||
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
|
||||
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-3')
|
||||
})
|
||||
|
||||
it('disposes timeline handles when onBack is clicked', () => {
|
||||
const cb = makeCbs()
|
||||
const root = renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), cb)
|
||||
|
||||
expect(mockTimelineHandle.dispose).not.toHaveBeenCalled()
|
||||
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
|
||||
expect(mockTimelineHandle.dispose).toHaveBeenCalled()
|
||||
expect(cb.onBack).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the activity section when no sessions are running', () => {
|
||||
const root = renderProjectDetail(makeDetail({ sessions: [] }), makeHooks(), makeCbs())
|
||||
expect(root.querySelector('.proj-activity-section')).toBeNull()
|
||||
expect(mockMountTimeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the activity section when all sessions are exited', () => {
|
||||
const root = renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
|
||||
expect(root.querySelector('.proj-activity-section')).toBeNull()
|
||||
})
|
||||
|
||||
it('timeline collector receives handles when provided (re-render disposal)', () => {
|
||||
const collector: Array<{ dispose(): void }> = []
|
||||
renderProjectDetail(
|
||||
makeDetail({ sessions: [runningSess] }),
|
||||
makeHooks(),
|
||||
makeCbs(),
|
||||
collector,
|
||||
)
|
||||
expect(collector).toHaveLength(1)
|
||||
expect(collector[0]).toBe(mockTimelineHandle)
|
||||
})
|
||||
|
||||
it('disposes the diff handle on back when the diff panel was open', () => {
|
||||
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
|
||||
// Open diff
|
||||
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
|
||||
// Press back
|
||||
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
|
||||
expect(mockDiffHandle.destroy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user