/* eslint-disable max-lines -- Terminal E2E helpers share one PaneManager-backed path for PTY IO, split actions, and stable pane identity snapshots. */ import type { Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ export type PaneIdentitySnapshot = { tabId: string activeLeafId: string | null panes: { numericPaneId: number leafId: string stablePaneId: string datasetLeafId: string | null ptyId: string | null }[] ptyIdsByLeafId: Record } export type ActivePaneHookDescriptor = { paneKey: string worktreeId: string } // Why: worktree restoration can render the terminal surface before the legacy // global activeTabId settles. Prefer the active worktree's saved terminal tab // pointer, then fall back to the first terminal tab. async function resolveActiveTabId(page: Page): Promise { return page.evaluate(() => { const store = window.__store if (!store) { return null } const state = store.getState() const wId = state.activeWorktreeId if (!wId) { return null } const tabs = state.tabsByWorktree[wId] ?? [] if (tabs.length === 0) { return null } const pref = state.activeTabType === 'terminal' ? state.activeTabId : (state.activeTabIdByWorktree?.[wId] ?? null) if (pref && tabs.some((t) => t.id === pref)) { return pref } return tabs[0]?.id ?? null }) } // Why: reads the buffer through the SerializeAddon that the PaneManager // already loads for every terminal pane (exposed via VITE_EXPOSE_STORE). export async function getTerminalContent(page: Page, charLimit = 4000): Promise { const tabId = await resolveActiveTabId(page) if (!tabId) { return '' } return page.evaluate( ({ tabId, charLimit }) => { const paneManagers = window.__paneManagers if (!paneManagers) { return '' } const manager = paneManagers.get(tabId) if (!manager) { return '' } const activePane = manager.getActivePane?.() if (!activePane) { const panes = manager.getPanes?.() ?? [] if (panes.length === 0) { return '' } const text = panes[0].serializeAddon?.serialize?.() ?? '' return text.slice(-charLimit) } const text = activePane.serializeAddon?.serialize?.() ?? '' return text.slice(-charLimit) }, { tabId, charLimit } ) } export async function waitForActivePanePtyId(page: Page, timeoutMs = 15_000): Promise { await expect .poll( async () => { const tabId = await resolveActiveTabId(page) if (!tabId) { return null } return page.evaluate((tabId) => { const manager = window.__paneManagers?.get(tabId) const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null return activePane?.container?.dataset?.ptyId ?? null }, tabId) }, { timeout: timeoutMs, message: 'Active terminal pane did not receive a PTY binding' } ) .not.toBeNull() const tabId = await resolveActiveTabId(page) if (!tabId) { throw new Error('waitForActivePanePtyId: no active terminal tab') } const ptyId = await page.evaluate((tabId) => { const manager = window.__paneManagers?.get(tabId) const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null return activePane?.container?.dataset?.ptyId ?? null }, tabId) if (!ptyId) { throw new Error('waitForActivePanePtyId: active pane has no PTY binding') } return ptyId } export async function waitForActivePaneHookDescriptor( page: Page, timeoutMs = 15_000 ): Promise { let descriptor: ActivePaneHookDescriptor | null = null await expect .poll( async () => { const tabId = await resolveActiveTabId(page) if (!tabId) { descriptor = null return false } descriptor = await page.evaluate((tabId) => { const layoutHasLeaf = (node: unknown, targetLeafId: string): boolean => { if (!node || typeof node !== 'object') { return false } const record = node as { type?: unknown leafId?: unknown first?: unknown second?: unknown } if (record.type === 'leaf') { return record.leafId === targetLeafId } return ( layoutHasLeaf(record.first, targetLeafId) || layoutHasLeaf(record.second, targetLeafId) ) } const store = window.__store const manager = window.__paneManagers?.get(tabId) if (!store || !manager) { return null } const state = store.getState() const worktreeId = state.activeWorktreeId if ( !worktreeId || !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) ) { return null } const activePane = manager.getActivePane?.() ?? manager.getPanes?.()[0] const leafId = activePane?.leafId ?? null const layout = state.terminalLayoutsByTabId[tabId] if ( !leafId || !layoutHasLeaf(layout?.root, leafId) || layout?.ptyIdsByLeafId?.[leafId] !== activePane?.container?.dataset?.ptyId ) { return null } return { paneKey: `${tabId}:${leafId}`, worktreeId } }, tabId) return descriptor !== null }, { timeout: timeoutMs, // Why: hook IPC routing drops statuses for pane keys before the store // layout knows that leaf, even if the terminal DOM already has a PTY. message: 'Active terminal pane did not become routable for hook status IPC' } ) .toBe(true) if (!descriptor) { throw new Error('Active terminal pane descriptor disappeared after routing wait') } return descriptor } // Why: PTY IDs are opaque integers not exposed in the DOM. Probe each // candidate with a unique marker and read back via SerializeAddon. export async function discoverActivePtyId(page: Page): Promise { const marker = `__PTY_PROBE_${Date.now()}__` const readCandidateIds = async (): Promise => { const tabId = await resolveActiveTabId(page) if (!tabId) { return [] } return page.evaluate((tabId) => { const store = window.__store if (!store) { return [] } return store.getState().ptyIdsByTabId[tabId] ?? [] }, tabId) } await expect .poll(readCandidateIds, { timeout: 15_000, message: 'discoverActivePtyId: active tab never received PTY candidates' }) .not.toEqual([]) const candidateIds = await readCandidateIds() if (candidateIds.length === 0) { // Why: blind-probing arbitrary PTY IDs can write into unrelated shells and // hides real regressions in the tab->PTY mapping the test depends on. throw new Error('discoverActivePtyId: active tab has no PTY candidates in store') } await page.evaluate( ({ marker, candidateIds }) => { // Why: daemon PTY IDs can contain path separators and shell metacharacters. // Echo a numeric probe index, then map it back to the opaque ID in Node. for (const [index, id] of candidateIds.entries()) { window.api.pty.write(String(id), `\x03\x15echo ${marker}_${index}\r`) } }, { marker, candidateIds } ) let foundPtyId: string | null = null await expect .poll( async () => { const content = await getTerminalContent(page) const markerRe = new RegExp(`${marker}_(\\d+)`, 'g') const matches = [...content.matchAll(markerRe)] if (matches.length > 0) { const index = Number(matches.at(-1)?.[1] ?? Number.NaN) foundPtyId = Number.isInteger(index) ? (candidateIds[index] ?? null) : null return true } return false }, { timeout: 10_000, message: 'PTY marker did not appear in terminal buffer' } ) .toBe(true) if (!foundPtyId) { throw new Error('discoverActivePtyId: no marker found in terminal buffer') } return foundPtyId } export async function readPaneIdentitySnapshot(page: Page): Promise { const tabId = await resolveActiveTabId(page) if (!tabId) { return null } return page.evaluate((tabId) => { const manager = window.__paneManagers?.get(tabId) const store = window.__store if (!manager || !store) { return null } const activePane = manager.getActivePane?.() ?? null return { tabId, activeLeafId: activePane?.leafId ?? null, panes: manager.getPanes().map((pane) => ({ numericPaneId: pane.id, leafId: pane.leafId, stablePaneId: pane.stablePaneId, datasetLeafId: pane.container.dataset.leafId ?? null, ptyId: pane.container.dataset.ptyId ?? null })), ptyIdsByLeafId: store.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} } }, tabId) } export async function waitForPaneIdentitySnapshot( page: Page, paneCount: number ): Promise { await expect .poll( async () => { const snapshot = await readPaneIdentitySnapshot(page) return Boolean( snapshot && snapshot.panes.length === paneCount && snapshot.panes.every( (pane) => UUID_RE.test(pane.leafId) && pane.stablePaneId === pane.leafId && pane.datasetLeafId === pane.leafId && pane.ptyId !== null && snapshot.ptyIdsByLeafId[pane.leafId] === pane.ptyId ) ) }, { timeout: 15_000, message: 'Split terminal panes did not settle with UUID leaf-keyed PTY bindings' } ) .toBe(true) const snapshot = await readPaneIdentitySnapshot(page) if (!snapshot) { throw new Error('Pane identity snapshot disappeared after settling') } return snapshot } export async function readTerminalPaneDomLeafOrder(page: Page): Promise { const snapshot = await readPaneIdentitySnapshot(page) if (!snapshot) { return [] } return page.evaluate((tabId) => { const manager = window.__paneManagers?.get(tabId) if (!manager) { return [] } const paneElements = new Set(manager.getPanes().map((pane) => pane.container)) return Array.from(document.querySelectorAll('.pane[data-leaf-id]')) .filter((element) => paneElements.has(element)) .map((element) => element.dataset.leafId ?? '') .filter((leafId) => leafId.length > 0) }, snapshot.tabId) } export async function moveTerminalPaneByLeafId( page: Page, sourceLeafId: string, targetLeafId: string, zone: 'top' | 'bottom' | 'left' | 'right' ): Promise { const snapshot = await readPaneIdentitySnapshot(page) if (!snapshot) { throw new Error('moveTerminalPaneByLeafId: no active terminal tab') } await page.evaluate( ({ tabId, sourceLeafId, targetLeafId, zone }) => { const manager = window.__paneManagers?.get(tabId) if (!manager) { throw new Error('moveTerminalPaneByLeafId: active pane manager not ready') } const sourcePaneId = manager.getNumericIdForLeaf(sourceLeafId) const targetPaneId = manager.getNumericIdForLeaf(targetLeafId) if (sourcePaneId == null || targetPaneId == null) { throw new Error('moveTerminalPaneByLeafId: source or target leaf is not mounted') } manager.movePane(sourcePaneId, targetPaneId, zone) }, { tabId: snapshot.tabId, sourceLeafId, targetLeafId, zone } ) } export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { await page.evaluate( ({ ptyId, text }) => { window.api.pty.write(ptyId, text) }, { ptyId, text } ) } export async function execInTerminal(page: Page, ptyId: string, command: string): Promise { await sendToTerminal(page, ptyId, `${command}\r`) } export async function waitForActiveTerminalManager(page: Page, timeoutMs = 30_000): Promise { await expect .poll( async () => { const tabId = await resolveActiveTabId(page) if (!tabId) { return false } return page.evaluate((tabId) => { const paneManagers = window.__paneManagers if (!paneManagers) { return false } return (paneManagers.get(tabId)?.getPanes?.().length ?? 0) > 0 }, tabId) }, { timeout: timeoutMs, message: 'Active terminal PaneManager did not finish mounting' } ) .toBe(true) } export async function splitActiveTerminalPane( page: Page, direction: 'vertical' | 'horizontal' ): Promise { const tabId = await resolveActiveTabId(page) if (!tabId) { throw new Error('splitActiveTerminalPane: no active terminal tab') } await page.evaluate( ({ tabId, direction }) => { const paneManagers = window.__paneManagers if (!paneManagers) { throw new Error('splitActiveTerminalPane: terminal store/manager unavailable') } const manager = paneManagers.get(tabId) const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null if (!manager?.splitPane || !activePane) { throw new Error('splitActiveTerminalPane: active pane manager not ready') } // Why: Electron key delivery to the terminal pane layer is flaky in E2E // even when the visible pane tree is mounted. Driving the active // PaneManager directly still exercises the real split/layout/PTY path // without depending on window-focus timing. manager.splitPane(activePane.id, direction) }, { tabId, direction } ) } export async function closeActiveTerminalPane(page: Page): Promise { const tabId = await resolveActiveTabId(page) if (!tabId) { throw new Error('closeActiveTerminalPane: no active terminal tab') } await page.evaluate((tabId) => { const paneManagers = window.__paneManagers if (!paneManagers) { throw new Error('closeActiveTerminalPane: terminal store/manager unavailable') } const manager = paneManagers.get(tabId) const panes = manager?.getPanes?.() ?? [] if (!manager?.closePane || panes.length < 2) { return } const activePane = manager.getActivePane?.() ?? panes[0] if (!activePane) { return } manager.closePane(activePane.id) }, tabId) } export async function focusLastTerminalPane(page: Page): Promise { const tabId = await resolveActiveTabId(page) if (!tabId) { throw new Error('focusLastTerminalPane: no active terminal tab') } await page.evaluate((tabId) => { const paneManagers = window.__paneManagers if (!paneManagers) { throw new Error('focusLastTerminalPane: terminal store/manager unavailable') } const manager = paneManagers.get(tabId) const panes = manager?.getPanes?.() ?? [] const lastPane = panes.at(-1) ?? null if (!manager?.setActivePane || !lastPane) { throw new Error('focusLastTerminalPane: active pane manager not ready') } manager.setActivePane(lastPane.id, { focus: true }) }, tabId) } // Why: hidden-window E2E mode keeps DOM visibility signals false. The pane // manager tracks the authoritative active split layout independently of CSS. export async function countVisibleTerminalPanes(page: Page): Promise { const tabId = await resolveActiveTabId(page) if (!tabId) { return 0 } return page.evaluate((tabId) => { const managerCount = window.__paneManagers?.get(tabId)?.getPanes?.().length ?? 0 if (managerCount > 0) { return managerCount } const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] if (!layout) { return 0 } // Why: `root: null` means the default single-pane tab (no splits yet). type N = { type: 'leaf' } | { type: 'split'; first: N | null; second: N | null } | null const countLeaves = (node: N): number => { if (!node || node.type === 'leaf') { return 1 } return countLeaves(node.first) + countLeaves(node.second) } return countLeaves(layout.root as N) }, tabId) } export async function waitForTerminalOutput( page: Page, expected: string, timeoutMs = 10_000 ): Promise { await expect .poll(async () => (await getTerminalContent(page)).includes(expected), { timeout: timeoutMs, message: `Terminal did not contain "${expected}"` }) .toBe(true) } export async function waitForPaneCount( page: Page, expectedCount: number, timeoutMs = 10_000 ): Promise { await expect .poll(async () => countVisibleTerminalPanes(page), { timeout: timeoutMs, message: `Expected ${expectedCount} visible terminal panes` }) .toBe(expectedCount) }