Files
apes-sidebar/tests/bottom-auto-terminal.spec.tsx
T
2026-08-24 16:41:02 +08:00

180 lines
7.5 KiB
TypeScript

/**
* 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).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
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)
})
})