/** * @fileoverview Session list layout: header tab strip ⟷ collapsible left sidebar. * * The whole design rests on ONE invariant: there is exactly one `#sessionTabs` * element and `applySessionListLayout()` RE-PARENTS it between the header host * and the sidebar. It must never be cloned or rebuilt — `app.$(id)` caches * elements by id and never invalidates, and settings-ui.js / webview-tabs.js * resolve the same id independently, so a rebuilt container would leave every * consumer writing into a detached orphan, silently and without an error. * `keeps the same DOM node across a layout flip` below is therefore the single * most important assertion in this file. * * Builds a JSDOM window in-test under the default node env, same shape as * test/webview-menu-rows.test.ts. Do NOT declare a per-file jsdom environment: * it externalizes node:fs under vite and the readFileSync calls below stop * working. ⚠ Do not name that directive in a comment either, vitest matches the * string anywhere in the file. */ import { readFileSync } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8'); const APP = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8'); const SETTINGS_UI = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8'); const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8'); const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8'); const MOBILE_CSS = readFileSync(new URL('../src/web/public/mobile.css', import.meta.url), 'utf-8'); const I18N = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf-8'); const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8'); const MOBILE_HANDLERS = readFileSync(new URL('../src/web/public/mobile-handlers.js', import.meta.url), 'utf-8'); const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url), 'utf-8'); interface LayoutApp { soloSessionId: string | null; isSoloWindow: boolean; sessions: Map; sessionOrder: string[]; _tallTabsEnabled?: boolean; _sidebarFilter?: string; _elemCache: Map; $(id: string): Element | null; getSessionListLayout(): string; isSessionSidebarActive(): boolean; isSessionSidebarCollapsed(): boolean; applySessionListLayout(): void; applyTabOrientation(): void; toggleSessionSidebar(): void; updateSidebarCount(): void; closeSessionSidebarOnHandheld(): void; _isSessionSidebarOverlay(): boolean; applySidebarFilter(query?: string): void; _fullRenderSessionTabs(): void; updateConnectionLines(): void; isSessionSidebarRich(): boolean; isTabRailRich(): boolean; isRichTabRows(): boolean; _sidebarRichRow(id: string, session: Record): RichRow | null; _sidebarRichMetaHTML(row: RichRow | null): string; _updateSidebarRichRow(tab: Element, id: string, session: Record): void; _startSidebarRichClock(): void; _stopSidebarRichClock(): void; _tickSidebarRichTimes(): void; _sidebarRichClock: ReturnType | null; pendingHooks?: Map>; _mobileOverviewState?: (session: Record, hooks?: Set) => string; _mobileOverviewSince?: (state: string, session: Record) => { key: string; at: number } | null; _mobileOverviewStampText?: (ts: number, fmt: string) => string; } interface RichRow { state: string; pill: string; createdAt: number; since: { key: string; at: number } | null; } /** The parts of index.html this feature touches, minus everything it does not. */ const SHELL = `
`; function boot( options: { stored?: Record; solo?: string | null; deviceType?: string; viewportWidth?: number; } = {} ) { const dom = new JSDOM(`${SHELL}`, { url: 'http://localhost/', runScripts: 'outside-only', }); const win = dom.window as unknown as Window & typeof globalThis & { __CodemanApp: new () => LayoutApp }; // Whether the sidebar is a docked column or a modal overlay is decided by // WIDTH (< 1024px), not by MobileDetection.getDeviceType() — that one calls // everything from 768px up 'desktop' while mobile.css, which defines the // overlay, is loaded with media="(max-width: 1023px)". jsdom defaults to // exactly 1024, so every handheld case has to say so explicitly. const width = options.viewportWidth ?? ((options.deviceType ?? 'desktop') === 'desktop' ? 1440 : 393); Object.defineProperty(win, 'innerWidth', { value: width, configurable: true, writable: true }); // Handhelds read a separate settings blob (getSettingsStorageKey), so a // handheld harness must seed the handheld key or the layout silently stays // on the header strip. const settingsKey = (options.deviceType ?? 'desktop') === 'desktop' ? 'codeman-app-settings' : 'codeman-app-settings-mobile'; if (options.stored) { win.localStorage.setItem(settingsKey, JSON.stringify(options.stored)); } // app.js assigns window.MobileDetection at top level from the global that // mobile-handlers.js declares, so it has to exist before the source runs. // One eval, not three: `class CodemanApp` is a lexical binding and would not // survive into a second global eval, and settings-ui.js needs it at load time. (win as unknown as { eval: (s: string) => void }).eval( [ `var MobileDetection = { getDeviceType: () => ${JSON.stringify(options.deviceType ?? 'desktop')}, isHandheldDevice: () => ${JSON.stringify(options.deviceType ?? 'desktop')} !== 'desktop', isMobile: () => false, isTouchDevice: () => false, };`, CONSTANTS, APP, SETTINGS_UI, 'window.__CodemanApp = CodemanApp;', ].join('\n') ); // Object.create, not `new`: the constructor boots SSE, timers and the whole // terminal stack. Only the layout surface is under test here. const app = Object.create(win.__CodemanApp.prototype) as LayoutApp; app.soloSessionId = options.solo ?? null; app.isSoloWindow = !!app.soloSessionId; app.sessions = new Map(); app.sessionOrder = []; app._elemCache = new Map(); app._fullRenderSessionTabs = vi.fn(); app.updateConnectionLines = vi.fn(); return { dom, win, app }; } const tabsEl = (win: Window) => win.document.getElementById('sessionTabs')!; const toggleBtn = (win: Window) => win.document.getElementById('sidebarToggleBtn')!; describe('session list layout', () => { it('defaults to the header tab strip when nothing is stored', () => { const { win, app } = boot(); expect(app.getSessionListLayout()).toBe('header'); app.applySessionListLayout(); expect(win.document.documentElement.dataset.sessionList).toBe('header'); expect(app.isSessionSidebarActive()).toBe(false); expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost'); expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true); }); it('preserves vertical rail ownership when the session-list layout reapplies', () => { const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } }); app.applySessionListLayout(); app.applyTabOrientation(); expect(tabsEl(win).parentElement?.id).toBe('tabRail'); app.applySessionListLayout(); expect(tabsEl(win).parentElement?.id).toBe('tabRail'); expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical'); }); it('keeps aria orientation synchronized when tab orientation moves hosts', () => { const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } }); app.applySessionListLayout(); app.applyTabOrientation(); expect(tabsEl(win).parentElement?.id).toBe('tabRail'); expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical'); win.localStorage.setItem( 'codeman-app-settings', JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'horizontal' }) ); delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings; app.applyTabOrientation(); expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost'); expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal'); }); it('re-parents the tab list into the sidebar and flips the a11y state', () => { const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } }); expect(app.getSessionListLayout()).toBe('sidebar'); app.applySessionListLayout(); expect(win.document.documentElement.dataset.sessionList).toBe('sidebar'); expect(win.document.documentElement.dataset.sidebar).toBe('expanded'); expect(app.isSessionSidebarActive()).toBe(true); expect(tabsEl(win).parentElement?.id).toBe('sessionSidebarList'); expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical'); expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(false); expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('true'); }); it('keeps the same DOM node across a layout flip (the $() element cache never invalidates)', () => { const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } }); const original = tabsEl(win); // Seed the cache the way any real render would. expect(app.$('sessionTabs')).toBe(original); app.applySessionListLayout(); expect(tabsEl(win)).toBe(original); expect(app.$('sessionTabs')).toBe(original); expect(original.parentElement?.id).toBe('sessionSidebarList'); // …and back again. win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'header' })); delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings; app.applySessionListLayout(); expect(tabsEl(win)).toBe(original); expect(app.$('sessionTabs')).toBe(original); expect(original.parentElement?.id).toBe('sessionTabsHost'); expect(original.getAttribute('aria-orientation')).toBe('horizontal'); expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true); }); it('never selects the sidebar in a solo (detached) window', () => { // A solo window shows one session, so the list is noise — and #sessionTabs // parked in the display:none