// @vitest-environment jsdom /** * test/tabs.test.ts — frontend TabApp unit tests. * * Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in * isolation: the v0.5 "close last tab → launcher" invariant and that addEntry * builds a real (non-null) session before pushing the entry (Phase 1#5 — the * `null as unknown as TerminalSession` hole is gone). */ 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 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 onClaudeStatus?: (s: string, d?: string) => void onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void }) { constructed += 1 this.id = opts.sessionId 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 })) // 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 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('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) }) })