Files
web-terminal/test/tabs.test.ts
Yaojia Wang 985ff8a178 feat(v0.6): add Home (⌂) button to reach Sessions/Projects from a session
Once a project/session tab was open, the home chooser (Sessions/Projects
segmented control) was hidden and only reachable by closing every tab — no
way back to the Projects view to open another project.

Add a ⌂ Home button to the tab bar that overlays the chooser on top of the
open tabs (homeForced flag; updateHomeView shows home when no tab OR forced,
hiding the terminal panes). Clicking ⌂ again, clicking a tab (activate resets
homeForced), or opening another session/project dismisses the overlay.
Closing the last tab resets the flag. The button highlights while active.

Tests: +3 (tabs.test.ts). Full suite 415 green; both typechecks clean;
verified in-browser (open tab → ⌂ → Projects panel reachable → ⌂ → terminal).
2026-06-30 11:39:38 +02:00

514 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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
})
})