mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
test(terminal): pin layout invariants the mirror refactor must keep (#26073)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { buildAgentResumeStartupPlan } from '../../../shared/tui-agent-resume-startup'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_GIT_WORKTREE_ID as WORKTREE,
|
||||
openTopologyStore,
|
||||
reopenTopologyStore
|
||||
} from './terminal-topology-profile-fixture'
|
||||
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
// The mirror refactor moves sleeping records into main's own commits and makes window saves
|
||||
// presentation-only; a slept agent must still be resumable after quit and relaunch.
|
||||
|
||||
const TAB = 'tab-agent'
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const PTY = `${WORKTREE}@@0a1b2c3d`
|
||||
const PANE_KEY = `${TAB}:${LEAF}`
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function record(origin: 'worktree-sleep' | 'quit', capturedAt: number): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: TAB,
|
||||
worktreeId: WORKTREE,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: `session-${capturedAt}` },
|
||||
prompt: 'finish the task',
|
||||
state: 'waiting',
|
||||
capturedAt,
|
||||
updatedAt: capturedAt,
|
||||
origin
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's session after it spawned an agent pane, as its debounced save sends it. */
|
||||
function windowSessionWithAgentPane(store: Store): WorkspaceSessionState {
|
||||
const session = structuredClone(store.getWorkspaceSession())
|
||||
session.tabsByWorktree = {
|
||||
...session.tabsByWorktree,
|
||||
[WORKTREE]: [
|
||||
{
|
||||
id: TAB,
|
||||
ptyId: PTY,
|
||||
worktreeId: WORKTREE,
|
||||
title: 'codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'codex'
|
||||
}
|
||||
]
|
||||
}
|
||||
session.terminalLayoutsByTabId = {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[TAB]: {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: PTY }
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
async function storeWithAgentPane() {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-'))
|
||||
directories.push(directory)
|
||||
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
|
||||
const window = windowSessionWithAgentPane(store)
|
||||
store.setWorkspaceSession(structuredClone(window))
|
||||
await expect(
|
||||
store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY })
|
||||
).resolves.toBe(true)
|
||||
return { directory, store, window }
|
||||
}
|
||||
|
||||
/** What main holds for the slept pane, and what the window would show from its slice. */
|
||||
function heldForPane(store: Store) {
|
||||
const session = store.getWorkspaceSession()
|
||||
const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, WORKTREE)
|
||||
return {
|
||||
record: session.sleepingAgentSessionsByPaneKey?.[PANE_KEY],
|
||||
sliceRecord: slice.sleeping[PANE_KEY],
|
||||
tabs: slice.tabs.map((tab) => tab.id),
|
||||
binding: slice.layouts[TAB]?.ptyIdsByLeafId?.[LEAF]
|
||||
}
|
||||
}
|
||||
|
||||
function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | undefined {
|
||||
return held
|
||||
? buildAgentResumeStartupPlan({
|
||||
agent: held.agent,
|
||||
providerSession: held.providerSession,
|
||||
cmdOverrides: {},
|
||||
platform: 'linux'
|
||||
})?.launchCommand
|
||||
: undefined
|
||||
}
|
||||
|
||||
describe('sleep → quit → resume', () => {
|
||||
it('keeps the quit capture over the periodic one through quit and relaunch', async () => {
|
||||
const { directory, store, window } = await storeWithAgentPane()
|
||||
// The agent is slept; the periodic save carries the first capture.
|
||||
window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) }
|
||||
store.setWorkspaceSession(structuredClone(window))
|
||||
// Quit: the window's quit capture rides only on the synchronous before-unload stage.
|
||||
window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) }
|
||||
store.stageWorkspaceSessionBeforeUnload(structuredClone(window))
|
||||
|
||||
const relaunched = await reopenTopologyStore(store, directory)
|
||||
try {
|
||||
const expected = {
|
||||
record: record('quit', 2),
|
||||
sliceRecord: record('quit', 2),
|
||||
tabs: [TAB],
|
||||
binding: PTY
|
||||
}
|
||||
expect(heldForPane(relaunched)).toEqual(expected)
|
||||
// The relaunched window's first save is its hydrated copy; the record stays.
|
||||
relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession()))
|
||||
expect(heldForPane(relaunched)).toEqual(expected)
|
||||
expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'")
|
||||
} finally {
|
||||
await relaunched.freezeWritesAsync()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a record that only the before-unload stage carried', async () => {
|
||||
const { directory, store, window } = await storeWithAgentPane()
|
||||
window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) }
|
||||
store.stageWorkspaceSessionBeforeUnload(structuredClone(window))
|
||||
|
||||
const relaunched = await reopenTopologyStore(store, directory)
|
||||
try {
|
||||
expect(heldForPane(relaunched)).toEqual({
|
||||
record: record('quit', 3),
|
||||
sliceRecord: record('quit', 3),
|
||||
tabs: [TAB],
|
||||
binding: PTY
|
||||
})
|
||||
expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-3'")
|
||||
} finally {
|
||||
await relaunched.freezeWritesAsync()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,529 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mulberry32 } from '../../../shared/agent-tui-ansi-fuzz-stream'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection'
|
||||
import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target'
|
||||
import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants'
|
||||
import { closeLeafOrTab } from './terminal-topology-commit'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_FOLDER_WORKTREE_ID,
|
||||
FIXTURE_GIT_WORKTREE_ID,
|
||||
openTopologyStore,
|
||||
reopenTopologyStore
|
||||
} from './terminal-topology-profile-fixture'
|
||||
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
/**
|
||||
* Model test for terminal layout as main holds it. Seeded gesture sequences run against main's
|
||||
* real Store, written the way today's desktop window writes them; a plain reference model says
|
||||
* what main must hold after each one. Making the window a mirror of main changes who writes, not
|
||||
* this outcome, so every seed must stay green through that refactor.
|
||||
* Replay one seed: FUZZ_SEED=<n> FUZZ_ITERATIONS=1 pnpm test <this file>.
|
||||
*/
|
||||
|
||||
const WORKTREES = [
|
||||
FIXTURE_GIT_WORKTREE_ID,
|
||||
FIXTURE_FOLDER_WORKTREE_ID,
|
||||
FLOATING_TERMINAL_WORKTREE_ID
|
||||
]
|
||||
const FIRST_SEED = Number(process.env.FUZZ_SEED ?? 0x9417)
|
||||
const ITERATIONS = Number(process.env.FUZZ_ITERATIONS ?? 6)
|
||||
const OPS_PER_SEED = 40
|
||||
|
||||
type ModelPane = { ptyId: string | undefined; sleeping: boolean }
|
||||
/** What main must hold: tab → leaf → binding, per worktree, in tab order. */
|
||||
type Model = Map<string, { worktreeId: string; leaves: Map<string, ModelPane> }>
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
function makeIds(random: () => number) {
|
||||
const hex = (length: number): string =>
|
||||
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join('')
|
||||
return {
|
||||
leaf: () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`,
|
||||
tab: () => `tab-${hex(8)}`,
|
||||
pty: (worktreeId: string) => `${worktreeId}@@${hex(8)}`
|
||||
}
|
||||
}
|
||||
|
||||
function leafIds(node: TerminalPaneLayoutNode | null | undefined): string[] {
|
||||
if (!node) {
|
||||
return []
|
||||
}
|
||||
return node.type === 'leaf' ? [node.leafId] : [...leafIds(node.first), ...leafIds(node.second)]
|
||||
}
|
||||
|
||||
function withoutLeaf(node: TerminalPaneLayoutNode, leafId: string): TerminalPaneLayoutNode | null {
|
||||
if (node.type === 'leaf') {
|
||||
return node.leafId === leafId ? null : node
|
||||
}
|
||||
const first = withoutLeaf(node.first, leafId)
|
||||
const second = withoutLeaf(node.second, leafId)
|
||||
if (!first || !second) {
|
||||
return first ?? second
|
||||
}
|
||||
return { ...node, first, second }
|
||||
}
|
||||
|
||||
function sleepingRecord(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string
|
||||
): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: `${tabId}:${leafId}`,
|
||||
tabId,
|
||||
worktreeId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: `session-${leafId}` },
|
||||
prompt: 'model',
|
||||
state: 'waiting',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'quit'
|
||||
}
|
||||
}
|
||||
|
||||
/** The desktop window's copy of the session: it authors tabs and layouts and saves them whole. */
|
||||
class WindowSession {
|
||||
constructor(public session: WorkspaceSessionState) {}
|
||||
|
||||
addTab(worktreeId: string, tabId: string, leafId: string): void {
|
||||
const tabs = this.session.tabsByWorktree[worktreeId] ?? []
|
||||
const tab: TerminalTab = {
|
||||
id: tabId,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: `Terminal ${tabs.length + 1}`,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: tabs.length,
|
||||
createdAt: 1
|
||||
}
|
||||
this.session.tabsByWorktree = { ...this.session.tabsByWorktree, [worktreeId]: [...tabs, tab] }
|
||||
this.setLayout(tabId, { type: 'leaf', leafId }, {})
|
||||
}
|
||||
|
||||
setLayout(tabId: string, root: TerminalPaneLayoutNode, ptyIdsByLeafId: Record<string, string>) {
|
||||
this.session.terminalLayoutsByTabId = {
|
||||
...this.session.terminalLayoutsByTabId,
|
||||
[tabId]: {
|
||||
root,
|
||||
activeLeafId: leafIds(root)[0] ?? null,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bind(tabId: string, leafId: string, ptyId: string): void {
|
||||
const layout = this.session.terminalLayoutsByTabId[tabId]
|
||||
if (layout?.root) {
|
||||
this.setLayout(tabId, layout.root, { ...layout.ptyIdsByLeafId, [leafId]: ptyId })
|
||||
}
|
||||
this.setTabPtyId(tabId, ptyId)
|
||||
}
|
||||
|
||||
/** `clearTabPtyId` on exit: the tab's live id goes; the layout keeps the binding as a resume hint. */
|
||||
setTabPtyId(tabId: string, ptyId: string | null): void {
|
||||
this.session.tabsByWorktree = Object.fromEntries(
|
||||
Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
tabs.map((tab) => (tab.id === tabId ? { ...tab, ptyId } : tab))
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
removeTab(tabId: string): void {
|
||||
this.session.tabsByWorktree = Object.fromEntries(
|
||||
Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [
|
||||
worktreeId,
|
||||
tabs.filter((tab) => tab.id !== tabId)
|
||||
])
|
||||
)
|
||||
const { [tabId]: _removed, ...layouts } = this.session.terminalLayoutsByTabId
|
||||
void _removed
|
||||
this.session.terminalLayoutsByTabId = layouts
|
||||
this.setSleeping(
|
||||
Object.fromEntries(
|
||||
Object.entries(this.session.sleepingAgentSessionsByPaneKey ?? {}).filter(
|
||||
([paneKey]) => !paneKey.startsWith(`${tabId}:`)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
setSleeping(records: Record<string, SleepingAgentSessionRecord>): void {
|
||||
this.session.sleepingAgentSessionsByPaneKey = records
|
||||
}
|
||||
|
||||
snapshot(): WorkspaceSessionState {
|
||||
return structuredClone(this.session)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fails on a terminal bound to two leaves or a leaf in two tabs, naming both owners. */
|
||||
function expectOwnerInvariants(session: WorkspaceSessionState, context: string): void {
|
||||
const owners = collectTerminalLeafOwners({ hostId: LOCAL_EXECUTION_HOST_ID, session })
|
||||
const describe = (owner: (typeof owners)[number]): string =>
|
||||
`${owner.worktreeId} ${owner.tab.id}:${owner.leafId} → ${owner.ptyId ?? 'unbound'}`
|
||||
const breaches: string[] = []
|
||||
for (const [index, left] of owners.entries()) {
|
||||
for (const right of owners.slice(index + 1)) {
|
||||
if (left.leafId === right.leafId && left.tab.id !== right.tab.id) {
|
||||
breaches.push(`leaf in two tabs: ${describe(left)} | ${describe(right)}`)
|
||||
} else if (left.leafId !== right.leafId && isSameTerminal(left, right)) {
|
||||
breaches.push(`terminal on two leaves: ${describe(left)} | ${describe(right)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(breaches, context).toEqual([])
|
||||
}
|
||||
|
||||
/** Main's projected slices, reduced to what the model states. */
|
||||
function projectedTopology(session: WorkspaceSessionState) {
|
||||
return Object.fromEntries(
|
||||
WORKTREES.map((worktreeId) => {
|
||||
const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, worktreeId)
|
||||
const tabs = slice.tabs.map((tab) => {
|
||||
const layout = slice.layouts[tab.id]
|
||||
return {
|
||||
tabId: tab.id,
|
||||
leaves: Object.fromEntries(
|
||||
leafIds(layout?.root).map((leafId) => [leafId, layout?.ptyIdsByLeafId?.[leafId]])
|
||||
)
|
||||
}
|
||||
})
|
||||
return [worktreeId, { tabs, sleeping: Object.keys(slice.sleeping).sort() }]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function expectedTopology(model: Model) {
|
||||
return Object.fromEntries(
|
||||
WORKTREES.map((worktreeId) => {
|
||||
const entries = [...model.entries()].filter(([, tab]) => tab.worktreeId === worktreeId)
|
||||
const tabs = entries.map(([tabId, tab]) => ({
|
||||
tabId,
|
||||
leaves: Object.fromEntries([...tab.leaves].map(([leafId, pane]) => [leafId, pane.ptyId]))
|
||||
}))
|
||||
const sleeping = entries
|
||||
.flatMap(([tabId, tab]) =>
|
||||
[...tab.leaves]
|
||||
.filter(([, pane]) => pane.sleeping)
|
||||
.map(([leafId]) => `${tabId}:${leafId}`)
|
||||
)
|
||||
.sort()
|
||||
return [worktreeId, { tabs, sleeping }]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
type Op =
|
||||
| 'new_tab'
|
||||
| 'split'
|
||||
| 'close_pane'
|
||||
| 'close_tab'
|
||||
| 'move_pane'
|
||||
| 'pty_exit'
|
||||
| 'respawn'
|
||||
| 'sleep'
|
||||
| 'wake'
|
||||
| 'restart'
|
||||
| 'quit_restart'
|
||||
|
||||
const OPS: readonly Op[] = [
|
||||
'new_tab',
|
||||
'new_tab',
|
||||
'split',
|
||||
'split',
|
||||
'close_pane',
|
||||
'close_tab',
|
||||
'move_pane',
|
||||
'pty_exit',
|
||||
'respawn',
|
||||
'sleep',
|
||||
'wake',
|
||||
'restart',
|
||||
'quit_restart'
|
||||
]
|
||||
|
||||
async function runSeed(seed: number): Promise<string[]> {
|
||||
const random = mulberry32(seed)
|
||||
const pick = <T>(items: readonly T[]): T | undefined =>
|
||||
items.length === 0 ? undefined : items[Math.floor(random() * items.length)]
|
||||
const ids = makeIds(random)
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-topology-model-'))
|
||||
directories.push(directory)
|
||||
let store = await openTopologyStore(directory, emptyTerminalSessionProfile())
|
||||
const window = new WindowSession(structuredClone(store.getWorkspaceSession()))
|
||||
const model: Model = new Map()
|
||||
const log: string[] = []
|
||||
const save = (): void => store.setWorkspaceSession(window.snapshot())
|
||||
const panes = () =>
|
||||
[...model].flatMap(([tabId, tab]) =>
|
||||
[...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane }))
|
||||
)
|
||||
const splitTabs = () => [...model].filter(([, tab]) => tab.leaves.size > 1)
|
||||
|
||||
const bind = async (worktreeId: string, tabId: string, leafId: string, ptyId: string) => {
|
||||
await expect(store.persistPtyBinding({ worktreeId, tabId, leafId, ptyId })).resolves.toBe(true)
|
||||
window.bind(tabId, leafId, ptyId)
|
||||
model.get(tabId)!.leaves.set(leafId, { ptyId, sleeping: false })
|
||||
}
|
||||
|
||||
/** `closeTerminalSurfaceFromRenderer`: the window removed the surface; main commits the close. */
|
||||
const commitClose = async (worktreeId: string, target: TerminalSurfaceCloseTarget) => {
|
||||
const refusal = await store.runDurableMutation(
|
||||
closeLeafOrTab({
|
||||
worktreeId,
|
||||
target,
|
||||
options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason: 'user' },
|
||||
requestedSession: store.getWorkspaceSession(),
|
||||
ownerMatches: () => true,
|
||||
hostId: () => LOCAL_EXECUTION_HOST_ID,
|
||||
getSession: (hostId) => store.getWorkspaceSession(hostId),
|
||||
setSession: (session, hostId) => store.setWorkspaceSession(session, hostId),
|
||||
onClosed: () => {}
|
||||
})
|
||||
)
|
||||
expect(refusal).toBeUndefined()
|
||||
}
|
||||
|
||||
const reopen = async (stageQuit: boolean): Promise<void> => {
|
||||
if (stageQuit) {
|
||||
store.stageWorkspaceSessionBeforeUnload(window.snapshot())
|
||||
}
|
||||
store = await reopenTopologyStore(store, directory)
|
||||
window.session = structuredClone(store.getWorkspaceSession())
|
||||
}
|
||||
|
||||
for (let step = 0; step < OPS_PER_SEED; step++) {
|
||||
const op = pick(OPS)!
|
||||
switch (op) {
|
||||
case 'new_tab': {
|
||||
const worktreeId = pick(WORKTREES)!
|
||||
const tabId = ids.tab()
|
||||
const leafId = ids.leaf()
|
||||
const ptyId = ids.pty(worktreeId)
|
||||
// pty:spawn can beat the window's debounced layout save.
|
||||
const spawnFirst = random() < 0.5
|
||||
log.push(
|
||||
`${op} ${worktreeId} ${tabId}:${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}`
|
||||
)
|
||||
model.set(tabId, { worktreeId, leaves: new Map() })
|
||||
window.addTab(worktreeId, tabId, leafId)
|
||||
if (!spawnFirst) {
|
||||
save()
|
||||
}
|
||||
await bind(worktreeId, tabId, leafId, ptyId)
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'split': {
|
||||
const target = pick(panes())
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
const leafId = ids.leaf()
|
||||
const ptyId = ids.pty(target.tab.worktreeId)
|
||||
const spawnFirst = random() < 0.5
|
||||
const direction = random() < 0.5 ? 'vertical' : 'horizontal'
|
||||
log.push(
|
||||
`${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}`
|
||||
)
|
||||
const layout = window.session.terminalLayoutsByTabId[target.tabId]!
|
||||
window.setLayout(
|
||||
target.tabId,
|
||||
{
|
||||
type: 'split',
|
||||
direction,
|
||||
first: layout.root!,
|
||||
second: { type: 'leaf', leafId }
|
||||
},
|
||||
layout.ptyIdsByLeafId ?? {}
|
||||
)
|
||||
if (!spawnFirst) {
|
||||
save()
|
||||
}
|
||||
await bind(target.tab.worktreeId, target.tabId, leafId, ptyId)
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'close_pane': {
|
||||
const [tabId, tab] = pick(splitTabs()) ?? []
|
||||
const leafId = tab && pick([...tab.leaves.keys()])
|
||||
if (!tabId || !tab || !leafId) {
|
||||
continue
|
||||
}
|
||||
log.push(`${op} ${tabId}:${leafId}`)
|
||||
const layout = window.session.terminalLayoutsByTabId[tabId]!
|
||||
const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {}
|
||||
void _closed
|
||||
window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings)
|
||||
const { [`${tabId}:${leafId}`]: _record, ...sleeping } =
|
||||
window.session.sleepingAgentSessionsByPaneKey ?? {}
|
||||
void _record
|
||||
window.setSleeping(sleeping)
|
||||
tab.leaves.delete(leafId)
|
||||
await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId })
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'close_tab': {
|
||||
const tabId = pick([...model.keys()])
|
||||
if (!tabId) {
|
||||
continue
|
||||
}
|
||||
log.push(`${op} ${tabId}`)
|
||||
const { worktreeId } = model.get(tabId)!
|
||||
window.removeTab(tabId)
|
||||
model.delete(tabId)
|
||||
await commitClose(worktreeId, { kind: 'tab', tabId })
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'move_pane': {
|
||||
const [sourceTabId, source] = pick(splitTabs()) ?? []
|
||||
const leafId = source && pick([...source.leaves.keys()])
|
||||
if (!sourceTabId || !source || !leafId) {
|
||||
continue
|
||||
}
|
||||
const targetTabId = ids.tab()
|
||||
const pane = source.leaves.get(leafId)!
|
||||
log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`)
|
||||
await expect(
|
||||
store.moveTerminalLeafToNewTab({
|
||||
worktreeId: source.worktreeId,
|
||||
sourceTabId,
|
||||
targetTabId,
|
||||
leafId,
|
||||
ptyId: pane.ptyId ?? null
|
||||
})
|
||||
).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null })
|
||||
// The window then shows what main moved, as the drag-out does today.
|
||||
const layout = window.session.terminalLayoutsByTabId[sourceTabId]!
|
||||
const { [leafId]: movedPty, ...bindings } = layout.ptyIdsByLeafId ?? {}
|
||||
window.setLayout(sourceTabId, withoutLeaf(layout.root!, leafId)!, bindings)
|
||||
window.addTab(source.worktreeId, targetTabId, leafId)
|
||||
if (movedPty) {
|
||||
window.bind(targetTabId, leafId, movedPty)
|
||||
}
|
||||
const records = { ...window.session.sleepingAgentSessionsByPaneKey }
|
||||
const record = records[`${sourceTabId}:${leafId}`]
|
||||
if (record) {
|
||||
delete records[`${sourceTabId}:${leafId}`]
|
||||
records[`${targetTabId}:${leafId}`] = {
|
||||
...record,
|
||||
paneKey: `${targetTabId}:${leafId}`,
|
||||
tabId: targetTabId
|
||||
}
|
||||
window.setSleeping(records)
|
||||
}
|
||||
source.leaves.delete(leafId)
|
||||
model.set(targetTabId, {
|
||||
worktreeId: source.worktreeId,
|
||||
leaves: new Map([[leafId, pane]])
|
||||
})
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'pty_exit': {
|
||||
const target = pick(panes().filter(({ pane }) => pane.ptyId))
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
log.push(`${op} ${target.tabId}:${target.leafId}`)
|
||||
// Main keeps an exited pane's binding; only the window's live id goes.
|
||||
window.setTabPtyId(target.tabId, null)
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'respawn': {
|
||||
const target = pick(panes())
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
const ptyId = ids.pty(target.tab.worktreeId)
|
||||
log.push(`${op} ${target.tabId}:${target.leafId} ${ptyId}`)
|
||||
const sleeping = target.pane.sleeping
|
||||
await bind(target.tab.worktreeId, target.tabId, target.leafId, ptyId)
|
||||
target.tab.leaves.get(target.leafId)!.sleeping = sleeping
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'sleep':
|
||||
case 'wake': {
|
||||
const target = pick(panes().filter(({ pane }) => pane.sleeping === (op === 'wake')))
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
log.push(`${op} ${target.tabId}:${target.leafId}`)
|
||||
const paneKey = `${target.tabId}:${target.leafId}`
|
||||
const records = { ...window.session.sleepingAgentSessionsByPaneKey }
|
||||
if (op === 'sleep') {
|
||||
records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId)
|
||||
} else {
|
||||
delete records[paneKey]
|
||||
}
|
||||
window.setSleeping(records)
|
||||
target.pane.sleeping = op === 'sleep'
|
||||
save()
|
||||
break
|
||||
}
|
||||
case 'restart':
|
||||
case 'quit_restart': {
|
||||
log.push(op)
|
||||
await reopen(op === 'quit_restart')
|
||||
break
|
||||
}
|
||||
}
|
||||
const context = `seed ${seed}, step ${step}:\n${log.join('\n')}`
|
||||
const session = store.getWorkspaceSession()
|
||||
expectOwnerInvariants(session, context)
|
||||
expect(projectedTopology(session), context).toEqual(expectedTopology(model))
|
||||
}
|
||||
|
||||
// The saved profile, read back cold, holds the same layout.
|
||||
await reopen(false)
|
||||
const context = `seed ${seed}, after reopen:\n${log.join('\n')}`
|
||||
expectOwnerInvariants(store.getWorkspaceSession(), context)
|
||||
expect(projectedTopology(store.getWorkspaceSession()), context).toEqual(expectedTopology(model))
|
||||
await store.freezeWritesAsync()
|
||||
return log
|
||||
}
|
||||
|
||||
describe('terminal layout model (main session)', () => {
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps one tab per pane, one pane per terminal, and the expected layout across random gestures', async () => {
|
||||
const ranOps = new Set<string>()
|
||||
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
|
||||
const log = await runSeed(FIRST_SEED + iteration)
|
||||
for (const entry of log) {
|
||||
ranOps.add(entry.split(' ')[0]!)
|
||||
}
|
||||
}
|
||||
// Precondition: the seeds reached every gesture, so a green run is not a quiet one.
|
||||
if (ITERATIONS >= 6) {
|
||||
expect([...ranOps].sort()).toEqual([...new Set(OPS)].sort())
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { join } from 'node:path'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture'
|
||||
import { Store } from '../loading-store/store'
|
||||
import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture'
|
||||
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
|
||||
|
||||
/** A worktree of the fixture's registered repo, so load keeps its rows. */
|
||||
export const FIXTURE_GIT_WORKTREE_ID = 'repo-local::/fixture/local'
|
||||
export const FIXTURE_FOLDER_WORKTREE_ID = 'repo-local::/fixture/local::workspace:folder-1'
|
||||
|
||||
/** The cutover fixture profile with no terminal session yet. */
|
||||
export function emptyTerminalSessionProfile(): string {
|
||||
const state = buildProfileStateCutoverFixture()
|
||||
return JSON.stringify({
|
||||
...state,
|
||||
// A fixed install id: load mints a random one when it is missing.
|
||||
settings: {
|
||||
...state.settings,
|
||||
telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' }
|
||||
},
|
||||
workspaceSession: getDefaultWorkspaceSession(),
|
||||
workspaceSessionsByHostId: {}
|
||||
})
|
||||
}
|
||||
|
||||
/** Opens a real Store on SQLite in `directory`, seeding it first when `seed` is given. */
|
||||
export async function openTopologyStore(directory: string, seed?: string): Promise<Store> {
|
||||
const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology')
|
||||
if (seed) {
|
||||
inner.writeSerializedState(Buffer.from(seed))
|
||||
}
|
||||
const store = new Store({
|
||||
dataFile: join(directory, 'orca-data.json'),
|
||||
profileStateAuthority: new DelayedAuthority(inner)
|
||||
})
|
||||
await store.flushPendingOrThrowAsync()
|
||||
return store
|
||||
}
|
||||
|
||||
/** Quit and relaunch: drain pending writes, close, and load the profile cold. */
|
||||
export async function reopenTopologyStore(store: Store, directory: string): Promise<Store> {
|
||||
await store.flushPendingOrThrowAsync()
|
||||
await store.freezeWritesAsync()
|
||||
return openTopologyStore(directory)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store'
|
||||
import { useAppStore } from '@/store'
|
||||
import { registerTerminalPresentationIpcBridge } from '@/hooks/ipc-events/terminal-presentation-ipc-bridge'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
|
||||
import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
|
||||
import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state'
|
||||
|
||||
/**
|
||||
* STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a
|
||||
* split of the (unmounted) primary tab, then the first activation of that worktree. When main's
|
||||
* split reveal does not reach the window, the activation sweep mints a second tab for the setup
|
||||
* PTY, so two saved panes own one terminal and one of them freezes.
|
||||
*/
|
||||
|
||||
const initialState = useAppStore.getState()
|
||||
const TAB_B = 'b3f1c1de-5e0f-4a51-9d0c-7a8e2a9f6b11'
|
||||
const PRIMARY_LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const SETUP_LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
type CreateTerminalListener = Parameters<Window['api']['ui']['onCreateTerminal']>[0]
|
||||
|
||||
function baseState(): Partial<AppState> {
|
||||
const worktree = makeWorktree()
|
||||
return {
|
||||
repos: [
|
||||
{
|
||||
id: worktree.repoId,
|
||||
path: path.join(path.sep, 'workspace', 'repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { [worktree.repoId]: [worktree] },
|
||||
activeRepoId: worktree.repoId,
|
||||
activeView: 'terminal',
|
||||
workspaceSessionReady: true,
|
||||
terminalStartupRestorationReady: true,
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
automaticAgentResumeClaimsByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
settings: {
|
||||
...getDefaultSettings(path.join(path.sep, 'home')),
|
||||
agentCmdOverrides: {},
|
||||
defaultTuiAgent: 'codex',
|
||||
setupScriptLaunchMode: 'split-vertical'
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's census: both panes are unmounted, so it reports them unowned with their record. */
|
||||
function orphanRow(ptyId: string, leafId: string): RuntimeTerminalSummary {
|
||||
const worktree = makeWorktree()
|
||||
return {
|
||||
handle: `handle-${leafId}`,
|
||||
ptyId,
|
||||
orphaned: true,
|
||||
recordedPaneKey: `${TAB_B}:${leafId}`,
|
||||
worktreeId: worktree.id,
|
||||
worktreePath: worktree.path,
|
||||
branch: worktree.branch ?? 'main',
|
||||
tabId: `pty:${ptyId}`,
|
||||
leafId: `pty:${ptyId}`,
|
||||
title: 'Terminal',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: ''
|
||||
}
|
||||
}
|
||||
|
||||
function stubHost(
|
||||
primaryPtyId: string,
|
||||
setupPtyId: string
|
||||
): { reveal: () => CreateTerminalListener } {
|
||||
const worktree = makeWorktree()
|
||||
let listener: CreateTerminalListener | undefined
|
||||
const runtimeCall = vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === 'session.tabs.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: worktree.id,
|
||||
publicationEpoch: 'sta-9417',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.list') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
terminals: [orphanRow(primaryPtyId, PRIMARY_LEAF), orphanRow(setupPtyId, SETUP_LEAF)],
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected runtime method: ${method}`)
|
||||
})
|
||||
const listSessions = vi.fn(async () =>
|
||||
[primaryPtyId, setupPtyId].map((id) => ({
|
||||
id,
|
||||
cwd: worktree.path,
|
||||
title: 'Terminal',
|
||||
agentOwnership: 'absent' as const
|
||||
}))
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtime: { call: runtimeCall },
|
||||
pty: { listSessions },
|
||||
ui: {
|
||||
onCreateTerminal: (next: CreateTerminalListener) => {
|
||||
listener = next
|
||||
return () => {}
|
||||
},
|
||||
onRequestTerminalTabMount: () => () => {},
|
||||
replyTerminalCreate: vi.fn()
|
||||
}
|
||||
},
|
||||
dispatchEvent: vi.fn()
|
||||
})
|
||||
return {
|
||||
reveal: () => {
|
||||
if (!listener) {
|
||||
throw new Error('presentation bridge did not register')
|
||||
}
|
||||
return listener
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Main's background reveal of the primary terminal: the tab exists here but never mounted. */
|
||||
function seedRevealedPrimaryTab(worktreeId: string, primaryPtyId: string): void {
|
||||
useAppStore.getState().createTab(worktreeId, undefined, undefined, {
|
||||
id: TAB_B,
|
||||
initialLeafId: PRIMARY_LEAF,
|
||||
initialPtyId: primaryPtyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
}
|
||||
|
||||
function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] {
|
||||
if (!node) {
|
||||
return []
|
||||
}
|
||||
return node.type === 'leaf'
|
||||
? [node.leafId]
|
||||
: [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)]
|
||||
}
|
||||
|
||||
function setupSplitReveal(
|
||||
requestId: string,
|
||||
worktreeId: string,
|
||||
setupPtyId: string
|
||||
): Parameters<CreateTerminalListener>[0] {
|
||||
return {
|
||||
requestId,
|
||||
worktreeId,
|
||||
ptyId: setupPtyId,
|
||||
activate: false,
|
||||
surfaceOwner: false,
|
||||
tabId: TAB_B,
|
||||
leafId: SETUP_LEAF,
|
||||
splitFromLeafId: PRIMARY_LEAF,
|
||||
splitDirection: 'vertical'
|
||||
}
|
||||
}
|
||||
|
||||
/** ptyId → every tab:leaf in the worktree that holds it. */
|
||||
function ownersByPty(worktreeId: string): Map<string, string[]> {
|
||||
const state = useAppStore.getState()
|
||||
const owners = new Map<string, string[]>()
|
||||
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
for (const leafId of leafIdsInOrder(layout?.root ?? null)) {
|
||||
const ptyId = layout?.ptyIdsByLeafId?.[leafId]
|
||||
if (ptyId) {
|
||||
owners.set(ptyId, [...(owners.get(ptyId) ?? []), `${tab.id}:${leafId}`])
|
||||
}
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
/** The sidebar click, then the first-activation sweep the terminal watcher runs (STA-9417 step 2). */
|
||||
async function activateFirstTime(worktreeId: string): Promise<void> {
|
||||
activateAndRevealWorktree(worktreeId)
|
||||
await expect(gateWorktreeAgentActivation(worktreeId)).resolves.toBe('adopted')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
describe('STA-9417: setup split of an unmounted tab, then first activation', () => {
|
||||
async function runScenario(revealSplit: boolean): Promise<void> {
|
||||
const worktree = makeWorktree()
|
||||
const primaryPtyId = `${worktree.id}@@primary`
|
||||
const setupPtyId = `${worktree.id}@@setup`
|
||||
useAppStore.setState(baseState())
|
||||
const host = stubHost(primaryPtyId, setupPtyId)
|
||||
registerTerminalPresentationIpcBridge([])
|
||||
seedRevealedPrimaryTab(worktree.id, primaryPtyId)
|
||||
|
||||
if (revealSplit) {
|
||||
host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId))
|
||||
}
|
||||
await activateFirstTime(worktree.id)
|
||||
|
||||
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs.map((tab) => tab.id)).toEqual([TAB_B])
|
||||
expect(Object.fromEntries(ownersByPty(worktree.id))).toEqual({
|
||||
[primaryPtyId]: [`${TAB_B}:${PRIMARY_LEAF}`],
|
||||
[setupPtyId]: [`${TAB_B}:${SETUP_LEAF}`]
|
||||
})
|
||||
}
|
||||
|
||||
it('ends with one tab and one owner per terminal when main reveals the split', async () => {
|
||||
await runScenario(true)
|
||||
})
|
||||
|
||||
// STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs.
|
||||
it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => {
|
||||
await runScenario(false)
|
||||
})
|
||||
|
||||
// STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate.
|
||||
it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => {
|
||||
let duplicates = 0
|
||||
for (let run = 0; run < 8; run++) {
|
||||
const worktree = makeWorktree()
|
||||
const primaryPtyId = `${worktree.id}@@primary-${run}`
|
||||
const setupPtyId = `${worktree.id}@@setup-${run}`
|
||||
useAppStore.setState(initialState, true)
|
||||
useAppStore.setState(baseState())
|
||||
const host = stubHost(primaryPtyId, setupPtyId)
|
||||
registerTerminalPresentationIpcBridge([])
|
||||
seedRevealedPrimaryTab(worktree.id, primaryPtyId)
|
||||
// Odd runs lose the reveal, as the natural repro did.
|
||||
if (run % 2 === 0) {
|
||||
host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId))
|
||||
}
|
||||
await activateFirstTime(worktree.id)
|
||||
duplicates += [...ownersByPty(worktree.id).values()].filter(
|
||||
(owners) => owners.length > 1
|
||||
).length
|
||||
duplicates += (useAppStore.getState().tabsByWorktree[worktree.id] ?? []).length - 1
|
||||
}
|
||||
expect(duplicates).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { createTestStore, makeTab, makeWorktree } from '../slices/store-test-helpers'
|
||||
import { createStoreSessionMockApi } from '../slices/store-session-test-harness'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
createStoreSessionMockApi()
|
||||
|
||||
// "Is this pane running" reads the liveness map; an exit leaves the pane's layout binding in place
|
||||
// for its one stale reattach. The mirror refactor keeps this split: liveness stays in the window,
|
||||
// the binding becomes main's.
|
||||
|
||||
const WORKTREE = 'repo1::/path/wt1'
|
||||
const LEFT = '11111111-1111-4111-8111-111111111111'
|
||||
const RIGHT = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function splitLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEFT },
|
||||
second: { type: 'leaf', leafId: RIGHT }
|
||||
},
|
||||
activeLeafId: LEFT,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' }
|
||||
}
|
||||
}
|
||||
|
||||
function storeWithSplitTab() {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt1', hostId: 'local' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[WORKTREE]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE, ptyId: 'pty-right' })]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': splitLayout() }
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
describe('exited pane in a split', () => {
|
||||
it('drops only the exited PTY from liveness and keeps every layout binding', () => {
|
||||
const store = storeWithSplitTab()
|
||||
|
||||
// Ctrl-D in the left pane.
|
||||
store.getState().clearTabPtyId('tab-1', 'pty-left')
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-right'])
|
||||
expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(true)
|
||||
expect(state.terminalLayoutsByTabId['tab-1']).toEqual(splitLayout())
|
||||
})
|
||||
|
||||
it('reads the tab as not running once both panes exit, with the bindings still saved', () => {
|
||||
const store = storeWithSplitTab()
|
||||
|
||||
store.getState().clearTabPtyId('tab-1', 'pty-left')
|
||||
store.getState().clearTabPtyId('tab-1', 'pty-right')
|
||||
|
||||
const state = store.getState()
|
||||
expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(false)
|
||||
expect(state.tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBeNull()
|
||||
expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({
|
||||
[LEFT]: 'pty-left',
|
||||
[RIGHT]: 'pty-right'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user