// @vitest-environment jsdom /** * test/tabs.test.ts — frontend TabApp unit tests. * * Mocks TerminalSession + the launcher + projects panel so we test TabApp's * tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant, * that addEntry builds a real (non-null) session before pushing the entry * (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6). */ import { describe, it, expect, vi, beforeEach } from 'vitest' // ── Mock TerminalSession ────────────────────────────────────────────────────── let constructed = 0 class FakeTerminalSession { el: HTMLDivElement id: string | null status = 'connecting' claudeStatus = 'unknown' cwd: string | null = null pendingApproval = false /** Captured so tests can assert initialInput ends with \r (Finding 1). */ initialInput: string | undefined = undefined connect = vi.fn() dispose = vi.fn() show = vi.fn() hide = vi.fn() applyTheme = vi.fn() refit = vi.fn() send = vi.fn() approve = vi.fn() reject = vi.fn() findNext = vi.fn() findPrevious = vi.fn() clearSearch = vi.fn() // Captured callbacks so tests can drive status/title/activity events. cbs: { onClaudeStatus?: (s: string, d?: string) => void onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void } static instances: FakeTerminalSession[] = [] constructor(opts: { sessionId: string | null initialInput?: string onClaudeStatus?: (s: string, d?: string) => void onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void [key: string]: unknown }) { constructed += 1 this.id = opts.sessionId this.initialInput = opts.initialInput this.el = document.createElement('div') this.cbs = opts FakeTerminalSession.instances.push(this) } } vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession })) // ── Mock the launcher (records visibility) ──────────────────────────────────── const launcherVisible = { value: false } const mountLauncher = vi.fn(() => ({ setVisible: (v: boolean) => { launcherVisible.value = v }, refresh: vi.fn(), })) vi.mock('../public/launcher.js', () => ({ mountLauncher })) // ── Mock the projects panel (records visibility) ────────────────────────────── const projectsVisible = { value: false } const mountProjects = vi.fn(() => ({ setVisible: (v: boolean) => { projectsVisible.value = v }, refresh: vi.fn(), })) vi.mock('../public/projects.js', () => ({ mountProjects })) // settings.js is light but imports nothing heavy; let it load for real. const { TabApp } = await import('../public/tabs.js') function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } { const paneHost = document.createElement('div') const tabBar = document.createElement('div') document.body.append(paneHost, tabBar) return { paneHost, tabBar } } beforeEach(() => { constructed = 0 FakeTerminalSession.instances = [] launcherVisible.value = false projectsVisible.value = false document.body.replaceChildren() localStorage.clear() }) describe('TabApp — v0.5 launcher chooser', () => { it('starts with NO auto-created tab and shows the launcher', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) // No TerminalSession constructed on boot (no auto tab). expect(constructed).toBe(0) expect(launcherVisible.value).toBe(true) }) it('newTab() creates a tab and hides the launcher', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(constructed).toBe(1) expect(launcherVisible.value).toBe(false) }) it('closing the last tab returns to the launcher (no auto-blank tab)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(launcherVisible.value).toBe(false) app.closeTab(0) // Back to the chooser, and no new tab was auto-created. expect(launcherVisible.value).toBe(true) expect(constructed).toBe(1) }) }) describe('TabApp — addEntry has no null-session hole (Phase 1#5)', () => { it('the constructed session is real and used immediately (connect called)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // The entry's session must be the constructed FakeTerminalSession with connect() called. const snap = app.snapshot() expect(snap).toHaveLength(1) // sendToActive routes to a real session (would throw if session were null). expect(() => app.sendToActive('x')).not.toThrow() }) it('openSession joins by id and focuses it', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openSession('11111111-1111-4111-8111-111111111111') expect(constructed).toBe(1) expect(app.activeSessionId()).toBe('11111111-1111-4111-8111-111111111111') }) it('openSession re-focuses an already-open session instead of duplicating', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const id = '22222222-2222-4222-8222-222222222222' app.openSession(id) app.openSession(id) expect(constructed).toBe(1) // not duplicated }) }) describe('TabApp — multi-tab lifecycle', () => { it('renders a tab bar with one .tab per tab plus the add button', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() expect(tabBar.querySelectorAll('.tab')).toHaveLength(2) expect(tabBar.querySelector('.tab-add')).not.toBeNull() }) it('activate() switches the active tab (show/hide on sessions)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() app.activate(0) const snap = app.snapshot() expect(snap.find((t) => t.active)?.idx).toBe(0) }) it('closeTab on a middle tab keeps the others and re-activates a neighbor', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() app.newTab() app.closeTab(1) expect(app.snapshot()).toHaveLength(2) }) it('applySettings re-themes every tab without throwing', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(() => app.applySettings({ theme: 'dark', fontSize: 15 })).not.toThrow() }) it('findInActive / clearActiveSearch route to the active session', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(() => { app.findInActive('q', 'next') app.findInActive('q', 'prev') app.clearActiveSearch() app.refitActive() }).not.toThrow() }) it('newTabForResume opens a tab seeded with a resume command', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333') expect(constructed).toBe(1) expect(app.snapshot()).toHaveLength(1) }) it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333') const session = FakeTerminalSession.instances[0]! expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r') }) it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/path/to/myrepo', 'myrepo') const session = FakeTerminalSession.instances[0]! expect(session.initialInput).toBe('claude\r') }) it('snapshot reflects per-tab connection + claude status fields', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const snap = app.snapshot() expect(snap[0]).toMatchObject({ idx: 0, conn: 'connecting', claude: 'unknown' }) }) }) describe('TabApp — DOM interactions on the tab bar', () => { function pointerdown(elm: Element): void { elm.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) } it('pointerdown on a tab activates it', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const tabs = tabBar.querySelectorAll('.tab') pointerdown(tabs[0]!) expect(app.snapshot().find((t) => t.active)?.idx).toBe(0) }) it('clicking the × close button closes that tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const close = tabBar.querySelector('.tab-close') as HTMLButtonElement close.dispatchEvent(new MouseEvent('click', { bubbles: true })) expect(app.snapshot()).toHaveLength(1) }) it('clicking the + add button opens a new tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) const add = tabBar.querySelector('.tab-add') as HTMLButtonElement add.click() expect(app.snapshot()).toHaveLength(1) }) it('middle-click (auxclick button 1) closes a tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const tab = tabBar.querySelector('.tab') as HTMLElement tab.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 })) expect(app.snapshot()).toHaveLength(1) }) it('double-click the label enters rename mode, Enter commits a custom title', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const label = tabBar.querySelector('.tab-label') as HTMLElement label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement expect(input).not.toBeNull() input.value = 'My Tab' input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) expect(app.snapshot()[0]!.title).toBe('My Tab') }) it('activate() out of range is ignored', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() expect(() => { app.activate(99) app.activate(-1) app.focusTab(0) }).not.toThrow() }) it('sendToActive / clearActiveSearch with no active tab do not throw', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) // No tab open (launcher visible). expect(() => { app.sendToActive('x') app.clearActiveSearch() app.refitActive() }).not.toThrow() expect(app.activeSessionId()).toBeNull() }) it('Escape in rename mode cancels without committing', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const label = tabBar.querySelector('.tab-label') as HTMLElement label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement input.value = 'discard me' input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) // Title falls back to the auto/default, not the typed value. expect(app.snapshot()[0]!.title).not.toBe('discard me') }) it('onTitle/onStatus/onActivity callbacks refresh the tab in place', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const fake = FakeTerminalSession.instances[0]! // Drive the captured callbacks like the real TerminalSession would. expect(() => { fake.cbs.onTitle?.('myproj') fake.cbs.onStatus?.('connected') fake.cbs.onActivity?.() }).not.toThrow() }) it('onClaudeStatus "waiting" on a background tab notifies (H2/H4)', () => { const NotificationMock = vi.fn() ;(NotificationMock as unknown as { permission: string }).permission = 'granted' vi.stubGlobal('Notification', NotificationMock) const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() // tab 0 (active) app.newTab() // tab 1 (active now) // tab 0 is in the background; fire its onClaudeStatus 'waiting'. const bg = FakeTerminalSession.instances[0]! bg.claudeStatus = 'waiting' bg.cbs.onClaudeStatus?.('waiting') expect(NotificationMock).toHaveBeenCalled() vi.unstubAllGlobals() }) it('drag-and-drop reorders tabs', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() app.newTab() const tabs = tabBar.querySelectorAll('.tab') // jsdom has no DataTransfer; the handlers use optional chaining + this.dragIndex, // so plain drag events (no dataTransfer) still drive the reorder path. tabs[0]!.dispatchEvent(new Event('dragstart', { bubbles: true })) tabs[1]!.dispatchEvent(new Event('dragover', { bubbles: true })) tabs[1]!.dispatchEvent(new Event('drop', { bubbles: true })) tabs[0]!.dispatchEvent(new Event('dragend', { bubbles: true })) expect(app.snapshot()).toHaveLength(2) }) }) describe('TabApp — P6 openProject + home segmented control', () => { it('openProject creates a tab with the repo name as its displayed title', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/path/to/myrepo', 'myrepo') expect(app.snapshot()).toHaveLength(1) expect(app.snapshot()[0]!.title).toBe('myrepo') }) it('a second openProject with the same name gets a #2 suffix', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/path/to/myrepo', 'myrepo') app.openProject('/path/to/myrepo', 'myrepo') const snap = app.snapshot() expect(snap).toHaveLength(2) expect(snap[0]!.title).toBe('myrepo') expect(snap[1]!.title).toBe('myrepo #2') }) it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p', 'proj') app.openProject('/p', 'proj') app.openProject('/p', 'proj') const snap = app.snapshot() expect(snap[0]!.title).toBe('proj') expect(snap[1]!.title).toBe('proj #2') expect(snap[2]!.title).toBe('proj #3') }) it('openProject hides the segmented control and both panels (tab open)', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p/repo', 'repo') const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg).not.toBeNull() expect(seg.style.display).toBe('none') expect(launcherVisible.value).toBe(false) expect(projectsVisible.value).toBe(false) }) it('home segmented control shows both panels depending on selected view', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) // Initially: sessions view → launcher visible, projects hidden. expect(launcherVisible.value).toBe(true) expect(projectsVisible.value).toBe(false) // Click the "Projects" button (second .home-seg-btn). const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement projBtn.click() expect(launcherVisible.value).toBe(false) expect(projectsVisible.value).toBe(true) // Click the "Sessions" button (first .home-seg-btn). const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement sessBtn.click() expect(launcherVisible.value).toBe(true) expect(projectsVisible.value).toBe(false) }) it('closing the last tab shows the segmented control and restores home view', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg.style.display).toBe('none') // hidden while tab open app.closeTab(0) expect(seg.style.display).not.toBe('none') // back to home expect(launcherVisible.value).toBe(true) // sessions view by default }) it('openProject with an empty repoPath still opens a tab', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) expect(() => app.openProject('', 'bare-repo')).not.toThrow() expect(app.snapshot()).toHaveLength(1) expect(app.snapshot()[0]!.title).toBe('bare-repo') }) }) describe('TabApp — Home (⌂) button: reach the chooser from an open session', () => { it('overlays the home chooser over an open tab, then toggles back', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.newTab() const seg = paneHost.querySelector('.home-seg') as HTMLElement const homeBtn = tabBar.querySelector('.tab-home') as HTMLButtonElement expect(homeBtn).not.toBeNull() expect(seg.style.display).toBe('none') // terminal owns the screen homeBtn.click() // overlay the home chooser expect(seg.style.display).toBe('flex') expect(launcherVisible.value).toBe(true) // default sessions view expect(homeBtn.classList.contains('active')).toBe(true) homeBtn.click() // back to the terminal expect(seg.style.display).toBe('none') expect(launcherVisible.value).toBe(false) expect(homeBtn.classList.contains('active')).toBe(false) }) it('can switch to Projects from the overlay and open another project', () => { const { paneHost, tabBar } = makeHosts() const app = new TabApp(paneHost, tabBar) app.openProject('/p/one', 'one') ;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click() // overlay home ;(paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement).click() // Projects expect(projectsVisible.value).toBe(true) app.openProject('/p/two', 'two') // opening dismisses the overlay expect(app.snapshot()).toHaveLength(2) const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg.style.display).toBe('none') expect(projectsVisible.value).toBe(false) expect((tabBar.querySelector('.tab-home') as HTMLButtonElement).classList.contains('active')).toBe(false) }) it('⌂ is a no-op when no tab is open (home already shown)', () => { const { paneHost, tabBar } = makeHosts() new TabApp(paneHost, tabBar) const seg = paneHost.querySelector('.home-seg') as HTMLElement expect(seg.style.display).toBe('flex') ;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click() expect(seg.style.display).toBe('flex') // still shown, no crash }) })