From 4e998861e10eec40d1f36b3006ea898d7c8aa661 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:50:11 -0400 Subject: [PATCH] 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 --- .../scripts/pr-unit-support-routing.test.mjs | 4 +- config/vitest.config.ts | 5 +- .../ipc/renderer-shutdown-checkpoint.test.ts | 17 +- src/main/ipc/session.test.ts | 3 + .../worktrees-canonical-ssh-listing.test.ts | 7 +- src/main/ipc/worktrees-test-harness.ts | 5 + src/main/ipc/worktrees-test-ipc-surface.ts | 5 + ...ersistence-pane-identity-migration.test.ts | 21 +- ...istence-pty-binding-reconciliation.test.ts | 11 +- ...persistence-split-pane-incarnation.test.ts | 6 +- src/main/persistence-test-harness.ts | 18 ++ ...tence-workspace-session-scrollback.test.ts | 13 +- ...sistence-worktree-deletion-fencing.test.ts | 6 +- .../pty-binding-placement-inertness.test.ts | 3 + .../terminal-late-bind-layout-rules.test.ts | 3 + .../runtime/agent-session-tab-layout.test.ts | 5 +- ...orca-runtime-serve-browser-restore.test.ts | 9 +- ...a-runtime-terminal-split-authority.test.ts | 10 + .../orca-runtime-test-fixtures.spec.ts | 13 + ...ructured-agent-session-rpc.test-fixture.ts | 7 +- ...editor-autosave-controller-test-fixture.ts | 4 +- .../editor/editor-autosave-controller.test.ts | 4 +- .../editor/editor-save-user-named-tab.test.ts | 8 +- .../floating-workspace-tab-reorder.test.ts | 20 +- .../tab-bar/TabBar.context-menu.test.ts | 9 + .../TabGroupSplitLayout.drag.test.tsx | 20 +- .../launch-parity-window-tab.test.ts | 7 + .../pty-connection-pty-exit-teardown.spec.ts | 7 + .../pty-connection-restore-and-resume.test.ts | 9 +- .../pty-connection-session-liveness.spec.ts | 3 + .../pty-connection-test-pane-fixtures.ts | 4 + ...pty-connection-visibility-and-exit.test.ts | 9 +- ...nt-launch-pane-verdict-application.test.ts | 7 + ...t-background-session-launch-parity.test.ts | 7 + ...unch-agent-in-new-tab-main-surface.test.ts | 7 + ...launch-agent-structured-chat-guard.test.ts | 2 + .../lib/launch-parity-plus-planner.test.ts | 7 + .../src/lib/pi-session-resume-wake.test.ts | 9 +- ...ping-agent-session-live-ssh-dedupe.test.ts | 7 + ...eping-agent-session-provider-claim.test.ts | 9 +- ...-agent-session-suppress-navigation.test.ts | 7 + .../lib/resume-sleeping-agent-session.test.ts | 7 + .../lib/retracted-host-pane-resume.test.ts | 9 +- .../lib/settled-worker-wake-policy.test.ts | 7 + ...etup-split-activation-single-owner.test.ts | 55 ++-- ...-agent-session-new-chat-own-launch.test.ts | 11 +- ...leeping-agents-live-unmounted-pane.test.ts | 7 + .../workspace-terminal-host-authority.test.ts | 7 + ...ktree-activation-automation-filter.test.ts | 7 + .../worktree-activation-created-agent.test.ts | 7 + ...tree-activation-default-agent-chat.test.ts | 7 + ...ctivation-emptied-workspace-reseed.test.ts | 7 + ...activation-structured-chat-surface.test.ts | 7 + .../worktree-agent-activation-seam.test.ts | 47 ++- ...worktree-creation-background-mount.test.ts | 7 + ...on-structured-session-host-decline.test.ts | 7 + ...ivation-preserved-pane-replacement.test.ts | 7 + ...tion-runtime-owned-resume-deferral.test.ts | 7 + .../layout-apply-paired-server-guard.test.ts | 2 + ...ayout-apply-test-transport.test-fixture.ts | 304 ++++++++++++++++++ .../main-loaded-layout-frames.test-fixture.ts | 53 +++ ...tabs-sync-remote-status-title-flap.test.ts | 10 +- .../workspace-layout-frame-sync.test.ts | 44 +-- .../workspace-layout-frame-tab-props.test.ts | 10 + .../slices/agent-generated-tab-title.test.ts | 49 ++- ...rowser-page-close-intent-recording.test.ts | 6 + .../store/slices/cmd-j-create-actions.test.ts | 7 + .../src/store/slices/editor-open-diff.test.ts | 22 +- .../store/slices/editor-slice-test-harness.ts | 11 +- .../store/slices/editor-tab-placement.test.ts | 30 +- .../src/store/slices/new-markdown.test.ts | 4 +- .../store/slices/recently-closed-tabs.test.ts | 64 +++- .../src/store/slices/tab-view-mode.test.ts | 26 +- .../structured-chat-tab-rename.test.ts | 18 +- .../workspace-layout-first-group-off.test.ts | 5 +- 75 files changed, 1045 insertions(+), 170 deletions(-) create mode 100644 src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts create mode 100644 src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index bdef0ec1579..238a55114e5 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -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) { diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 6af92217a6e..49ef5ffcfca 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -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: [ diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 43491ba31af..14b0819ded4 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -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): 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 }) }) diff --git a/src/main/ipc/session.test.ts b/src/main/ipc/session.test.ts index f481b356193..5387c13b66f 100644 --- a/src/main/ipc/session.test.ts +++ b/src/main/ipc/session.test.ts @@ -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()) diff --git a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts index 0eccdba499b..59820525df6 100644 --- a/src/main/ipc/worktrees-canonical-ssh-listing.test.ts +++ b/src/main/ipc/worktrees-canonical-ssh-listing.test.ts @@ -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) { 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, diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index f583c275791..daaee96560d 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -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([]) diff --git a/src/main/ipc/worktrees-test-ipc-surface.ts b/src/main/ipc/worktrees-test-ipc-surface.ts index 4c23765fe15..5be261ad5d3 100644 --- a/src/main/ipc/worktrees-test-ipc-surface.ts +++ b/src/main/ipc/worktrees-test-ipc-surface.ts @@ -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(), diff --git a/src/main/persistence-pane-identity-migration.test.ts b/src/main/persistence-pane-identity-migration.test.ts index 882cd8fa2e8..11bbc49881c 100644 --- a/src/main/persistence-pane-identity-migration.test.ts +++ b/src/main/persistence-pane-identity-migration.test.ts @@ -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: {}, diff --git a/src/main/persistence-pty-binding-reconciliation.test.ts b/src/main/persistence-pty-binding-reconciliation.test.ts index 364960a1c5a..378b9263d68 100644 --- a/src/main/persistence-pty-binding-reconciliation.test.ts +++ b/src/main/persistence-pty-binding-reconciliation.test.ts @@ -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(), diff --git a/src/main/persistence-split-pane-incarnation.test.ts b/src/main/persistence-split-pane-incarnation.test.ts index 9be1133cb66..c34cf951ca1 100644 --- a/src/main/persistence-split-pane-incarnation.test.ts +++ b/src/main/persistence-split-pane-incarnation.test.ts @@ -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( { diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 5a9d54a8348..75980f46788 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -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 => ({ ...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 => ({ id: 'project-1', displayName: 'Project', diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index 873e82615bb..658e9a3094e 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -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: {}, diff --git a/src/main/persistence-worktree-deletion-fencing.test.ts b/src/main/persistence-worktree-deletion-fencing.test.ts index 1dcf80578d8..81322ecbf17 100644 --- a/src/main/persistence-worktree-deletion-fencing.test.ts +++ b/src/main/persistence-worktree-deletion-fencing.test.ts @@ -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', diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index 1ca2e60f7c9..3e50e41ae09 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -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() diff --git a/src/main/persistence/terminal-topology/terminal-late-bind-layout-rules.test.ts b/src/main/persistence/terminal-topology/terminal-late-bind-layout-rules.test.ts index 67372b9c161..640fcd81efe 100644 --- a/src/main/persistence/terminal-topology/terminal-late-bind-layout-rules.test.ts +++ b/src/main/persistence/terminal-topology/terminal-late-bind-layout-rules.test.ts @@ -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: [] }), diff --git a/src/main/runtime/agent-session-tab-layout.test.ts b/src/main/runtime/agent-session-tab-layout.test.ts index 5b6c4adc330..4609ac2b344 100644 --- a/src/main/runtime/agent-session-tab-layout.test.ts +++ b/src/main/runtime/agent-session-tab-layout.test.ts @@ -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 = { diff --git a/src/main/runtime/orca-runtime-serve-browser-restore.test.ts b/src/main/runtime/orca-runtime-serve-browser-restore.test.ts index ba4cf19857a..afd7d8798b2 100644 --- a/src/main/runtime/orca-runtime-serve-browser-restore.test.ts +++ b/src/main/runtime/orca-runtime-serve-browser-restore.test.ts @@ -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() }) diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 080327db04c..dbbd6331521 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -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: [ { diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 0f8c72449a3..f6511cb98e5 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -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(), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 739d470cc04..09cf9ceeb46 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -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()) } diff --git a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts index 4bb93ede790..4e91f57a874 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts @@ -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 @@ -71,8 +72,9 @@ export function stubEditorWindow(): ReturnType { } export function createEditorStore(): StoreApi { - // 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()((...args: any[]) => ({ + ...emptyTabLayoutState(), activeWorktreeId: 'wt-1', repos: [], worktreesByRepo: { diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 5ee2f60e78b..bddb243e8af 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -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 { - // 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()((...args: any[]) => ({ + ...emptyTabLayoutState(), activeWorktreeId: 'wt-1', settings: { editorAutoSave: true, diff --git a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts index 36818ef2d24..b123f3afe96 100644 --- a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts +++ b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts @@ -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 { // 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()((...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() diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts index 6c1d0930af9..a5215217552 100644 --- a/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts +++ b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts @@ -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], diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index b6cf6641781..d0be80e55d7 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -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() diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx index 8cd7374b86a..aa50fe497f6 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx @@ -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) => unknown) => - selector({ - recordFeatureInteraction: recordFeatureInteractionMock, - setTabGroupSplitRatio: setTabGroupSplitRatioMock - }) -})) +vi.mock('../../store', () => { + const state = (): Record => ({ + recordFeatureInteraction: recordFeatureInteractionMock, + setTabGroupSplitRatio: setTabGroupSplitRatioMock, + layoutByWorktree: {} + }) + // The switch-on ratio commit reads the store directly. + const useAppStore = Object.assign( + (selector: (state: Record) => unknown) => selector(state()), + { getState: state } + ) + return { useAppStore } +}) vi.mock('./TabGroupPanel', () => ({ default: ({ groupId }: { groupId: string }) =>
diff --git a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts index 6ba97e10cd8..9c4dc519574 100644 --- a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts +++ b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts index 9d67ce14d49..8ffea7c279e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts index b7fba75ab8b..884de20db45 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-restore-and-resume.test.ts @@ -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', diff --git a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts index c58c559bd18..82571cc00c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index 9a6ec1be734..1e868695d03 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -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 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts index adb2e1e0171..5e2944f2bec 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-visibility-and-exit.test.ts @@ -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) diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts index e49cb2edf04..57267c70c8b 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts @@ -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()), diff --git a/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts b/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts index 22c561117de..bfa11b38040 100644 --- a/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-launch-parity.test.ts @@ -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) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts index c1c311d2731..0403b5a8c33 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-main-surface.test.ts @@ -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 diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index ee3898257dd..840744fa15a 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -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, diff --git a/src/renderer/src/lib/launch-parity-plus-planner.test.ts b/src/renderer/src/lib/launch-parity-plus-planner.test.ts index c3e37690f9a..c7c1b0f40ba 100644 --- a/src/renderer/src/lib/launch-parity-plus-planner.test.ts +++ b/src/renderer/src/lib/launch-parity-plus-planner.test.ts @@ -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) diff --git a/src/renderer/src/lib/pi-session-resume-wake.test.ts b/src/renderer/src/lib/pi-session-resume-wake.test.ts index 984a4fd9639..5a4e2951bde 100644 --- a/src/renderer/src/lib/pi-session-resume-wake.test.ts +++ b/src/renderer/src/lib/pi-session-resume-wake.test.ts @@ -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') diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-live-ssh-dedupe.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-live-ssh-dedupe.test.ts index 117329b1e19..03b356f7f52 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-live-ssh-dedupe.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-live-ssh-dedupe.test.ts @@ -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' diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts index b3c5075a4c8..80650f8dd63 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts @@ -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' diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts index 6f03d9544f4..e195e4c6a98 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts @@ -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(() => { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index c90aff730c4..9cc8923f70b 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -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' diff --git a/src/renderer/src/lib/retracted-host-pane-resume.test.ts b/src/renderer/src/lib/retracted-host-pane-resume.test.ts index 52245ba5519..669cfa0a3a7 100644 --- a/src/renderer/src/lib/retracted-host-pane-resume.test.ts +++ b/src/renderer/src/lib/retracted-host-pane-resume.test.ts @@ -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' diff --git a/src/renderer/src/lib/settled-worker-wake-policy.test.ts b/src/renderer/src/lib/settled-worker-wake-policy.test.ts index d227568cb00..a61471096ac 100644 --- a/src/renderer/src/lib/settled-worker-wake-policy.test.ts +++ b/src/renderer/src/lib/settled-worker-wake-policy.test.ts @@ -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(() => { diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index 2ddd15ef67c..4dfd68bb7bc 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -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() diff --git a/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts b/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts index 5a6c8690fb0..9de47ccd66d 100644 --- a/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts @@ -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 groupsByWorktree: Record + layoutByWorktree: Record activeGroupIdByWorktree: Record nativeChatLaunchDraftByTabId: Record } @@ -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: {} } diff --git a/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts b/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts index 06e86500992..f505993f740 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts @@ -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' diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts index 89979637bfb..9afb2e662e7 100644 --- a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts +++ b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts @@ -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() diff --git a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts index 8ddc5ff4225..eb9858bc4cd 100644 --- a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts +++ b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts @@ -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(() => { diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 9ce08a1b768..d4017ee4901 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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() { diff --git a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts index c4d86793f69..739d5930683 100644 --- a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts +++ b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts @@ -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(), diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index 6425c9681b2..47dd851e5d8 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -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(() => { diff --git a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts index dae397b554b..ec2d92f8f3f 100644 --- a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts +++ b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts @@ -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() diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts index 99ae44a6fc8..4a70c090290 100644 --- a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -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 () => { diff --git a/src/renderer/src/lib/worktree-creation-background-mount.test.ts b/src/renderer/src/lib/worktree-creation-background-mount.test.ts index a39bd6dcac2..41c81455d76 100644 --- a/src/renderer/src/lib/worktree-creation-background-mount.test.ts +++ b/src/renderer/src/lib/worktree-creation-background-mount.test.ts @@ -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() diff --git a/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts b/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts index 82426c322b3..fec03660786 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts @@ -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 { diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index 6332bd39b68..bbc135af0d1 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -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 — diff --git a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts index 33fa2b55f8d..332ab96a132 100644 --- a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts @@ -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 diff --git a/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts b/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts index 385e5425899..12c4bdaff0f 100644 --- a/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts +++ b/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts @@ -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 }) }) diff --git a/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts b/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts new file mode 100644 index 00000000000..3413f6f3a73 --- /dev/null +++ b/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts @@ -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['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 + inFlight: Promise[] +} + +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 { + 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(type: T): Extract[] { + return state.sent.filter( + (command): command is Extract => 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 { + 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 { + 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( + target: RuntimeClientTarget, + method: string, + params?: unknown, + options?: Parameters[3] + ): Promise { + 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 + } + return actual.callRuntimeRpc(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 } +} diff --git a/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts b/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts new file mode 100644 index 00000000000..c25b8c345fa --- /dev/null +++ b/src/renderer/src/runtime/main-loaded-layout-frames.test-fixture.ts @@ -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 { + 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 { + return publishedLayoutsOf(loadMainLayoutModel(hostId, session)) +} + +/** The snapshot frame a stream opens with. */ +export function layoutSnapshotFrame( + layouts: Record, + seq?: number +): WorkspaceLayoutChangeFrame { + return { + type: 'snapshot', + workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })), + ...(seq === undefined ? {} : { seq }) + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 5402e3747ff..7692d3257cf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -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) + }) return store } diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts index e276b1f37fe..1f4780e8291 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -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 { - const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext()) - return Object.fromEntries( - Object.entries(layout.workspaces).map(([key, workspace]) => [ - key, - publishWorkspaceLayout(workspace, hostId) - ]) - ) -} - -function snapshot(layouts: Record): WorkspaceLayoutChangeFrame { - return { - type: 'snapshot', - workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })) - } -} - const flushMicrotasks = () => new Promise((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 diff --git a/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts b/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts index f80994e8c18..a3a8f582660 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-tab-props.test.ts @@ -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() @@ -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() diff --git a/src/renderer/src/store/slices/agent-generated-tab-title.test.ts b/src/renderer/src/store/slices/agent-generated-tab-title.test.ts index 16a5efe0d6a..994cae08520 100644 --- a/src/renderer/src/store/slices/agent-generated-tab-title.test.ts +++ b/src/renderer/src/store/slices/agent-generated-tab-title.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts index a315a561b05..3e91acab288 100644 --- a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts +++ b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts @@ -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, diff --git a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts index 8b187215d56..6c9840a4a6b 100644 --- a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts +++ b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts @@ -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()) diff --git a/src/renderer/src/store/slices/editor-open-diff.test.ts b/src/renderer/src/store/slices/editor-open-diff.test.ts index 901def57fd6..ffe98f5a6a9 100644 --- a/src/renderer/src/store/slices/editor-open-diff.test.ts +++ b/src/renderer/src/store/slices/editor-open-diff.test.ts @@ -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 + ) }) }) diff --git a/src/renderer/src/store/slices/editor-slice-test-harness.ts b/src/renderer/src/store/slices/editor-slice-test-harness.ts index 6be3aa657e0..2adac4f2263 100644 --- a/src/renderer/src/store/slices/editor-slice-test-harness.ts +++ b/src/renderer/src/store/slices/editor-slice-test-harness.ts @@ -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 { // 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()((...args: any[]) => ({ activeWorktreeId: 'wt-1', tabsByWorktree: {}, + ...emptyTabLayoutState(), browserTabsByWorktree: {}, activeBrowserTabId: null, activeBrowserTabIdByWorktree: {}, diff --git a/src/renderer/src/store/slices/editor-tab-placement.test.ts b/src/renderer/src/store/slices/editor-tab-placement.test.ts index 39ee3d9a902..5362aec92b5 100644 --- a/src/renderer/src/store/slices/editor-tab-placement.test.ts +++ b/src/renderer/src/store/slices/editor-tab-placement.test.ts @@ -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') }) diff --git a/src/renderer/src/store/slices/new-markdown.test.ts b/src/renderer/src/store/slices/new-markdown.test.ts index 982f804b6cb..09ce96c906d 100644 --- a/src/renderer/src/store/slices/new-markdown.test.ts +++ b/src/renderer/src/store/slices/new-markdown.test.ts @@ -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): StoreApi { - // 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()((...args: any[]) => ({ + ...emptyTabLayoutState(), activeWorktreeId: 'wt-1', tabsByWorktree: {}, browserTabsByWorktree: {}, diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts index 8ca10f494e7..88cd5bef188 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -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) diff --git a/src/renderer/src/store/slices/tab-view-mode.test.ts b/src/renderer/src/store/slices/tab-view-mode.test.ts index 56b99c13ba4..94f33b6e451 100644 --- a/src/renderer/src/store/slices/tab-view-mode.test.ts +++ b/src/renderer/src/store/slices/tab-view-mode.test.ts @@ -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') }) }) diff --git a/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts b/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts index ade01274659..2c8975db0fa 100644 --- a/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts +++ b/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts @@ -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): 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() }) }) diff --git a/src/shared/workspace-layout/workspace-layout-first-group-off.test.ts b/src/shared/workspace-layout/workspace-layout-first-group-off.test.ts index dd57b9000c6..bc216aa2775 100644 --- a/src/shared/workspace-layout/workspace-layout-first-group-off.test.ts +++ b/src/shared/workspace-layout/workspace-layout-first-group-off.test.ts @@ -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 }])