test: shared switch-on fixtures so 71 layout tests pass with main owning the layout (#31076)

* test: shared switch-on fixtures so 71 layout-owner test files run with the switch on

- layoutApplyTestTransport answers layout.apply (accept, apply to main's model with its frame, pending, refuse)
- main's loaded layout as snapshot frames, shared from the frame-sync test
- tab-layout maps for the editor store harnesses; missing store methods in the main harnesses
- workspaceRepos/registerWorkspaceRepos so main's load keeps seeded layouts
- ORCA_LAYOUT_OWNER=main runs vitest with the switch on
- the 4 rollback-build tests stay switch-off, labelled

* test: keep switch-off assertions exact, settle only after layout commands, reset the transport per test

* test: parse the transport's command without Reflect.get; name the staged-session type

* test: pin the test transport in the layout.apply census; route the autosave fixture as unit support

---------

Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-20 06:50:11 -04:00
committed by GitHub
co-authored by E2E Test
parent 94e55a2f3d
commit 4e998861e1
75 changed files with 1045 additions and 170 deletions
@@ -23,7 +23,9 @@ const unitHarnesses = [
]
const supportBridges = new Set([
'src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts',
'src/renderer/src/components/editor/editor-model-lifetime-fixture.ts'
'src/renderer/src/components/editor/editor-model-lifetime-fixture.ts',
// Autosave suites' store rig; it takes the editor harness's tab-layout maps.
'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts'
])
function productConsumers(file, graph, unitFiles) {
+4 -1
View File
@@ -1,3 +1,4 @@
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { defaultExclude, defineConfig } from 'vitest/config'
import { UNIT_INCLUDE, UNIT_EXCLUDE } from './scripts/ci-unit-files.mjs'
@@ -16,7 +17,9 @@ const transforms = {
define: {
ORCA_FEATURE_WALL_ENABLED: 'true',
// Why: tests only interpolate it; they must not depend on the checkout's HEAD.
ORCA_BUILD_COMMIT: JSON.stringify('0'.repeat(40))
ORCA_BUILD_COMMIT: JSON.stringify('0'.repeat(40)),
// A dark switch set to 'main' in the env (`ORCA_LAYOUT_OWNER=main pnpm vitest`) is on in tests.
...createRequire(import.meta.url)('./dark-build-switches.cjs').turnedOnDarkBuildDefines()
},
resolve: {
alias: [
@@ -24,6 +24,9 @@ vi.mock('electron', () => ({
}
}))
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import {
registerRendererShutdownCheckpointHandler,
SHUTDOWN_CHECKPOINT_FLUSH_DEADLINE_MS
@@ -31,6 +34,11 @@ import {
const AWAIT_CHANNEL = 'app:await-before-unload-checkpoint'
/** What is staged for a window's save: the save itself, or main's merge of it when main owns the
* layout (its content is covered by renderer-shutdown-checkpoint-sleep-records.test.ts). */
const windowSave = (state: Partial<WorkspaceSessionState>): unknown =>
ORCA_LAYOUT_OWNER_IS_MAIN ? expect.any(Object) : state
describe('registerRendererShutdownCheckpointHandler', () => {
beforeEach(() => {
syncHandlers.clear()
@@ -41,6 +49,8 @@ describe('registerRendererShutdownCheckpointHandler', () => {
it('stages every shutdown mutation before queueing persistence', () => {
const callOrder: string[] = []
const store = {
// Read by the switch-on staging, which keeps main's own layout.
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
stageWorkspaceSessionBeforeUnload: vi.fn((_state, hostId?: string) => {
callOrder.push(`session:${hostId ?? 'local'}`)
}),
@@ -64,12 +74,12 @@ describe('registerRendererShutdownCheckpointHandler', () => {
expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith(
1,
localSession,
windowSave(localSession),
undefined
)
expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith(
2,
remoteSession,
windowSave(remoteSession),
'runtime:host-1'
)
expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' })
@@ -84,6 +94,7 @@ describe('registerRendererShutdownCheckpointHandler', () => {
it('never stages the source partition of a fenced host', () => {
const store = {
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
stageWorkspaceSessionBeforeUnload: vi.fn(),
getSshTarget: vi.fn(() => ({ orcadFence: { environmentId: 'env-1' } })),
updateUI: vi.fn(),
@@ -98,7 +109,7 @@ describe('registerRendererShutdownCheckpointHandler', () => {
})
expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledTimes(1)
expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledWith({}, undefined)
expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenCalledWith(windowSave({}), undefined)
expect(event.returnValue).toEqual({ ok: true })
})
+3
View File
@@ -25,6 +25,7 @@ vi.mock('electron', () => ({
}
}))
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { registerSessionHandlers } from './session'
describe('registerSessionHandlers', () => {
@@ -33,6 +34,8 @@ describe('registerSessionHandlers', () => {
getSshTarget: vi.fn((id: string) =>
id === 'fenced' ? { orcadFence: { environmentId: 'env-1' } } : {}
),
// Read by the switch-on save, which keeps main's own layout.
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
setWorkspaceSession: vi.fn(),
patchWorkspaceSession: vi.fn(),
flushPendingOrThrowAsync: vi.fn(() => Promise.resolve())
@@ -47,6 +47,7 @@ import {
hostWorktreeTarget,
type HostWorktreeTarget
} from '../../shared/worktree/host-qualified-identity'
import { getDefaultWorkspaceSession } from '../../shared/constants'
const path = '/remote/repository'
const rows = [{ path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }]
@@ -68,7 +69,11 @@ function fixture(fields: Pick<Repo, 'connectionId' | 'executionHostId'>) {
getAllWorktreeLineage: () => ({}),
getAllWorkspaceLineage: () => ({}),
removeWorktreeLineage: vi.fn(),
captureNativeLocalWorktreeMetadataScanExpectation: () => undefined
captureNativeLocalWorktreeMetadataScanExpectation: () => undefined,
// Read by main's switch-on layout purge after a listing.
getWorkspaceSessionHostIds: () => ['local'],
getWorkspaceSession: () => getDefaultWorkspaceSession(),
removeWorkspaceSessionStateForWorktree: vi.fn()
}
const context = {
store,
+5
View File
@@ -10,6 +10,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { createWorktreeRuntimeStub, type WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
import { handlers, mainWindow, store } from './worktrees-test-ipc-surface'
import { configureMetadataPruningStoreMocks } from './worktrees-test-metadata-pruning-store'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import {
ORIGINAL_PLATFORM,
setPlatform,
@@ -143,6 +144,8 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
store.removeWorktreeMeta,
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo,
store.removeWorkspaceSessionStateForWorktree,
store.getWorkspaceSessionHostIds,
store.getWorkspaceSession,
store.getAllWorktreeLineage,
store.removeWorktreeLineage,
store.getAllWorkspaceLineage,
@@ -220,6 +223,8 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
updatedAt: 0
}
])
store.getWorkspaceSessionHostIds.mockReturnValue(['local'])
store.getWorkspaceSession.mockReturnValue(getDefaultWorkspaceSession())
store.getAllWorktreeLineage.mockReturnValue({})
store.getAllWorkspaceLineage.mockReturnValue({})
store.getFolderWorkspaces.mockReturnValue([])
@@ -37,6 +37,9 @@ export type TestStore = {
removeWorktreeMeta: TargetStoreMock
pruneSessionlessMissingLocalWorktreeMetadataForRepo: StoreMock
removeWorkspaceSessionStateForWorktree: KeyedStoreMock
/** Read by main's switch-on layout purge after a listing. */
getWorkspaceSessionHostIds: StoreMock
getWorkspaceSession: StoreMock
getAllWorktreeLineage: StoreMock
removeWorktreeLineage: TargetStoreMock
getAllWorkspaceLineage: StoreMock
@@ -72,6 +75,8 @@ export const store: TestStore = {
removeWorktreeMeta: vi.fn(),
pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(),
removeWorkspaceSessionStateForWorktree: vi.fn(),
getWorkspaceSessionHostIds: vi.fn(),
getWorkspaceSession: vi.fn(),
getAllWorktreeLineage: vi.fn(),
removeWorktreeLineage: vi.fn(),
getAllWorkspaceLineage: vi.fn(),
@@ -1,9 +1,10 @@
import {
closeTestStores,
testState,
createStore,
writeDataFile,
readDataFile
readDataFile,
testState,
workspaceRepos,
writeDataFile
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { writeFileSync, rmSync, mkdtempSync, mkdirSync } from 'node:fs'
@@ -77,7 +78,7 @@ describe('Store', () => {
mkdirSync(agentHooksDir, { recursive: true })
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -189,7 +190,7 @@ describe('Store', () => {
mkdirSync(agentHooksDir, { recursive: true })
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -300,7 +301,7 @@ describe('Store', () => {
mkdirSync(agentHooksDir, { recursive: true })
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -396,7 +397,7 @@ describe('Store', () => {
const ownerPaneKey = makePaneKey('tab-target', TEST_LEAF_2)
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -432,7 +433,7 @@ describe('Store', () => {
it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -522,7 +523,7 @@ describe('Store', () => {
const leafCount = 130_000
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -578,7 +579,7 @@ describe('Store', () => {
const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1)
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -1,9 +1,11 @@
import {
closeTestStores,
testState,
createStore,
writeDataFile,
makeTerminalTab
makeTerminalTab,
registerWorkspaceRepos,
testState,
workspaceRepos,
writeDataFile
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
@@ -68,7 +70,7 @@ describe('Store', () => {
it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -320,6 +322,7 @@ describe('Store', () => {
it('reconciles only the incarnation of an unchanged durable PTY binding', async () => {
const store = await createStore()
registerWorkspaceRepos(store, 'wt1')
const paneKey = `tab1:${TEST_LEAF_1}`
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
@@ -1,8 +1,9 @@
import {
closeTestStores,
testState,
createStore,
makeTerminalTab
makeTerminalTab,
registerWorkspaceRepos,
testState
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
@@ -290,6 +291,7 @@ describe('Store', () => {
'preserves a reconciled incarnation across a renderer snapshot ($label)',
async ({ hostId }) => {
const store = await createStore()
registerWorkspaceRepos(store, 'wt1')
const paneKey = `tab1:${TEST_LEAF_1}`
store.setWorkspaceSession(
{
+18
View File
@@ -5,6 +5,7 @@ import type { Project, ProjectHostSetup } from '../shared/project-types'
import type { Repo } from '../shared/repo-types'
import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../shared/worktree/id'
import type { PersistedState } from '../shared/persisted-state-types'
import { getDefaultPersistedState } from '../shared/constants'
import type { SshRemotePtyLease } from '../shared/ssh-types'
@@ -165,6 +166,23 @@ export const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({
...overrides
})
/**
* The repos of the given workspaces, as a real profile registers them: main's load drops a stored
* layout whose repo is not registered.
*/
export function workspaceRepos(...worktreeIds: string[]): Repo[] {
return [...new Set(worktreeIds.map(getRepoIdFromWorktreeId))].map((id) => makeRepo({ id }))
}
/** Registers the repos of the given workspaces in a running store; see `workspaceRepos`. */
export function registerWorkspaceRepos(store: Store, ...worktreeIds: string[]): void {
for (const repo of workspaceRepos(...worktreeIds)) {
if (!store.getRepo(repo.id)) {
store.addRepo(repo)
}
}
}
export const makeProject = (overrides: Partial<Project> = {}): Project => ({
id: 'project-1',
displayName: 'Project',
@@ -9,13 +9,14 @@ import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser
import {
closeTestStores,
createSqliteTestStore,
readPersistedStateJson,
testState,
createStore,
dataFile,
writeDataFile,
makeRepo,
makeTerminalTab
makeTerminalTab,
readPersistedStateJson,
testState,
workspaceRepos,
writeDataFile
} from './persistence-test-harness'
import {
TEST_LEAF_1,
@@ -457,7 +458,7 @@ describe('Store', () => {
it('remaps legacy SSH lease leaf ids when loading legacy workspace layouts', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -519,7 +520,7 @@ describe('Store', () => {
mkdirSync(agentHooksDir, { recursive: true })
writeDataFile({
schemaVersion: 1,
repos: [],
repos: workspaceRepos('wt1'),
worktreeMeta: {},
settings: {},
ui: {},
@@ -1,8 +1,9 @@
import {
closeTestStores,
testState,
createStore,
makeTerminalTab
makeTerminalTab,
registerWorkspaceRepos,
testState
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
@@ -68,6 +69,7 @@ describe('Store', () => {
})
it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => {
const store = await createStore()
registerWorkspaceRepos(store, 'wt1')
store.setWorkspaceSession({
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
@@ -8,6 +8,9 @@ import { fixture } from './profile-state-delayed-authority-fixture'
import type * as AgreementModule from '../terminal-topology/terminal-pane-placement-agreement'
import type { PersistPtyBindingArgs } from './pty-binding-persistence'
// rollback build (switch-off) — delete with the switch-off code post-flip
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false }))
const agreementCheck = vi.hoisted(() => ({ throws: false }))
vi.mock('../terminal-topology/terminal-pane-placement-agreement', async (importOriginal) => {
const actual = await importOriginal<typeof AgreementModule>()
@@ -14,6 +14,9 @@ import {
import { WindowSession, withoutLeaf } from './terminal-topology-window-session-fixture'
import { checkWorkspaceLayoutRules } from '../../../shared/workspace-layout/workspace-layout-rules'
// rollback build (switch-off) — delete with the switch-off code post-flip
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false }))
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/config/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { withChatTabChanges, type ChatTabChange } from './agent-session-tab-layout'
// rollback build (switch-off) — delete with the switch-off code post-flip
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false }))
const WT = 'repo-1::/tmp/worktree-a'
const EMPTY: WorkspaceSessionState = {
@@ -181,8 +181,13 @@ describe('a saved browser tab on a host with no window', () => {
page: 'page-new'
})
const saved = getSession().browserTabsByWorktree?.[TEST_WORKTREE_ID] ?? []
expect(saved.map((tab) => [tab.id, tab.url])).toEqual([['page-new', 'https://example.com/new']])
// A switch-on build saves the page once main's layout write lands.
await vi.waitFor(() => {
const saved = getSession().browserTabsByWorktree?.[TEST_WORKTREE_ID] ?? []
expect(saved.map((tab) => [tab.id, tab.url])).toEqual([
['page-new', 'https://example.com/new']
])
})
// Which tab shows is a view's choice; opening one records none.
expect(getSession().activeBrowserTabIdByWorktree?.[TEST_WORKTREE_ID]).toBeUndefined()
})
@@ -6,6 +6,7 @@ import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { OrcaRuntimeService } from './orca-runtime'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
const REPO_ID = 'repo-1'
const WORKTREE_ID = `${REPO_ID}::/workspace`
@@ -222,6 +223,9 @@ function createHarness(
}
}
},
/** The split pane's terminal starting, as its window reports it. */
registerSplitPty: (leafId: string) =>
runtime.registerPty(SPLIT_PTY_ID, WORKTREE_ID, connectionId, { tabId: TAB_ID, leafId }),
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })
}
@@ -232,6 +236,8 @@ describe('remote runtime terminal split authority', () => {
const harness = createHarness(true, { rendererMounted: true, graphOnlySource: true })
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
// A switch-on build tells the window once main has admitted the new pane.
await vi.waitFor(() => expect(harness.rendererSplitTerminal).toHaveBeenCalled())
const newLeafId = harness.rendererSplitTerminal.mock.calls[0]?.[2]?.newLeafId
expect(newLeafId).toEqual(expect.any(String))
@@ -246,6 +252,10 @@ describe('remote runtime terminal split authority', () => {
telemetrySource: undefined,
newLeafId
})
// A switch-on build binds the new pane only once its terminal registered, before the graph.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
harness.registerSplitPty(newLeafId)
}
harness.runtime.syncWindowGraph(1, {
tabs: [
{
@@ -9,6 +9,9 @@ import {
} from '../../shared/agent-prompt-injection'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types'
import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission'
import { resolveHostId } from '../persistence/loading-store/session-host-partitions'
import { toComparableRelaySshPtyId } from '../../shared/ssh-pty-id'
import { spawnedPtyEnv } from './spawned-pty-env-fixtures'
import {
@@ -589,6 +592,16 @@ function makeRuntimeStoreWithWorkspaceSession(
getWorkspaceSession: (hostId?: string) =>
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
setWorkspaceSession: vi.fn(setSession),
// Main's `layout.apply` on this partition, as the Store applies it (switch-on writes).
applyLayoutCommand: vi.fn(async (command: LayoutCommand, hostIds?: readonly string[]) => {
const change = applyCommandToSession(
resolveHostId(hostIds?.[0] ?? ownerHostId),
session,
command
)
session = change.session ?? session
return change.value
}),
// Headless close is a durable transaction; keep the in-memory fixture's
// persistence contract equivalent to the production store.
flushOrThrow: vi.fn(),
@@ -21,6 +21,7 @@ import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger'
import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { resetStructuredAgentSessionClosesBeforeCreateForTests } from './structured-agent-session-close-before-create'
export const SESSION = 'session-alpha'
export const FINGERPRINT = 'f'.repeat(64)
@@ -210,9 +211,9 @@ export function hostStub(): StructuredAgentSessionHost {
subscribeTurnCompletions: vi.fn(() => () => undefined),
unsubscribe: vi.fn()
})
const store = { getOperationRow: vi.fn(() => null), getRecord: vi.fn(() => null) }
// Not a call: the logger the host hands a runtime caller that reports for it.
const logger = recordingStructuredAgentSessionLogger().logger
Reflect.set(hostCalls, 'deps', { logger, store: { getOperationRow: vi.fn(() => null) } })
Reflect.set(hostCalls, 'deps', { logger: recordingStructuredAgentSessionLogger().logger, store })
return hostCalls as unknown as StructuredAgentSessionHost
}
@@ -322,6 +323,8 @@ export const STRUCTURED_MOBILE_CLIENT = {
/** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */
export function installStructuredHostStub(): void {
// A switch-on close remembers the session, refusing its later create; each test starts clean.
resetStructuredAgentSessionClosesBeforeCreateForTests()
setStructuredAgentSessionHost(hostStub())
}
@@ -6,6 +6,7 @@ import { createStore, type StoreApi } from 'zustand/vanilla'
import { createEditorSlice } from '@/store/slices/editor'
import type { AppState } from '@/store'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import { emptyTabLayoutState } from '@/store/slices/editor-slice-test-harness'
export type FakeEditorDisk = {
files: Map<string, string>
@@ -71,8 +72,9 @@ export function stubEditorWindow(): ReturnType<typeof vi.fn> {
}
export function createEditorStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// oxlint-disable-next-line typescript/no-explicit-any, typescript/consistent-type-assertions -- SAFETY: a partial store; tests read only the slices it builds.
return createStore<any>()((...args: any[]) => ({
...emptyTabLayoutState(),
activeWorktreeId: 'wt-1',
repos: [],
worktreesByRepo: {
@@ -16,6 +16,7 @@ import {
} from '@/runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { emptyTabLayoutState } from '../../store/slices/editor-slice-test-harness'
const mocks = vi.hoisted(() => ({
getConnectionIdForFile: vi.fn()
@@ -42,8 +43,9 @@ type WindowStub = {
}
function createEditorStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// oxlint-disable-next-line typescript/no-explicit-any, typescript/consistent-type-assertions -- SAFETY: a partial store; tests read only the slices it builds.
return createStore<any>()((...args: any[]) => ({
...emptyTabLayoutState(),
activeWorktreeId: 'wt-1',
settings: {
editorAutoSave: true,
@@ -6,12 +6,14 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-save-events'
import { attachEditorAutosaveController } from './editor-autosave-controller'
import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry'
import { emptyTabLayoutState } from '../../store/slices/editor-slice-test-harness'
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
function createEditorStore(): StoreApi<AppState> {
// oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-explicit-any -- SAFETY: the editor save path reads only the slice and fields built here.
return createStore<any>()((...args: any[]) => ({
...emptyTabLayoutState(),
settings: { editorAutoSave: false },
repos: [],
worktreesByRepo: {
@@ -71,8 +73,10 @@ describe('saving a restored tab', () => {
store
.getState()
.openFile({ filePath, relativePath, worktreeId, language: 'markdown', mode: 'edit' })
store.getState().setEditorDraft(filePath, 'edited')
store.getState().markFileDirty(filePath, true)
// The file's own id: its path, or an owned id when main owns the layout.
const fileId = store.getState().openFiles.find((file) => file.filePath === filePath)!.id
store.getState().setEditorDraft(fileId, 'edited')
store.getState().markFileDirty(fileId, true)
const detach = attachEditorAutosaveController(store)
try {
await saveDirtyFiles()
@@ -9,6 +9,14 @@ import type { Tab, TabGroup } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { useTabDragSplit, type TabDragItemData } from '../tab-group/useTabDragSplit'
import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('../browser-pane/host-guest/webview-registry', () => ({
acquireWebviewsDragPassthrough: vi.fn(() => vi.fn())
@@ -145,23 +153,27 @@ afterEach(() => {
})
describe('floating workspace tab reorder', () => {
it('moves a tab after the hovered tab when released on its right half', () => {
it('moves a tab after the hovered tab when released on its right half', async () => {
dropTab('tab-a', 'tab-c', 290)
await layoutApplyTestTransport.settle()
expect(floatingTabOrder()).toEqual(['tab-b', 'tab-c', 'tab-a'])
})
it('moves a tab before the hovered tab when released on its left half', () => {
it('moves a tab before the hovered tab when released on its left half', async () => {
dropTab('tab-c', 'tab-a', 210)
await layoutApplyTestTransport.settle()
expect(floatingTabOrder()).toEqual(['tab-c', 'tab-a', 'tab-b'])
})
it('leaves the order alone when a tab is dropped on itself', () => {
it('leaves the order alone when a tab is dropped on itself', async () => {
dropTab('tab-b', 'tab-b', 290)
await layoutApplyTestTransport.settle()
expect(floatingTabOrder()).toEqual(['tab-a', 'tab-b', 'tab-c'])
})
it('republishes sortOrder so the panel renders the committed order', () => {
it('republishes sortOrder so the panel renders the committed order', async () => {
dropTab('tab-a', 'tab-c', 290)
await layoutApplyTestTransport.settle()
const tabs = useAppStore.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
expect(tabs.map((tab) => [tab.id, tab.sortOrder])).toEqual([
['tab-a', 2],
@@ -5,6 +5,14 @@ import {
stubShallowSelector,
stubTooltip
} from './tab-bar-windows-shell-launch-render-stubs'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const appStoreSnapshot: {
activeTabId: string | null
@@ -452,6 +460,7 @@ describe('TabBar context menu wiring', () => {
expect(sortable[0].props.isPinned).toBe(true)
;(sortable[0].props.onTogglePin as () => void)()
await layoutApplyTestTransport.settle()
expect(unpinTabMock).toHaveBeenCalledWith('unified-term-1')
expect(pinTabMock).not.toHaveBeenCalled()
@@ -13,13 +13,19 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true
const setTabGroupSplitRatioMock = vi.fn()
const recordFeatureInteractionMock = vi.fn()
vi.mock('../../store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
recordFeatureInteraction: recordFeatureInteractionMock,
setTabGroupSplitRatio: setTabGroupSplitRatioMock
})
}))
vi.mock('../../store', () => {
const state = (): Record<string, unknown> => ({
recordFeatureInteraction: recordFeatureInteractionMock,
setTabGroupSplitRatio: setTabGroupSplitRatioMock,
layoutByWorktree: {}
})
// The switch-on ratio commit reads the store directly.
const useAppStore = Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector(state()),
{ getState: state }
)
return { useAppStore }
})
vi.mock('./TabGroupPanel', () => ({
default: ({ groupId }: { groupId: string }) => <div data-testid={`panel-${groupId}`} />
@@ -29,6 +29,13 @@ import {
type WindowLaunchCase
} from '../../../../shared/launch-parity-window-request.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const holder = vi.hoisted((): LaunchStoreHolder => ({ store: null }))
vi.mock('@/store', async () =>
(await import('@/lib/launch-parity-renderer.test-fixture')).launchStoreModuleMock(holder)
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
import { flushAsyncTicks } from './pty-connection-test-async'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import {
LEAF_1,
LEAF_2,
@@ -259,6 +260,8 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
expect(onPtyExit).toBeTypeOf('function')
onPtyExit?.('pty-pane-2')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2')
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
@@ -760,6 +763,8 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
capturedDataCallback.current?.('shell prompt')
onPtyExit?.('pty-pane-2')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(manager.closePane).toHaveBeenCalledWith(2)
})
@@ -809,6 +814,8 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
terminalInputHandler?.('exit\r')
onPtyExit?.('pty-pane-2')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(manager.closePane).toHaveBeenCalledWith(2)
})
@@ -1,4 +1,4 @@
import { describe } from 'vitest'
import { describe, vi } from 'vitest'
import { registerPtyConnectionAgentSessionResumeTests } from './pty-connection-agent-session-resume.spec'
import { registerPtyConnectionColdRestoreAgentResumeTests } from './pty-connection-cold-restore-agent-resume.spec'
import { registerPtyConnectionColdRestoreRepaintTests } from './pty-connection-cold-restore-repaint.spec'
@@ -9,6 +9,13 @@ import { registerPtyConnectionRestartReplacementTests } from './pty-connection-r
import { registerPtyConnectionSessionLivenessTests } from './pty-connection-session-liveness.spec'
import { registerPtyConnectionSleepingResumeBannerTests } from './pty-connection-sleeping-resume-banner.spec'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
describe('pty-connection-agent-session-resume', registerPtyConnectionAgentSessionResumeTests)
describe(
'pty-connection-cold-restore-agent-resume',
@@ -1,4 +1,5 @@
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushAsyncTicks } from './pty-connection-test-async'
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
@@ -348,6 +349,8 @@ export function registerPtyConnectionSessionLivenessTests(): void {
// Restart rebinds the pane to a new live PTY; its later real exit must not be ignored by a stale guard.
transport.getPtyId.mockReturnValue('pty-pane-2-restarted')
onPtyExit?.('pty-pane-2-restarted')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
@@ -238,6 +238,7 @@ export type MockPaneManager = {
closePane: Mock
getActivePane: Mock<() => { id: number; leafId?: string } | null>
getNumericIdForLeaf: Mock<(leafId: string) => number | null>
getLeafId: Mock<(paneId: number) => string | null>
setActivePane: Mock<(paneId: number) => void>
}
@@ -265,6 +266,9 @@ export function createManager(
getNumericIdForLeaf: vi.fn((leafId: string) => {
return panes.find((candidate) => candidate.leafId === leafId)?.id ?? null
}),
getLeafId: vi.fn((paneId: number) => {
return panes.find((candidate) => candidate.id === paneId)?.leafId ?? null
}),
setActivePane: vi.fn((paneId: number) => {
activePaneId = paneId
})
@@ -1,4 +1,4 @@
import { describe } from 'vitest'
import { describe, vi } from 'vitest'
import { registerPtyConnectionVisibilityResumeSizeTests } from './pty-connection-visibility-resume-size.spec'
import { registerPtyConnectionHiddenCodexQueriesTests } from './pty-connection-hidden-codex-queries.spec'
import { registerPtyConnectionHiddenDeliveryGateTests } from './pty-connection-hidden-delivery-gate.spec'
@@ -6,6 +6,13 @@ import { registerPtyConnectionParkRevealModelSnapshotTests } from './pty-connect
import { registerPtyConnectionReplayPayloadHandlingTests } from './pty-connection-replay-payload-handling.spec'
import { registerPtyConnectionPtyExitTeardownTests } from './pty-connection-pty-exit-teardown.spec'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
describe('pty-connection-visibility-resume-size', registerPtyConnectionVisibilityResumeSizeTests)
describe('pty-connection-hidden-codex-queries', registerPtyConnectionHiddenCodexQueriesTests)
describe('pty-connection-hidden-delivery-gate', registerPtyConnectionHiddenDeliveryGateTests)
@@ -4,6 +4,13 @@ import type { AgentLaunchPaneVerdict } from '../../../shared/agent-launch-pane-v
import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness'
import { createTestStore } from '../store/slices/store-test-helpers'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => ({
...(await importOriginal<typeof AgentStatusModule>()),
@@ -19,6 +19,13 @@ import {
launchWorkspaceId
} from '../../../shared/launch-parity-window-request.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const holder = vi.hoisted((): LaunchStoreHolder => ({ store: null }))
vi.mock('@/store', async () =>
(await import('@/lib/launch-parity-renderer.test-fixture')).launchStoreModuleMock(holder)
@@ -5,6 +5,13 @@ import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shar
import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers'
import { createStoreCascadesMockApi } from '../store/slices/store-cascades-test-harness'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const storeBox = vi.hoisted(() => {
const box: { store: unknown } = { store: null }
return box
@@ -105,6 +105,8 @@ const store = {
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
unifiedTabsByWorktree: emptyUnifiedTabsByWorktree,
groupsByWorktree: {},
layoutByWorktree: {},
activeGroupIdByWorktree: {},
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
@@ -8,6 +8,13 @@ import { getDefaultSettings } from '../../../shared/constants'
import { WIN_PATH, WSL_PATH } from '../../../shared/launch-parity-window-cases.test-fixture'
import { launchWorkspaceId } from '../../../shared/launch-parity-window-request.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const holder = vi.hoisted((): LaunchStoreHolder => ({ store: null }))
vi.mock('@/store', async () =>
(await import('@/lib/launch-parity-renderer.test-fixture')).launchStoreModuleMock(holder)
@@ -1,11 +1,18 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
const PI_TRANSCRIPT_PATH = join(tmpdir(), 'pi-session-1.jsonl')
@@ -5,6 +5,13 @@ import { useAppStore } from '@/store'
import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
@@ -1,10 +1,17 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
@@ -5,6 +5,13 @@ import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
@@ -5,6 +5,13 @@ import { parseWorkspaceSession } from '../../../shared/workspace-session-schema'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
@@ -1,9 +1,16 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialState = useAppStore.getState()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const HOST_TAB_ID = 'web-terminal-host-tab'
@@ -2,6 +2,13 @@ import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialState = useAppStore.getState()
afterEach(() => {
@@ -4,12 +4,20 @@ 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 { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
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-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
/**
* Conditional old-host reconstruction, not proof of STA-9417: a host-created worktree with a
* split of the (unmounted) primary tab, then the first activation of that worktree. When main's
@@ -257,30 +265,33 @@ describe('old-host split of an unmounted tab, then first activation', () => {
await runScenario(false)
})
// The window alone, as above; 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))
// The window alone, as above; the odd runs mint the duplicate. Main owning the create fixes it.
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it : 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
}
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)
}
expect(duplicates).toBe(0)
})
)
it('rejects a split into a tab it lacks without switching to that workspace', () => {
const worktree = makeWorktree()
@@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import type { Tab, TabGroup } from '../../../shared/tab-types'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
import type * as LaunchAdmissionModule from './structured-agent-session-launch-admission'
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
type StoreState = {
unifiedTabsByWorktree: Record<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
layoutByWorktree: Record<string, TabGroupLayoutNode>
activeGroupIdByWorktree: Record<string, string>
nativeChatLaunchDraftByTabId: Record<string, { text: string; adopted?: boolean }>
}
@@ -35,6 +36,14 @@ function emptyStoreState(): StoreState {
return {
unifiedTabsByWorktree: {},
groupsByWorktree: { 'wt-new-chat': [group('group-left'), group('group-right')] },
layoutByWorktree: {
'wt-new-chat': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
}
},
activeGroupIdByWorktree: { 'wt-new-chat': 'group-left' },
nativeChatLaunchDraftByTabId: {}
}
@@ -9,6 +9,13 @@ import type { AppState } from '@/store/types'
import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
const WT = 'repo-1::/repo'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -12,6 +12,13 @@ import {
} from '@/lib/workspace-terminal-host-authority'
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
@@ -3,6 +3,13 @@ import type { Worktree } from '../../../shared/worktree/types'
import { useAppStore } from '@/store'
import { activateAndRevealWorkspace, activateAndRevealWorktree } from './worktree-activation'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
@@ -13,6 +13,13 @@ import {
} from '@/lib/worktree-activation-created-agent.test-fixture'
import { stubRuntimeEnvironmentBridge } from '../runtime/runtime-environment-bridge-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
function makeWebRuntimeWorktree() {
@@ -20,6 +20,13 @@ import {
registerWorktreeActivationReset
} from './worktree-activation.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const defaultChat = vi.hoisted(() => ({
open: vi.fn(),
awaitsDetection: vi.fn(),
@@ -15,6 +15,13 @@ import {
} from '@/lib/worktree-activation-created-agent.test-fixture'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
@@ -13,6 +13,13 @@ import {
createdTabStartups
} from './worktree-activation.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
registerWorktreeActivationReset()
@@ -1,4 +1,5 @@
import { getDefaultSettings } from '../../../shared/constants'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
@@ -11,6 +12,23 @@ import * as sleepingResume from './resume-sleeping-agent-session'
import { activateAndRevealWorktree } from './worktree-activation'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent.test-fixture'
import { emptySession } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture'
import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await importOriginal())
)
const initialState = useAppStore.getState()
@@ -163,6 +181,7 @@ afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
useAppStore.setState(initialState, true)
layoutApplyTestTransport.reset()
})
describe('worktree agent activation seam', () => {
@@ -212,15 +231,29 @@ describe('worktree agent activation seam', () => {
it('adopts a live terminal without spawning a fallback', async () => {
const worktree = makeWorktree()
const livePtyId = `${worktree.id}@@live-codex`
useAppStore.setState(baseState())
// Restored with no tabs: frames draw this workspace.
useAppStore.setState({ ...baseState(), unifiedTabsByWorktree: { [worktree.id]: [] } })
stubInventory({ livePtyId })
// A switch-on build shows the adopted terminal from main's frame.
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.useMainModel(loadMainLayoutModel('local', emptySession()))
const frames = openWorkspaceLayoutFrameSync(useAppStore)
frames.start()
try {
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
await waitForWorktreeAgentActivationGateForTests(worktree.id)
await layoutApplyTestTransport.settle()
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
await waitForWorktreeAgentActivationGateForTests(worktree.id)
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
expect(tabs[0]?.ptyId).toBe(livePtyId)
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
// Switch-on, the pane binds to the terminal main's frame names; the row keeps no ptyId.
const shownPtyId = ORCA_LAYOUT_OWNER_IS_MAIN
? frames.terminalTab(worktree.id, tabs[0]!.id)?.terminal?.panes[0]?.ptyId
: tabs[0]?.ptyId
expect(shownPtyId).toBe(livePtyId)
} finally {
frames.close()
}
})
it('spawns a fallback when the workspace has no agent', async () => {
@@ -25,6 +25,13 @@ import {
seedEmptyActivatableWorktree
} from './worktree-activation-created-agent.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initialAppStoreState = useAppStore.getState()
const worktree = makeCreatedAgentWorktree()
@@ -18,6 +18,13 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
import { useAppStore } from '@/store'
import { launchStructuredWorktreeSession } from './worktree-creation-structured-session'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const initial = useAppStore.getState()
function worktree(name: string, host: 'paired' | 'local' = 'paired'): Worktree {
@@ -13,6 +13,13 @@ import {
resetHostSessionMirrorHydrationForTests
} from '@/runtime/host-session-mirror-hydration'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
// Pins the activation contract behind the run6-review-pr-11959 incident shape:
// a persisted (husk) tab whose pane cannot resume in place gets ONE appended
// replacement tab per provider session — the husk is retained for scrollback —
@@ -7,6 +7,13 @@ import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agen
import { makePaneKey } from '../../../shared/stable-pane-id'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
// Red repro for the aug20 "windows 2" incident (restart-reattach/resume-relaunch):
// a runtime-owned (paired remote) worktree's web-mirror tab holds a sleeping
// record for an agent whose host PTY is STILL ALIVE, but at activation time the
@@ -149,6 +149,8 @@ describe('layout.apply senders', () => {
expect(layoutApplySenders(path.resolve(__dirname, '..'))).toEqual({
'runtime/generated-tab-titles-send.ts': 1,
'runtime/layout-apply-send.ts': 1,
// Tests' stand-in for main: it answers layout.apply, never sends it.
'runtime/layout-apply-test-transport.test-fixture.ts': 1,
'store/terminals/terminal-tab-create-through-layout-owner.ts': 2
})
})
@@ -0,0 +1,304 @@
// A stand-in for main's `layout.apply` and `layout.subscribe` in switch-on renderer tests. It answers
// each command the window sends (accept, apply to main's model, leave pending, or refuse) and, when
// applying, emits main's frame at the reply's stream position, as main does.
//
// Install it in a test file, before any import that reaches the RPC client:
// vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
// (await import('@/runtime/layout-apply-test-transport.test-fixture'))
// .runtimeRpcClientWithLayoutApply(await importOriginal()))
// and, for frames, `@/runtime/runtime-layout-client` with `runtimeLayoutClientWithTestFrames`.
// Imports stay clear of the RPC client at runtime: a mock factory must not reach what it replaces.
import { createBrowserUuid } from '@/lib/browser-uuid'
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
import type {
LayoutApplyReply,
LayoutCommand,
LayoutCommandResult
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model'
import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { layoutSnapshotFrame, publishedLayoutsOf } from './main-loaded-layout-frames.test-fixture'
import type * as LayoutClient from './runtime-layout-client'
import type * as RpcClient from './runtime-rpc-client'
type Refusal = Extract<LayoutApplyReply, { ok: false }>['code']
/**
* `accept`: ok, with a result naming the ids the command did; no frame (main's copy is not
* modelled). `apply`: main's model (`useMainModel`) applies it; the reply carries its real result
* and the position of the frame emitted with it. `pending`: no answer until `answerPending`.
*/
export type LayoutApplyTestAnswer =
| 'accept'
| 'apply'
| 'pending'
| { refuse: Refusal }
| ((command: LayoutCommand) => LayoutApplyReply | 'pending')
type FrameSink = {
onFrame: (frame: WorkspaceLayoutChangeFrame) => void
onPosition?: (seq: number) => void
}
type PendingApply = { command: LayoutCommand; resolve: (reply: LayoutApplyReply) => void }
type TransportState = {
answer: LayoutApplyTestAnswer
sent: LayoutCommand[]
pending: PendingApply[]
model: WorkspaceLayoutModel | null
seq: number
sinks: Set<FrameSink>
inFlight: Promise<LayoutApplyReply>[]
}
const fresh = (): TransportState => ({
answer: 'accept',
sent: [],
pending: [],
model: null,
seq: 0,
sinks: new Set(),
inFlight: []
})
let state = fresh()
/** What main's result names for a command it accepted, read off the command. */
function acceptedResult(command: LayoutCommand): LayoutCommandResult {
const result: LayoutCommandResult = {}
if ('tabId' in command && typeof command.tabId === 'string') {
result.tabId = command.tabId
}
if ('leafId' in command && typeof command.leafId === 'string') {
result.leafId = command.leafId
}
if ('groupId' in command && typeof command.groupId === 'string') {
result.groupId = command.groupId
}
if ('pageId' in command && typeof command.pageId === 'string') {
result.pageId = command.pageId
}
if (command.type === 'closeTabs') {
return { ...result, closed: [...command.tabIds], refused: [] }
}
if (command.type === 'sleep') {
return { ...result, slept: [...(command.paneKeys ?? [])] }
}
if (command.type === 'wake') {
return { ...result, woken: [...(command.paneKeys ?? [])] }
}
if (command.type === 'splitPane' && command.newLeafId) {
return { ...result, leafId: command.newLeafId }
}
return result
}
// Shape check only: the window's own sender built it.
function isLayoutCommand(value: unknown): value is LayoutCommand {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof value.type === 'string' &&
'workspace' in value &&
typeof value.workspace === 'string'
)
}
/** The command of a `layout.apply` request's params. */
function commandOf(params: unknown): unknown {
return typeof params === 'object' && params !== null && 'command' in params
? params.command
: undefined
}
function emit(frame: WorkspaceLayoutChangeFrame): void {
for (const sink of state.sinks) {
sink.onFrame(frame)
if (frame.seq !== undefined) {
sink.onPosition?.(frame.seq)
}
}
}
/** Main's apply without its routing: no host partitions, `host_not_owned`, forced closes, or a
* frame position on refusals. */
function applyToMainModel(command: LayoutCommand): LayoutApplyReply {
const model = state.model
if (!model) {
throw new Error('layoutApplyTestTransport: answer "apply" needs useMainModel first')
}
const applied = applyLayoutCommand(model, command, {
mintId: createBrowserUuid,
mintLeafId: createBrowserUuid,
now: () => Date.now()
})
if (!applied.ok) {
return applied
}
state.model = applied.model
state.seq += 1
const seq = state.seq
const key = command.workspace
const workspace = applied.model.workspaces[key]
// Main publishes before it replies.
emit(
workspace
? { type: 'workspace', key, layout: publishWorkspaceLayout(workspace, model.hostId), seq }
: { type: 'removed', key, seq }
)
return { ok: true, result: applied.result, frameSeq: seq }
}
/** The current answer to one command; `pending` when it waits for `answerPending`. */
function replyTo(command: LayoutCommand): LayoutApplyReply | 'pending' {
const answer = state.answer
if (typeof answer === 'function') {
return answer(command)
}
if (answer === 'accept') {
return { ok: true, result: acceptedResult(command) }
}
if (answer === 'apply') {
return applyToMainModel(command)
}
return answer === 'pending' ? 'pending' : { ok: false, code: answer.refuse }
}
function answerCommand(command: LayoutCommand): Promise<LayoutApplyReply> {
state.sent.push(command)
const reply = replyTo(command)
if (reply === 'pending') {
return new Promise((resolve) => state.pending.push({ command, resolve }))
}
const answered = Promise.resolve(reply)
state.inFlight.push(answered)
return answered
}
// layout-apply-send.ts's chain (reply, frame wait, handler) runs a few microtasks after the answer.
const REPLY_CHAIN_TURNS = 5
export const layoutApplyTestTransport = {
/** How later commands are answered; `accept` after each `reset`. */
answerWith(answer: LayoutApplyTestAnswer): void {
state.answer = answer
},
/** Main's model, which `apply` edits and frames publish. */
useMainModel(model: WorkspaceLayoutModel): void {
state.model = model
},
get mainModel(): WorkspaceLayoutModel | null {
return state.model
},
/** Every command sent, in order, with its workspace. */
get sent(): readonly LayoutCommand[] {
return state.sent
},
sentOfType<T extends LayoutCommand['type']>(type: T): Extract<LayoutCommand, { type: T }>[] {
return state.sent.filter(
(command): command is Extract<LayoutCommand, { type: T }> => command.type === type
)
},
get pendingCount(): number {
return state.pending.length
},
/** Answers every pending command: with `reply`, else as the current answer (`accept` when that
* is still `pending`). */
answerPending(reply?: LayoutApplyReply): void {
const pending = state.pending
state.pending = []
for (const { command, resolve } of pending) {
const next = replyTo(command)
const answer =
reply ?? (next === 'pending' ? { ok: true, result: acceptedResult(command) } : next)
state.inFlight.push(Promise.resolve(answer))
resolve(answer)
}
},
/** Resolves once every answered command's handler ran; real or fake timers alike. */
async settle(): Promise<void> {
while (state.inFlight.length > 0) {
const answered = state.inFlight
state.inFlight = []
await Promise.all(answered)
for (let turn = 0; turn < REPLY_CHAIN_TURNS; turn += 1) {
await Promise.resolve()
}
}
},
/** Sends a frame main made on its own (a CLI create, a load repair). */
emitFrame(frame: WorkspaceLayoutChangeFrame): void {
emit(frame)
},
/** Answers one `layout.apply` request's params; the RPC mock below routes here. */
call(params: unknown): Promise<LayoutApplyReply> {
const command = commandOf(params)
if (!isLayoutCommand(command)) {
return Promise.reject(new Error('layout.apply without a command'))
}
return answerCommand(command)
},
reset(): void {
state = fresh()
}
}
type RpcClientModule = typeof RpcClient
/** The RPC client module with local `layout.apply` answered by the transport. */
export function runtimeRpcClientWithLayoutApply(actual: RpcClientModule): RpcClientModule {
function callRuntimeRpc<TResult>(
target: RuntimeClientTarget,
method: string,
params?: unknown,
options?: Parameters<RpcClientModule['callRuntimeRpc']>[3]
): Promise<TResult> {
if (target.kind === 'local' && method === 'layout.apply') {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: callers of layout.apply type TResult as LayoutApplyReply.
return layoutApplyTestTransport.call(params) as Promise<TResult>
}
return actual.callRuntimeRpc<TResult>(target, method, params, options)
}
return { ...actual, callRuntimeRpc }
}
type LayoutClientModule = typeof LayoutClient
/** The layout client module with the local stream fed by the transport: main's model's snapshot
* first, then the frames `apply` and `emitFrame` send. */
export function runtimeLayoutClientWithTestFrames(actual: LayoutClientModule): LayoutClientModule {
const subscribeRuntimeLayout: LayoutClientModule['subscribeRuntimeLayout'] = (
target,
params,
onFrame,
onStatus,
onPosition
) => {
if (target.kind !== 'local') {
return actual.subscribeRuntimeLayout(target, params, onFrame, onStatus, onPosition)
}
const sink: FrameSink = { onFrame, onPosition }
state.sinks.add(sink)
onStatus({ state: 'connecting' })
queueMicrotask(() => {
if (!state.sinks.has(sink)) {
return
}
onStatus({ state: 'live' })
onFrame(layoutSnapshotFrame(state.model ? publishedLayoutsOf(state.model) : {}, state.seq))
onPosition?.(state.seq)
})
return {
close: () => {
state.sinks.delete(sink)
onStatus({ state: 'closed' })
}
}
}
return { ...actual, subscribeRuntimeLayout }
}
@@ -0,0 +1,53 @@
// Main's loaded layout as the frames its stream sends: what main publishes for a stored partition,
// so a switch-on test feeds the window the layout main holds instead of the window's own restore.
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model'
import {
publishWorkspaceLayout,
type PublishedWorkspaceLayout
} from '../../../shared/workspace-layout/workspace-layout-published'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
/** The model main loads from one stored partition, with the ids it mints at load. */
export function loadMainLayoutModel(
hostId: ExecutionHostId,
session: WorkspaceSessionState
): WorkspaceLayoutModel {
return loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext()).layout
}
/** What main publishes for each workspace of a model, by workspace key. */
export function publishedLayoutsOf(
model: WorkspaceLayoutModel
): Record<string, PublishedWorkspaceLayout> {
return Object.fromEntries(
Object.entries(model.workspaces).map(([key, workspace]) => [
key,
publishWorkspaceLayout(workspace, model.hostId)
])
)
}
/** What main publishes for one stored partition, by workspace key. */
export function mainLoadedLayouts(
hostId: ExecutionHostId,
session: WorkspaceSessionState
): Record<string, PublishedWorkspaceLayout> {
return publishedLayoutsOf(loadMainLayoutModel(hostId, session))
}
/** The snapshot frame a stream opens with. */
export function layoutSnapshotFrame(
layouts: Record<string, PublishedWorkspaceLayout>,
seq?: number
): WorkspaceLayoutChangeFrame {
return {
type: 'snapshot',
workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })),
...(seq === undefined ? {} : { seq })
}
}
@@ -224,12 +224,18 @@ function seedPairedClientStore(): TestStore {
},
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' }),
// The paired server owns this workspace, so its titles stay the window's to apply.
makeWorktree({
id: WT,
repoId: 'repo1',
path: '/path/wt1',
runtimeOwnerEnvironmentId: ENV
}),
makeWorktree({ id: LOCAL_WT, repoId: 'repo1', path: '/path/local-wt' })
]
},
activeWorktreeId: WT
} as Partial<AppState>)
})
return store
}
@@ -3,16 +3,10 @@ import type * as AgentStatusModule from '@/lib/agent-status'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture'
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
import {
localDesktopSession,
WORKSPACE_LAYOUT_PROFILES
} from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
import {
publishWorkspaceLayout,
type PublishedWorkspaceLayout
} from '../../../shared/workspace-layout/workspace-layout-published'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
import {
emptySession,
@@ -30,6 +24,7 @@ import {
} from './layout-frame-position'
import { holdTabPaneTree, requestTabPaneTreeRedraw } from './layout-owner-pane-intents'
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
import { layoutSnapshotFrame, mainLoadedLayouts } from './main-loaded-layout-frames.test-fixture'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
@@ -85,35 +80,14 @@ const SLICES: (keyof LayoutFrameSlices)[] = [
'activeTabTypeByWorktree'
]
/** What main publishes for one stored partition. */
function published(
hostId: ExecutionHostId,
session: WorkspaceSessionState
): Record<string, PublishedWorkspaceLayout> {
const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext())
return Object.fromEntries(
Object.entries(layout.workspaces).map(([key, workspace]) => [
key,
publishWorkspaceLayout(workspace, hostId)
])
)
}
function snapshot(layouts: Record<string, PublishedWorkspaceLayout>): WorkspaceLayoutChangeFrame {
return {
type: 'snapshot',
workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout }))
}
}
const flushMicrotasks = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
async function bootWithFrames(hostId: ExecutionHostId, session: WorkspaceSessionState) {
const store = createTestStore()
await bootWindowFromPartitions(store, [{ hostId, session }])
const sync = openWorkspaceLayoutFrameSync(store)
const layouts = published(hostId, session)
stream.onFrame!(snapshot(layouts))
const layouts = mainLoadedLayouts(hostId, session)
stream.onFrame!(layoutSnapshotFrame(layouts))
return { store, sync, layouts }
}
@@ -145,7 +119,7 @@ describe('the window drawn from layout frames', () => {
await bootWindowFromPartitions(store, [{ hostId: profile.hostId, session: profile.build() }])
const before = store.getState()
const sync = openWorkspaceLayoutFrameSync(store)
stream.onFrame!(snapshot(published(profile.hostId, profile.build())))
stream.onFrame!(layoutSnapshotFrame(mainLoadedLayouts(profile.hostId, profile.build())))
sync.start()
const after = store.getState()
expect(SLICES.filter((slice) => after[slice] !== before[slice])).toEqual([])
@@ -333,7 +307,7 @@ describe('the window drawn from layout frames', () => {
expect(layoutFramesFlowing()).toBe(true)
const renamed = structuredClone(layouts)
renamed[GIT_KEY]!.tabs.find((tab) => tab.id === 'tab-unbound')!.customTitle = 'from main'
stream.onFrame!(snapshot(renamed))
stream.onFrame!(layoutSnapshotFrame(renamed))
stream.onStatus!({ state: 'live' })
await flushMicrotasks()
@@ -412,8 +386,8 @@ describe('the window drawn from layout frames', () => {
const store = createTestStore()
await bootWindowFromPartitions(store, [{ hostId: 'local', session: emptySession() }])
const sync = openWorkspaceLayoutFrameSync(store)
const layouts = published('local', localDesktopSession())
stream.onFrame!(snapshot(layouts))
const layouts = mainLoadedLayouts('local', localDesktopSession())
stream.onFrame!(layoutSnapshotFrame(layouts))
sync.start()
await flushMicrotasks()
expect(store.getState().tabsByWorktree[GIT_KEY]).toBeUndefined()
@@ -434,9 +408,9 @@ describe('the window drawn from layout frames', () => {
await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }])
const sync = openWorkspaceLayoutFrameSync(store)
sync.start()
const layouts = published('local', localDesktopSession())
const layouts = mainLoadedLayouts('local', localDesktopSession())
layouts[GIT_KEY]!.tabs.find((tab) => tab.id === 'tab-shell')!.customTitle = 'from main'
stream.onFrame!(snapshot(layouts))
stream.onFrame!(layoutSnapshotFrame(layouts))
await flushMicrotasks()
expect(
store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell')!.customTitle
@@ -22,8 +22,16 @@ import {
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
import { createTestStore } from '../store/slices/store-test-helpers'
import type { AppState } from '../store/types'
import { layoutApplyTestTransport } from './layout-apply-test-transport.test-fixture'
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
@@ -228,6 +236,7 @@ describe('tab properties through main', () => {
// Today: the window acts on its own copy.
const today = await boot(stored)
entry.windowAction(today.getState())
await layoutApplyTestTransport.settle()
const todayTabs = shownTabs(today.getState())
expect(todayTabs).not.toEqual(shownTabs((await boot(stored)).getState()))
const expected = { ...todayTabs, ...entry.mainShows }
@@ -245,6 +254,7 @@ describe('tab properties through main', () => {
// Main's answer then runs the window's own action, which leaves what today's action shows.
entry.windowAction(drawn.getState())
await layoutApplyTestTransport.settle()
expect(shownTabs(drawn.getState())).toEqual(expected)
sync.close()
@@ -4,6 +4,14 @@ import { getDefaultSettings } from '../../../../shared/constants'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const WORKTREE_ID = 'repo1::/path/wt1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -26,7 +34,7 @@ describe('generated agent tab titles', () => {
vi.useRealTimers()
})
it('stays disabled by default when agent prompts arrive', () => {
it('stays disabled by default when agent prompts arrive', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, false)
@@ -36,12 +44,13 @@ describe('generated agent tab titles', () => {
prompt: 'Refactor the auth middleware',
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBeUndefined()
expect(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0].generatedLabel).toBeUndefined()
})
it('generates one stable title from the first known agent prompt when enabled', () => {
it('generates one stable title from the first known agent prompt when enabled', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -51,11 +60,13 @@ describe('generated agent tab titles', () => {
prompt: 'Can you please refactor the auth middleware to use JWT tokens?',
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
store.getState().setAgentStatus(makePaneKey(tabId, LEAF_ID), {
state: 'working',
prompt: 'Replace this with a later task name',
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Refactor the auth middleware to use JWT'
@@ -65,7 +76,7 @@ describe('generated agent tab titles', () => {
)
})
it('replaces a raw dispatch preamble title when orchestration display metadata arrives', () => {
it('replaces a raw dispatch preamble title when orchestration display metadata arrives', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -83,6 +94,7 @@ orca orchestration send --to term_parent
Implement the detailed worker instructions that should not be the short label`,
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Implement the detailed worker'
@@ -96,6 +108,7 @@ Implement the detailed worker instructions that should not be the short label`,
displayName: 'Better worker label'
}
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Better worker label'
@@ -105,7 +118,7 @@ Implement the detailed worker instructions that should not be the short label`,
)
})
it('does not replace with sticky orchestration when a new non-dispatch prompt arrives', () => {
it('does not replace with sticky orchestration when a new non-dispatch prompt arrives', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -124,6 +137,7 @@ Implement the detailed worker instructions that should not be the short label`
prompt: dispatchPrompt,
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
store.getState().setRuntimeAgentOrchestrationByPaneKey({
[paneKey]: {
taskId: 'task-1',
@@ -132,6 +146,7 @@ Implement the detailed worker instructions that should not be the short label`
displayName: 'Better worker label'
}
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Better worker label'
)
@@ -143,13 +158,14 @@ Implement the detailed worker instructions that should not be the short label`
prompt: 'Refactor the auth middleware to use JWT tokens for session recovery',
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Better worker label'
)
})
it('generates from a new non-dispatch prompt when sticky orchestration remains but no title exists', () => {
it('generates from a new non-dispatch prompt when sticky orchestration remains but no title exists', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -165,6 +181,7 @@ Your task ID is: task-1
Old dispatch task that already finished`,
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
store.getState().setRuntimeAgentOrchestrationByPaneKey({
[paneKey]: {
taskId: 'task-1',
@@ -173,6 +190,7 @@ Old dispatch task that already finished`,
displayName: 'Stale worker label'
}
})
await layoutApplyTestTransport.settle()
// Clear any title set during the dispatch turn so the next non-dispatch
// prompt is a pure first-write with sticky orchestration still present.
const tabs = store.getState().tabsByWorktree[WORKTREE_ID]
@@ -196,6 +214,7 @@ Old dispatch task that already finished`,
prompt: 'Can you please refactor the auth middleware to use JWT tokens?',
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Refactor the auth middleware to use JWT'
@@ -205,7 +224,7 @@ Old dispatch task that already finished`,
)
})
it('does not re-pin sticky task A labels onto a later dispatch task B preamble', () => {
it('does not re-pin sticky task A labels onto a later dispatch task B preamble', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -220,6 +239,7 @@ Your task ID is: task-a
Implement task A worker instructions that should not stick`,
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
store.getState().setRuntimeAgentOrchestrationByPaneKey({
[paneKey]: {
taskId: 'task-a',
@@ -228,6 +248,7 @@ Implement task A worker instructions that should not stick`,
displayName: 'Worker A label'
}
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe('Worker A label')
store.getState().setAgentStatus(paneKey, {
@@ -239,13 +260,14 @@ Your task ID is: task-b
Implement task B worker instructions for the next dispatch`,
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Implement task B worker instructions'
)
})
it('does not force-replace titles when sticky orchestration updates after a non-dispatch prompt', () => {
it('does not force-replace titles when sticky orchestration updates after a non-dispatch prompt', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -256,6 +278,7 @@ Implement task B worker instructions for the next dispatch`,
prompt: 'Can you please refactor the auth middleware to use JWT tokens?',
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Refactor the auth middleware to use JWT'
)
@@ -268,13 +291,14 @@ Implement task B worker instructions for the next dispatch`,
displayName: 'Stale orchestration label'
}
})
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Refactor the auth middleware to use JWT'
)
})
it('does not trim the full paste-sized prompt before generating an optional title', () => {
it('does not trim the full paste-sized prompt before generating an optional title', async () => {
vi.useFakeTimers()
const trimSpy = vi.spyOn(String.prototype, 'trim')
const store = createTestStore()
@@ -286,6 +310,7 @@ Implement task B worker instructions for the next dispatch`,
prompt,
agentType: 'codex'
})
await layoutApplyTestTransport.settle()
const tab = store.getState().tabsByWorktree[WORKTREE_ID][0]
expect(tab.generatedTitle).toBe('Fix the flaky status tests large pasted')
@@ -296,7 +321,7 @@ Implement task B worker instructions for the next dispatch`,
).toBe(false)
})
it('keeps manual rename precedence over generated and live titles', () => {
it('keeps manual rename precedence over generated and live titles', async () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
@@ -306,15 +331,18 @@ Implement task B worker instructions for the next dispatch`,
prompt: 'Fix the flaky status tests',
agentType: 'claude'
})
await layoutApplyTestTransport.settle()
store.getState().updateTabTitle(tabId, 'Claude working')
await layoutApplyTestTransport.settle()
store.getState().setTabCustomTitle(tabId, 'Status tests')
await layoutApplyTestTransport.settle()
const tab = store.getState().tabsByWorktree[WORKTREE_ID][0]
expect(resolveTerminalTabTitle(tab, true)).toBe('Status tests')
expect(tab.generatedTitle).toBe('Fix the flaky status tests')
})
it('does not generate a title for quick command labeled tabs', () => {
it('does not generate a title for quick command labeled tabs', async () => {
vi.useFakeTimers()
const store = createTestStore()
seedStore(store, {
@@ -335,6 +363,7 @@ Implement task B worker instructions for the next dispatch`,
prompt: 'Fix the flaky status tests',
agentType: 'claude'
})
await layoutApplyTestTransport.settle()
const tab = store.getState().tabsByWorktree[WORKTREE_ID][0]
expect(tab.generatedTitle).toBeUndefined()
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { createTestStore, makeWorktree } from './store-test-helpers'
const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
@@ -18,6 +19,11 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
// Main's answer to the window's layout commands, as a switch-on build sends them.
mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) =>
method === 'layout.apply' ? layoutApplyTestTransport.call(params) : undefined
)
const WORKTREE_ID = 'repo1::/path/wt1'
const DOC_LOCATION = {
kind: 'workspace-doc' as const,
@@ -8,6 +8,13 @@ import {
MANAGED_BROWSER_UNAVAILABLE_MESSAGE
} from '@/lib/client-creation-action-policy'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
const createWebRuntimeSessionTerminalMock = vi.hoisted(() => vi.fn())
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { createEditorStore, createEditorTabsStore } from './editor-slice-test-harness'
import type { AppState } from '../types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn()
@@ -644,14 +645,17 @@ describe('createEditorSlice openDiff', () => {
expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([
expect.objectContaining({
entityId: '/repo/a.ts',
isPinned: true,
isPreview: false
isPinned: true
}),
expect.objectContaining({
entityId: 'wt-1::diff::unstaged::b.ts',
isPreview: true
})
])
// Not a preview: a switch-on build omits the flag rather than writing false.
expect(store.getState().unifiedTabsByWorktree['wt-1']?.[0]?.isPreview).toBe(
ORCA_LAYOUT_OWNER_IS_MAIN ? undefined : false
)
})
it('makes a preview file permanent without pinning the tab', () => {
@@ -678,10 +682,13 @@ describe('createEditorSlice openDiff', () => {
expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([
expect.objectContaining({
entityId: '/repo/a.ts',
isPinned: undefined,
isPreview: false
isPinned: undefined
})
])
// Not a preview: a switch-on build omits the flag rather than writing false.
expect(store.getState().unifiedTabsByWorktree['wt-1']?.[0]?.isPreview).toBe(
ORCA_LAYOUT_OWNER_IS_MAIN ? undefined : false
)
})
it('does not replace a dirty file that was opened as a preview', () => {
@@ -714,13 +721,16 @@ describe('createEditorSlice openDiff', () => {
])
expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([
expect.objectContaining({
entityId: '/repo/a.ts',
isPreview: false
entityId: '/repo/a.ts'
}),
expect.objectContaining({
entityId: 'wt-1::diff::unstaged::b.ts',
isPreview: true
})
])
// Not a preview: a switch-on build omits the flag rather than writing false.
expect(store.getState().unifiedTabsByWorktree['wt-1']?.[0]?.isPreview).toBe(
ORCA_LAYOUT_OWNER_IS_MAIN ? undefined : false
)
})
})
@@ -4,12 +4,21 @@ import { createEditorSlice } from './editor'
import { createTabsSlice } from './tabs'
import type { AppState } from '../types'
/** The tab-bar maps switch-on editor paths read; a store without the tabs slice needs them too. */
export function emptyTabLayoutState(): Pick<
AppState,
'unifiedTabsByWorktree' | 'groupsByWorktree' | 'layoutByWorktree'
> {
return { unifiedTabsByWorktree: {}, groupsByWorktree: {}, layoutByWorktree: {} }
}
export function createEditorStore(): StoreApi<AppState> {
// Only the editor slice + activeWorktreeId are needed for these tests.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// oxlint-disable-next-line typescript/no-explicit-any, typescript/consistent-type-assertions -- SAFETY: a partial store; tests read only the slices it builds.
return createStore<any>()((...args: any[]) => ({
activeWorktreeId: 'wt-1',
tabsByWorktree: {},
...emptyTabLayoutState(),
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
@@ -1,8 +1,13 @@
import type { StoreApi } from 'zustand/vanilla'
import { describe, expect, it, vi } from 'vitest'
import { createEditorStore, createEditorTabsStore } from './editor-slice-test-harness'
import {
createEditorStore,
createEditorTabsStore,
ownedEditorFileId
} from './editor-slice-test-harness'
import type { AppState } from '../types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn()
@@ -20,6 +25,17 @@ vi.mock('@/runtime/close-mirrored-editor-tab', () => ({
notifyHostOfMirroredEditorCloseMock(...args)
}))
function fileIdOf(store: { getState: () => AppState }, filePath: string): string | undefined {
return store.getState().openFiles.find((file) => file.filePath === filePath)?.id
}
/** A floating file's id: its path, or an owned id when main owns the layout. */
function expectedFileId(filePath: string): unknown {
return ORCA_LAYOUT_OWNER_IS_MAIN
? ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null)
: filePath
}
describe('createEditorSlice floating editor activation', () => {
it('creates a visible floating editor tab when the floating workspace is empty', () => {
const store = createEditorTabsStore()
@@ -36,10 +52,12 @@ describe('createEditorSlice floating editor activation', () => {
{ suppressActiveRuntimeFallback: true }
)
const fileId = fileIdOf(store, '/tmp/orca/notes.md')
expect(fileId).toEqual(expectedFileId('/tmp/orca/notes.md'))
const tab = store.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]
expect(tab).toMatchObject({
contentType: 'editor',
entityId: '/tmp/orca/notes.md',
entityId: fileId,
label: 'notes.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
@@ -47,9 +65,7 @@ describe('createEditorSlice floating editor activation', () => {
activeTabId: tab?.id,
tabOrder: [tab?.id]
})
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
'/tmp/orca/notes.md'
)
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(fileId)
})
it('opens floating markdown tabs without changing the main active editor surface', () => {
@@ -72,8 +88,8 @@ describe('createEditorSlice floating editor activation', () => {
expect(store.getState().activeFileId).toBe('/repo/main.md')
expect(store.getState().activeTabType).toBe('editor')
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
'/tmp/orca/untitled.md'
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual(
expectedFileId('/tmp/orca/untitled.md')
)
expect(store.getState().activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('editor')
})
@@ -5,10 +5,12 @@ import type { AppState } from '../types'
import type { BrowserTab } from '../../../../shared/browser-workspace-types'
import type { Tab, TabContentType, TabGroup } from '../../../../shared/tab-types'
import { makeTab } from './store-session-test-harness'
import { emptyTabLayoutState } from './editor-slice-test-harness'
function createEditorStore(overrides?: Partial<AppState>): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// oxlint-disable-next-line typescript/no-explicit-any, typescript/consistent-type-assertions -- SAFETY: a partial store; tests read only the slices it builds.
return createStore<any>()((...args: any[]) => ({
...emptyTabLayoutState(),
activeWorktreeId: 'wt-1',
tabsByWorktree: {},
browserTabsByWorktree: {},
@@ -47,6 +47,15 @@ import {
remapClosedTerminalTabSnapshotCwds,
restoreRecentlyClosedTabPosition
} from './recently-closed-tabs'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
const WT = 'repo1::/path/wt1'
@@ -66,15 +75,18 @@ beforeEach(() => {
})
describe('terminal recently-closed capture', () => {
it('captures a snapshot and kind entry on user close', () => {
it('captures a snapshot and kind entry on user close', async () => {
const store = makeSeededStore()
const tab = store
.getState()
.createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/packages/app' })
store.getState().setTabCustomTitle(tab.id, 'build shell')
await layoutApplyTestTransport.settle()
store.getState().setTabColor(tab.id, '#ff0000')
await layoutApplyTestTransport.settle()
store.getState().closeTab(tab.id)
await layoutApplyTestTransport.settle()
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toEqual([
expect.objectContaining({
@@ -97,23 +109,25 @@ describe('terminal recently-closed capture', () => {
expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toBeUndefined()
})
it('skips capture for a confirmed parked PTY exit', () => {
it('skips capture for a confirmed parked PTY exit', async () => {
const store = makeSeededStore()
const tab = store.getState().createTab(WT)
store.getState().closeTab(tab.id, { captureRecentlyClosed: false })
await layoutApplyTestTransport.settle()
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toBeUndefined()
expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toBeUndefined()
})
it('caps the terminal stack at 10 snapshots, newest first', () => {
it('caps the terminal stack at 10 snapshots, newest first', async () => {
const store = makeSeededStore()
for (let i = 0; i < 12; i++) {
const tab = store
.getState()
.createTab(WT, undefined, undefined, { startupCwd: `/path/wt1/dir-${i}` })
store.getState().closeTab(tab.id)
await layoutApplyTestTransport.settle()
}
const stack = store.getState().recentlyClosedTerminalTabsByWorktree[WT]
@@ -153,7 +167,7 @@ describe('terminal snapshot cwd remapping', () => {
})
describe('reopenClosedTerminalTab', () => {
it('restores the tab bar and group position of a closed middle tab', () => {
it('restores the tab bar and group position of a closed middle tab', async () => {
const store = makeSeededStore()
const first = store.getState().createTab(WT)
const middle = store.getState().createTab(WT)
@@ -161,7 +175,9 @@ describe('reopenClosedTerminalTab', () => {
store.getState().setTabBarOrder(WT, [first.id, middle.id, last.id])
store.getState().closeTab(middle.id)
await layoutApplyTestTransport.settle()
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
await layoutApplyTestTransport.settle()
const restored = store
.getState()
@@ -177,17 +193,21 @@ describe('reopenClosedTerminalTab', () => {
).toEqual([first.id, restored!.id, last.id])
})
it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', () => {
it('recreates a fresh terminal with the snapshot cwd, shell, title, and color', async () => {
const store = makeSeededStore()
const tab = store
.getState()
.createTab(WT, undefined, 'zsh', { startupCwd: '/path/wt1/packages/app' })
store.getState().setTabCustomTitle(tab.id, 'build shell')
await layoutApplyTestTransport.settle()
store.getState().setTabColor(tab.id, '#ff0000')
await layoutApplyTestTransport.settle()
store.getState().closeTab(tab.id)
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WT]).toHaveLength(0)
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
await layoutApplyTestTransport.settle()
const restored = store.getState().tabsByWorktree[WT]
expect(restored).toHaveLength(1)
@@ -203,17 +223,20 @@ describe('reopenClosedTerminalTab', () => {
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toHaveLength(0)
})
it('returns false when the stack is empty (no double-restore)', () => {
it('returns false when the stack is empty (no double-restore)', async () => {
const store = makeSeededStore()
const tab = store.getState().createTab(WT)
store.getState().closeTab(tab.id)
await layoutApplyTestTransport.settle()
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
await layoutApplyTestTransport.settle()
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(false)
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WT]).toHaveLength(1)
})
it('skips local reopen on a remote-runtime worktree (host owns the terminal)', () => {
it('skips local reopen on a remote-runtime worktree (host owns the terminal)', async () => {
// Why: a `runtime:` host id makes the worktree host-owned; a local createTab
// would leave an unbacked phantom tab, so reopen must bail and let the
// cross-type dispatcher fall through to browser/editor.
@@ -232,24 +255,27 @@ describe('reopenClosedTerminalTab', () => {
})
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(false)
await layoutApplyTestTransport.settle()
// No local tab spawned, and the snapshot is left intact (not consumed).
expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0)
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]).toHaveLength(1)
})
it('reopens a legacy local worktree while another runtime is merely focused', () => {
it('reopens a legacy local worktree while another runtime is merely focused', async () => {
const store = makeSeededStore()
store.setState({
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' }
})
const tab = store.getState().createTab(WT)
store.getState().closeTab(tab.id)
await layoutApplyTestTransport.settle()
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WT]).toHaveLength(1)
})
it('reopens SSH terminals through the renderer-owned create path', () => {
it('reopens SSH terminals through the renderer-owned create path', async () => {
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
@@ -263,8 +289,10 @@ describe('reopenClosedTerminalTab', () => {
.getState()
.createTab(WT, undefined, undefined, { startupCwd: '/remote/wt1/packages/app' })
store.getState().closeTab(tab.id)
await layoutApplyTestTransport.settle()
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
await layoutApplyTestTransport.settle()
expect(store.getState().tabsByWorktree[WT]?.[0]?.startupCwd).toBe('/remote/wt1/packages/app')
})
})
@@ -325,12 +353,17 @@ describe('restoreRecentlyClosedTabPosition', () => {
{ groupId: 'captured-group', groupIndex: 0 }
)
expect(reordered).toHaveBeenCalledWith('captured-group', ['captured-tab', 'captured-sibling'], {
recordInteraction: false
})
// A switch-on build also marks the reorder as one main already made.
expect(reordered).toHaveBeenCalledWith(
'captured-group',
['captured-tab', 'captured-sibling'],
ORCA_LAYOUT_OWNER_IS_MAIN
? { recordInteraction: false, layoutOwnerHandled: true }
: { recordInteraction: false }
)
})
it('does not reuse a stale flat index after group drag reordering', () => {
it('does not reuse a stale flat index after group drag reordering', async () => {
const store = makeSeededStore()
const first = store.getState().createTab(WT)
const middle = store.getState().createTab(WT)
@@ -343,12 +376,14 @@ describe('restoreRecentlyClosedTabPosition', () => {
store.getState().reorderUnifiedTabs(groupId, [first.id, last.id, middle.id])
store.getState().closeTab(middle.id)
await layoutApplyTestTransport.settle()
expect(store.getState().recentlyClosedTerminalTabsByWorktree[WT]?.[0]?.position).toEqual({
groupId,
groupIndex: 2
})
expect(store.getState().reopenClosedTerminalTab(WT)).toBe(true)
await layoutApplyTestTransport.settle()
const restored = store
.getState()
.tabsByWorktree[WT]?.find((tab) => tab.id !== first.id && tab.id !== last.id)
@@ -362,7 +397,7 @@ describe('restoreRecentlyClosedTabPosition', () => {
})
describe('reopenClosedTab cross-type MRU', () => {
it('reopens closed tabs of mixed kinds in most-recent-first order', () => {
it('reopens closed tabs of mixed kinds in most-recent-first order', async () => {
const store = makeSeededStore()
// Close order: editor → terminal → browser.
@@ -372,6 +407,7 @@ describe('reopenClosedTab cross-type MRU', () => {
.getState()
.createTab(WT, undefined, undefined, { startupCwd: '/path/wt1/api' })
store.getState().closeTab(terminal.id)
await layoutApplyTestTransport.settle()
const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' })
store.getState().closeBrowserTab(browser.id)
@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { AppState } from '../types'
import { createTestStore, makeUnifiedTab, makeTabGroup } from './store-test-helpers'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
// Mock sonner (imported transitively by repos.ts via the full store).
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@@ -51,25 +59,30 @@ describe('tab view mode', () => {
seedTwoLeafTabs(store)
})
it('toggles a tab to chat and back to terminal', () => {
it('toggles a tab to chat and back to terminal', async () => {
expect(findTab(store, 'left-tab')?.viewMode).toBeUndefined()
store.getState().toggleTabViewMode('left-tab')
await layoutApplyTestTransport.settle()
expect(findTab(store, 'left-tab')?.viewMode).toBe('chat')
store.getState().toggleTabViewMode('left-tab')
await layoutApplyTestTransport.settle()
expect(findTab(store, 'left-tab')?.viewMode).toBe('terminal')
})
it('setTabViewMode sets an explicit mode', () => {
it('setTabViewMode sets an explicit mode', async () => {
store.getState().setTabViewMode('left-tab', 'chat')
await layoutApplyTestTransport.settle()
expect(findTab(store, 'left-tab')?.viewMode).toBe('chat')
store.getState().setTabViewMode('left-tab', 'terminal')
await layoutApplyTestTransport.settle()
expect(findTab(store, 'left-tab')?.viewMode).toBe('terminal')
})
it('mutates only the targeted tab, leaving a split sibling unchanged', () => {
it('mutates only the targeted tab, leaving a split sibling unchanged', async () => {
store.getState().toggleTabViewMode('left-tab')
await layoutApplyTestTransport.settle()
expect(findTab(store, 'left-tab')?.viewMode).toBe('chat')
// The sibling leaf's tab keeps its (default) view mode.
@@ -105,16 +118,19 @@ describe('tab view mode', () => {
let rowTabId = ''
it('toggleTabViewMode patches the row in the same write', () => {
it('toggleTabViewMode patches the row in the same write', async () => {
store.getState().toggleTabViewMode(rowTabId)
await layoutApplyTestTransport.settle()
expect(terminalRow(rowTabId)?.viewMode).toBe('chat')
store.getState().toggleTabViewMode(rowTabId)
await layoutApplyTestTransport.settle()
expect(terminalRow(rowTabId)?.viewMode).toBe('terminal')
})
it('setTabViewMode patches the row in the same write', () => {
it('setTabViewMode patches the row in the same write', async () => {
store.getState().setTabViewMode(rowTabId, 'chat')
await layoutApplyTestTransport.settle()
expect(terminalRow(rowTabId)?.viewMode).toBe('chat')
})
})
@@ -1,6 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import type { Tab } from '../../../../shared/tab-types'
import { createTestStore, makeWorktree, seedStore } from '../slices/store-test-helpers'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
// Main's answer to the window's layout commands, as a switch-on build sends them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
@@ -68,7 +76,7 @@ function colorOf(store: ReturnType<typeof createTestStore>): string | null | und
}
describe('renaming a terminal tab still resolves', () => {
it('routes a terminal rename through its entityId, not the unified id', () => {
it('routes a terminal rename through its entityId, not the unified id', async () => {
const store = createTestStore()
seedStore(store, {
repos: [{ id: 'local-repo', path: '/tmp/app', name: 'app' }] as never,
@@ -80,6 +88,7 @@ describe('renaming a terminal tab still resolves', () => {
// Keyed by the TERMINAL's entityId — the structured tab must not absorb it.
store.getState().setTabCustomTitle(TERMINAL_TAB_ID, 'Build logs')
await layoutApplyTestTransport.settle()
const tabs = store.getState().unifiedTabsByWorktree[WORKTREE] ?? []
expect(tabs.find((t) => t.id === TERMINAL_UNIFIED_ID)?.customLabel).toBe('Build logs')
@@ -88,21 +97,24 @@ describe('renaming a terminal tab still resolves', () => {
})
describe('recoloring a structured chat tab', () => {
it('writes the color onto the agent-session tab', () => {
it('writes the color onto the agent-session tab', async () => {
const store = storeWithStructuredTab()
store.getState().setTabColor(STRUCTURED_TAB_ID, 'red')
await layoutApplyTestTransport.settle()
expect(colorOf(store)).toBe('red')
})
})
describe('renaming a structured chat tab', () => {
it('clears the custom label when the rename is emptied', () => {
it('clears the custom label when the rename is emptied', async () => {
const store = storeWithStructuredTab()
store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, 'Flaky retry test')
await layoutApplyTestTransport.settle()
// Guard: without the intermediate assertion this case passes on a rename
// that never wrote anything, since the label starts out null too.
expect(labelOf(store)).toBe('Flaky retry test')
store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, null)
await layoutApplyTestTransport.settle()
expect(labelOf(store)).toBeNull()
})
})
@@ -1,8 +1,11 @@
// Switch-off builds (the switch reads off in tests): a workspace's first group keeps a minted id.
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { build, testContext, WS } from './workspace-layout-command.test-fixture'
// rollback build (switch-off) — delete with the switch-off code post-flip
vi.mock('../layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false }))
describe('a workspace first group with the layout-owner switch off', () => {
it('is a minted id, whatever group the sender names', () => {
const plain = build(testContext(), [{ type: 'createTerminalTab', workspace: WS }])