mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Add terminal scheduler regression coverage
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -36,7 +36,7 @@ import {
|
||||
} from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
|
||||
|
||||
// Why: registry lives in a leaf module so the store slice can import it
|
||||
// without re-entering the `slice → TerminalPane → store → slice` cycle
|
||||
@@ -44,8 +44,6 @@ import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-sch
|
||||
import { shutdownBufferCaptures } from './shutdown-buffer-captures'
|
||||
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024
|
||||
|
||||
type TerminalPaneProps = {
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
@@ -835,41 +833,6 @@ export default function TerminalPane({
|
||||
if (panes.length === 0) {
|
||||
return
|
||||
}
|
||||
const buffers: Record<string, string> = {}
|
||||
for (const pane of panes) {
|
||||
try {
|
||||
// Why: non-focused panes may have renderer-throttled PTY bytes queued;
|
||||
// push them into xterm before taking the shutdown scrollback snapshot.
|
||||
flushTerminalOutput(pane.terminal)
|
||||
const leafId = paneLeafId(pane.id)
|
||||
let scrollback = pane.terminal.options.scrollback ?? 10_000
|
||||
let serialized = pane.serializeAddon.serialize({ scrollback })
|
||||
// Cap at 512KB — binary search for largest scrollback that fits.
|
||||
if (serialized.length > MAX_BUFFER_BYTES && scrollback > 1) {
|
||||
let lo = 1
|
||||
let hi = scrollback
|
||||
let best = ''
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2)
|
||||
const attempt = pane.serializeAddon.serialize({ scrollback: mid })
|
||||
if (attempt.length <= MAX_BUFFER_BYTES) {
|
||||
best = attempt
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
serialized = best
|
||||
}
|
||||
if (serialized.length > 0) {
|
||||
buffers[leafId] = serialized
|
||||
}
|
||||
} catch {
|
||||
// Serialization failure for one pane should not block others.
|
||||
}
|
||||
}
|
||||
const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null
|
||||
const layout = serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current)
|
||||
// Why: setTabLayout REPLACES — it doesn't merge. captureBuffers can
|
||||
// run during a transient window (post-remount, just-attached,
|
||||
// mid-replay) where xterm hasn't rendered yet so serialize returns 0
|
||||
@@ -877,42 +840,14 @@ export default function TerminalPane({
|
||||
// buffer. Merge prior state in for leaves whose live capture came back
|
||||
// empty. Same shape as persistLayoutSnapshot.
|
||||
const existing = useAppStore.getState().terminalLayoutsByTabId[tabId]
|
||||
const currentLeafIds = new Set(panes.map((p) => paneLeafId(p.id)))
|
||||
const ptyEntries = panes
|
||||
.map(
|
||||
(pane) =>
|
||||
[
|
||||
paneLeafId(pane.id),
|
||||
paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
|
||||
] as const
|
||||
)
|
||||
.filter((entry): entry is readonly [string, string] => entry[1] !== null)
|
||||
const mergedBuffers = mergeCapturedLeafState({
|
||||
prior: existing?.buffersByLeafId,
|
||||
fresh: buffers,
|
||||
currentLeafIds
|
||||
const layout = captureTerminalShutdownLayout({
|
||||
manager,
|
||||
container,
|
||||
expandedPaneId: expandedPaneIdRef.current,
|
||||
paneTransports: paneTransportsRef.current,
|
||||
paneTitlesByPaneId: paneTitlesRef.current,
|
||||
existingLayout: existing
|
||||
})
|
||||
const mergedPtyIds = mergeCapturedLeafState({
|
||||
prior: existing?.ptyIdsByLeafId,
|
||||
fresh: Object.fromEntries(ptyEntries),
|
||||
currentLeafIds
|
||||
})
|
||||
if (Object.keys(mergedBuffers).length > 0) {
|
||||
layout.buffersByLeafId = mergedBuffers
|
||||
}
|
||||
if (Object.keys(mergedPtyIds).length > 0) {
|
||||
layout.ptyIdsByLeafId = mergedPtyIds
|
||||
}
|
||||
// Merge pane titles so the shutdown snapshot doesn't silently drop them.
|
||||
// Why: the old early-return on empty buffers skipped this entirely, which
|
||||
// meant titles were lost on restart when the terminal had no scrollback
|
||||
// content (e.g. fresh pane, cleared screen).
|
||||
const titleEntries = panes
|
||||
.filter((p) => paneTitlesRef.current[p.id])
|
||||
.map((p) => [paneLeafId(p.id), paneTitlesRef.current[p.id]] as const)
|
||||
if (titleEntries.length > 0) {
|
||||
layout.titlesByLeafId = Object.fromEntries(titleEntries)
|
||||
}
|
||||
setTabLayout(tabId, layout)
|
||||
}
|
||||
shutdownBufferCaptures.set(tabId, captureBuffers)
|
||||
|
||||
@@ -785,6 +785,32 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('writes visible split-pane PTY bytes immediately even when the tab is not active', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isActiveRef: { current: false },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
capturedDataCallback.current?.('visible split output\r\n')
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n')
|
||||
})
|
||||
|
||||
it('marks panes that receive Arabic output for DOM rendering', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
flushTerminalOutput: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: mocks.flushTerminalOutput
|
||||
}))
|
||||
|
||||
class MockHTMLElement {
|
||||
classList: { contains: (cls: string) => boolean }
|
||||
dataset: Record<string, string>
|
||||
children: MockHTMLElement[]
|
||||
style: Record<string, string>
|
||||
firstElementChild: MockHTMLElement | null
|
||||
|
||||
constructor(opts: {
|
||||
classList?: string[]
|
||||
dataset?: Record<string, string>
|
||||
children?: MockHTMLElement[]
|
||||
style?: Record<string, string>
|
||||
firstElementChild?: MockHTMLElement | null
|
||||
}) {
|
||||
const classes = opts.classList ?? []
|
||||
this.classList = { contains: (cls: string) => classes.includes(cls) }
|
||||
this.dataset = opts.dataset ?? {}
|
||||
this.children = opts.children ?? []
|
||||
this.style = opts.style ?? {}
|
||||
this.firstElementChild = opts.firstElementChild ?? null
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as unknown as Record<string, unknown>).HTMLElement = MockHTMLElement
|
||||
})
|
||||
|
||||
function mockRootForPane(paneId: number): HTMLDivElement {
|
||||
const pane = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: String(paneId) } })
|
||||
return new MockHTMLElement({ firstElementChild: pane }) as unknown as HTMLDivElement
|
||||
}
|
||||
|
||||
describe('captureTerminalShutdownLayout', () => {
|
||||
it('flushes queued terminal output before serializing shutdown scrollback', async () => {
|
||||
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
|
||||
const order: string[] = []
|
||||
const terminal = {
|
||||
options: { scrollback: 1_000 },
|
||||
pendingOutput: ''
|
||||
}
|
||||
const pane = {
|
||||
id: 1,
|
||||
terminal,
|
||||
serializeAddon: {
|
||||
serialize: vi.fn(() => {
|
||||
order.push('serialize')
|
||||
return `snapshot:${terminal.pendingOutput}`
|
||||
})
|
||||
}
|
||||
}
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [pane]),
|
||||
getActivePane: vi.fn(() => pane)
|
||||
}
|
||||
mocks.flushTerminalOutput.mockImplementation((target: typeof terminal) => {
|
||||
expect(target).toBe(terminal)
|
||||
order.push('flush')
|
||||
terminal.pendingOutput = 'queued-before-quit'
|
||||
})
|
||||
|
||||
const layout = captureTerminalShutdownLayout({
|
||||
manager: manager as never,
|
||||
container: mockRootForPane(1),
|
||||
expandedPaneId: null,
|
||||
paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
|
||||
paneTitlesByPaneId: { 1: 'build logs' },
|
||||
existingLayout: undefined
|
||||
})
|
||||
|
||||
expect(order).toEqual(['flush', 'serialize'])
|
||||
expect(layout).toMatchObject<TerminalLayoutSnapshot>({
|
||||
root: { type: 'leaf', leafId: 'pane:1' },
|
||||
activeLeafId: 'pane:1',
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'pane:1': 'snapshot:queued-before-quit' },
|
||||
ptyIdsByLeafId: { 'pane:1': 'pty-1' },
|
||||
titlesByLeafId: { 'pane:1': 'build logs' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { paneLeafId, serializeTerminalLayout } from './layout-serialization'
|
||||
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024
|
||||
|
||||
type ShutdownPane = Pick<ManagedPane, 'id' | 'terminal' | 'serializeAddon'>
|
||||
|
||||
type ShutdownPaneManager = {
|
||||
getPanes(): ShutdownPane[]
|
||||
getActivePane(): ShutdownPane | null
|
||||
}
|
||||
|
||||
type CaptureTerminalShutdownLayoutArgs = {
|
||||
manager: ShutdownPaneManager
|
||||
container: HTMLDivElement
|
||||
expandedPaneId: number | null
|
||||
paneTransports: ReadonlyMap<number, Pick<PtyTransport, 'getPtyId'>>
|
||||
paneTitlesByPaneId: Record<number, string>
|
||||
existingLayout: TerminalLayoutSnapshot | undefined
|
||||
}
|
||||
|
||||
export function captureTerminalShutdownLayout({
|
||||
manager,
|
||||
container,
|
||||
expandedPaneId,
|
||||
paneTransports,
|
||||
paneTitlesByPaneId,
|
||||
existingLayout
|
||||
}: CaptureTerminalShutdownLayoutArgs): TerminalLayoutSnapshot {
|
||||
const panes = manager.getPanes()
|
||||
const buffers: Record<string, string> = {}
|
||||
|
||||
for (const pane of panes) {
|
||||
try {
|
||||
// Why: non-focused panes may have renderer-throttled PTY bytes queued;
|
||||
// push them into xterm before taking the shutdown scrollback snapshot.
|
||||
flushTerminalOutput(pane.terminal)
|
||||
const leafId = paneLeafId(pane.id)
|
||||
let scrollback = pane.terminal.options.scrollback ?? 10_000
|
||||
let serialized = pane.serializeAddon.serialize({ scrollback })
|
||||
// Cap at 512KB — binary search for largest scrollback that fits.
|
||||
if (serialized.length > MAX_BUFFER_BYTES && scrollback > 1) {
|
||||
let lo = 1
|
||||
let hi = scrollback
|
||||
let best = ''
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2)
|
||||
const attempt = pane.serializeAddon.serialize({ scrollback: mid })
|
||||
if (attempt.length <= MAX_BUFFER_BYTES) {
|
||||
best = attempt
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
serialized = best
|
||||
}
|
||||
if (serialized.length > 0) {
|
||||
buffers[leafId] = serialized
|
||||
}
|
||||
} catch {
|
||||
// Serialization failure for one pane should not block others.
|
||||
}
|
||||
}
|
||||
|
||||
const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null
|
||||
const layout = serializeTerminalLayout(container, activePaneId, expandedPaneId)
|
||||
const currentLeafIds = new Set(panes.map((p) => paneLeafId(p.id)))
|
||||
const ptyEntries = panes
|
||||
.map((pane) => [paneLeafId(pane.id), paneTransports.get(pane.id)?.getPtyId() ?? null] as const)
|
||||
.filter((entry): entry is readonly [string, string] => entry[1] !== null)
|
||||
|
||||
const mergedBuffers = mergeCapturedLeafState({
|
||||
prior: existingLayout?.buffersByLeafId,
|
||||
fresh: buffers,
|
||||
currentLeafIds
|
||||
})
|
||||
const mergedPtyIds = mergeCapturedLeafState({
|
||||
prior: existingLayout?.ptyIdsByLeafId,
|
||||
fresh: Object.fromEntries(ptyEntries),
|
||||
currentLeafIds
|
||||
})
|
||||
if (Object.keys(mergedBuffers).length > 0) {
|
||||
layout.buffersByLeafId = mergedBuffers
|
||||
}
|
||||
if (Object.keys(mergedPtyIds).length > 0) {
|
||||
layout.ptyIdsByLeafId = mergedPtyIds
|
||||
}
|
||||
|
||||
const titleEntries = panes
|
||||
.filter((p) => paneTitlesByPaneId[p.id])
|
||||
.map((p) => [paneLeafId(p.id), paneTitlesByPaneId[p.id]] as const)
|
||||
if (titleEntries.length > 0) {
|
||||
layout.titlesByLeafId = Object.fromEntries(titleEntries)
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type * as ReactModule from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fitAndFocusPanes: vi.fn(),
|
||||
fitPanes: vi.fn(),
|
||||
flushTerminalOutput: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ReactModule>()
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
},
|
||||
useRef: <T>(value: T) => ({ current: value })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pane-helpers', () => ({
|
||||
fitAndFocusPanes: mocks.fitAndFocusPanes,
|
||||
fitPanes: mocks.fitPanes
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: mocks.flushTerminalOutput
|
||||
}))
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
|
||||
describe('useTerminalPaneGlobalEffects', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as unknown as { window: unknown }).window = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
api: {
|
||||
ui: {
|
||||
onFileDrop: vi.fn(() => vi.fn())
|
||||
}
|
||||
}
|
||||
}
|
||||
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { window?: unknown }).window
|
||||
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
||||
})
|
||||
|
||||
it('flushes visible terminal panes before resuming rendering and fitting', async () => {
|
||||
const { useTerminalPaneGlobalEffects } = await import('./use-terminal-pane-global-effects')
|
||||
const order: string[] = []
|
||||
const terminalA = { name: 'terminal-a' }
|
||||
const terminalB = { name: 'terminal-b' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [
|
||||
{ id: 1, terminal: terminalA },
|
||||
{ id: 2, terminal: terminalB }
|
||||
]),
|
||||
resumeRendering: vi.fn(() => order.push('resume')),
|
||||
suspendRendering: vi.fn(),
|
||||
fitAllPanes: vi.fn(),
|
||||
getActivePane: vi.fn(() => null),
|
||||
setActivePane: vi.fn()
|
||||
}
|
||||
mocks.flushTerminalOutput.mockImplementation((terminal: { name: string }) => {
|
||||
order.push(`flush:${terminal.name}`)
|
||||
})
|
||||
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
|
||||
|
||||
const isActiveRef = { current: false }
|
||||
const isVisibleRef = { current: false }
|
||||
useTerminalPaneGlobalEffects({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
isActive: true,
|
||||
isVisible: true,
|
||||
managerRef: { current: manager as never },
|
||||
containerRef: { current: null },
|
||||
paneTransportsRef: { current: new Map() },
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
toggleExpandPane: vi.fn()
|
||||
})
|
||||
|
||||
expect(order).toEqual(['flush:terminal-a', 'flush:terminal-b', 'resume', 'fit-focus'])
|
||||
expect(mocks.fitPanes).not.toHaveBeenCalled()
|
||||
expect(isActiveRef.current).toBe(true)
|
||||
expect(isVisibleRef.current).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user