/** * Bottom-panel first-expand auto-terminal tests — the TRIGGER CHAIN behind * issue #42 (bottom panel first expand + auto terminal → whole panel blank). * * #42's crash itself was the zero-size xterm open (the same root cause as * #25, fixed by openWhenSized — see tests/open-when-sized.spec.ts), and the * "whole panel blank" amplification is covered by the per-tab containment * tests (tests/sidebar-crash.spec.tsx). What no test pinned was the trigger: * the Sidebar effect that opens a terminal tab the FIRST time the bottom * panel expands (`bottomOpenedOnce`), its pref / enable-switch gates, and * the once-per-session semantics. These tests render the REAL Sidebar shell * against a minimal fake context (the repo's jsdom pattern) and drive the * bottom panel through the store, asserting the tab lands in the bottom * workbench, renders, and the panel itself survives. */ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' // The act() environment flag (React 18.2 reads it before flushing effects). import { setupReactAct } from './test-utils.ts' setupReactAct() import { Sidebar } from '../src/client/Sidebar.tsx' import { allLeaves, createSidebarStore, toggleBottomPanel, type SidebarStore } from '../src/client/state.ts' import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts' import { t } from '../src/client/locales.ts' /** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */ class FakeWebSocket { onmessage: ((event: { data: unknown }) => void) | null = null onclose: (() => void) | null = null onerror: (() => void) | null = null close = (): void => {} constructor(_url: string) {} } interface MountedSidebar { container: HTMLDivElement store: SidebarStore service: BetterSidebarService unmount: () => void } /** Mount the real Sidebar shell against a minimal context (real store + service). */ /** Unique per-test session ids (see the comment inside). */ let sessionSeq = 0 function mountSidebar(): MountedSidebar { vi.stubGlobal('WebSocket', FakeWebSocket) const container = document.createElement('div') document.body.append(container) const store = createSidebarStore() const service = createBetterSidebarService(store) // Fresh-session seed: the right panel starts OPEN, the bottom panel closed // (bottomOpen false → the first expansion is a false→true TRANSITION). // Unique session per test — the store persists per-session state to // localStorage (200ms debounce); a shared id lets a previous test's late // write leak into this store's setSession restore. const sessionId = `s1-${++sessionSeq}` store.setSession(sessionId) // useSyncExternalStore requires STABLE snapshots across calls (the real DSH // services return stable objects) — a fresh object per call loops forever. const localeSnapshot = { active: 'en' } const sessionsSnapshot = { current: sessionId, // cwd present → api.sessionCwd is never called in these tests. byId: { [sessionId]: { cwd: '/tmp' } }, } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, betterSidebar: service, get: (name: string) => name === 'betterSidebar' ? service : undefined, } const root: Root = createRoot(container) act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) }) return { container, store, service, unmount: () => { act(() => { root.unmount() }) container.remove() }, } } afterEach(() => { document.body.innerHTML = '' // Belt and braces: drop any persisted layout a pending 200ms debounce // write left behind between tests (unique session ids already isolate). localStorage.clear() vi.unstubAllGlobals() }) /** Every tab currently living in the bottom workbench. */ function bottomTabs(store: SidebarStore): Array<{ type: string; title: string }> { const state = store.getSnapshot().state if (state === undefined) return [] return allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) } /** The stub terminal tab: counts renders so the test can see it actually mount. */ function registerStubTerminal(service: BetterSidebarService, renders: { count: number }): void { service.registerTab({ id: 'terminal', title: () => 'Terminal', component: () => { renders.count += 1 return createElement('div', null, 'terminal-stub-content') }, }) } describe('bottom-panel first-expand auto terminal (issue #42 trigger chain)', () => { it('auto-opens exactly one terminal tab in the bottom workbench on the FIRST expansion', () => { const { container, store, service } = mountSidebar() const renders = { count: 0 } registerStubTerminal(service, renders) act(() => { store.reduce(toggleBottomPanel) }) const state = store.getSnapshot().state! expect(state.bottomOpen).toBe(true) // The once-flag is set atomically with the first fire. expect(state.bottomOpenedOnce).toBe(true) // The terminal tab landed in the BOTTOM workbench (the effect pins the // active pane to the bottom tree's first leaf before opening). expect(bottomTabs(store).map(tab => tab.type)).toEqual(['terminal']) // The tab actually mounted and rendered — the chain is live end to end. expect(renders.count).toBeGreaterThanOrEqual(1) expect(container.textContent).toContain('terminal-stub-content') // The panel itself survived (the #42 symptom was a WHOLE blank panel): // the close control is present and the layout push for the bottom panel // height is live. expect(container.querySelector(`[aria-label="${t('collapseBottomPanel')}"]`)).not.toBeNull() expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')).toBe( `${state.bottomHeight}px`, ) }) it('never repeats the auto-open on later expansions (once per session)', () => { const { store, service } = mountSidebar() registerStubTerminal(service, { count: 0 }) act(() => { store.reduce(toggleBottomPanel) }) expect(bottomTabs(store)).toHaveLength(1) // Collapse → expand again: the once-flag suppresses any second terminal. act(() => { store.reduce(toggleBottomPanel) }) expect(store.getSnapshot().state!.bottomOpen).toBe(false) act(() => { store.reduce(toggleBottomPanel) }) expect(bottomTabs(store)).toHaveLength(1) }) it('does not auto-open when the bottomPanelAutoTerminal pref is off', () => { const { store, service } = mountSidebar() registerStubTerminal(service, { count: 0 }) // setPrefs REPLACES the prefs record — spread the current one so only // the toggle moves. act(() => { store.setPrefs({ ...store.getPrefs(), bottomPanelAutoTerminal: false }) }) act(() => { store.reduce(toggleBottomPanel) }) expect(store.getSnapshot().state!.bottomOpen).toBe(true) expect(bottomTabs(store)).toHaveLength(0) }) it('does not auto-open when the terminal tab type is disabled in settings', () => { const { store, service } = mountSidebar() registerStubTerminal(service, { count: 0 }) act(() => { store.setPrefs({ ...store.getPrefs(), tabsEnabled: { terminal: false } }) }) act(() => { store.reduce(toggleBottomPanel) }) expect(store.getSnapshot().state!.bottomOpen).toBe(true) expect(bottomTabs(store)).toHaveLength(0) }) })