From 7bb83b47c82b19fca666e2bd3fbec61cc44f9f98 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:26:46 -0700 Subject: [PATCH] test(store): host-twin fixture and destructive/selection twin isolation suite (#29991) Seeds local+ssh, local+runtime and folder twins (one id on two hosts) with live shells, a dormant tab, dirty docs, drafts, sleeping-agent records and module-level stores, then runs a hand-declared action table (remove, folder delete, purge, kill, sleep, select) on one twin and asserts the other twin is untouched. Today's leaks are listed cell by cell and asserted to still leak, so a fix turns them red until the entry is removed. --- .../src/store/host-twin-isolation.test.ts | 259 +++++++++++++++++ ...host-twin-state-projection.test-fixture.ts | 100 +++++++ .../src/store/host-twin.test-fixture.ts | 267 ++++++++++++++++++ 3 files changed, 626 insertions(+) create mode 100644 src/renderer/src/store/host-twin-isolation.test.ts create mode 100644 src/renderer/src/store/host-twin-state-projection.test-fixture.ts create mode 100644 src/renderer/src/store/host-twin.test-fixture.ts diff --git a/src/renderer/src/store/host-twin-isolation.test.ts b/src/renderer/src/store/host-twin-isolation.test.ts new file mode 100644 index 00000000000..b5ec0db49af --- /dev/null +++ b/src/renderer/src/store/host-twin-isolation.test.ts @@ -0,0 +1,259 @@ +/** + * Destructive and selection actions aimed at one twin must leave the other twin's state alone. + * The action table is declared by hand: adding a destroy, hide, link or select entry point means + * adding its row here. Cells listed in KNOWN_TWIN_LEAKS are today's failures (the Stage 1 worklist): + * they assert the leak still happens, so a fix turns them red until the entry is deleted. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client' +import { + changedHostTwinState, + projectHostTwinState, + resetHostTwinModuleStores +} from './host-twin-state-projection.test-fixture' +import { + createHostTwinFixture, + TWIN_RUNTIME_ENV, + type HostTwinFixture, + type HostTwinKind, + type HostTwinSide +} from './host-twin.test-fixture' + +const appStore = vi.hoisted((): { current: HostTwinFixture['store'] | null } => ({ + current: null +})) + +// Why: the sleep flow reads the app singleton; route it to this test's store. +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!appStore.current) { + throw new Error('no twin fixture store bound') + } + return appStore.current.getState() + } + } +})) + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +const { sleepWorktreesReportingFailures } = await import('@/components/sidebar/sleep-worktree-flow') + +const ptyKill = vi.fn() + +function answerLikeHost(args: RuntimeEnvironmentCallRequest): unknown { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return compatible + } + const result = + args.method === 'terminal.sleep' + ? { + stopped: 1, + stoppedPtyIds: [], + livePtyIds: [], + postStopVerified: true + } + : {} + return { + id: args.method, + ok: true, + result, + _meta: { runtimeId: 'runtime-b' } + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + ptyKill.mockReset() + vi.stubGlobal('window', { + api: { + repos: { remove: vi.fn(), removeForHost: vi.fn() }, + folderWorkspaces: { delete: vi.fn().mockResolvedValue(true) }, + pty: { kill: ptyKill }, + gh: { enqueuePRRefresh: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { + call: vi.fn(answerLikeHost), + list: vi.fn().mockResolvedValue([{ id: TWIN_RUNTIME_ENV }]) + }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + }) +}) + +let current: HostTwinFixture | null = null + +afterEach(() => { + if (current) { + resetHostTwinModuleStores(current) + } + current = null + appStore.current = null + vi.unstubAllGlobals() +}) + +type TwinAction = { + id: string + /** The entry point under test; review this table when one is added or moved. */ + source: string + appliesTo: (kind: HostTwinKind) => boolean + run: (fixture: HostTwinFixture, target: HostTwinSide) => Promise + /** Proves the action reached its target, so an isolated no-op can't pass. */ + applied: (fixture: HostTwinFixture, target: HostTwinSide) => void +} + +const isFolderKind = (kind: HostTwinKind) => kind === 'folder local+ssh' +const settle = async (run: () => unknown) => { + try { + await run() + } catch { + // A refusal is an acceptable outcome; the isolation assertion is what matters. + } +} + +const TWIN_ACTIONS: readonly TwinAction[] = [ + { + id: 'remove project', + source: 'store/repos/repo-removal.ts removeProject', + appliesTo: (kind) => !isFolderKind(kind), + run: (f, target) => f.store.getState().removeProject(f.projectId, { hostId: target.hostId }), + applied: (f, target) => + expect(f.store.getState().repos.map((repo) => repo.executionHostId)).not.toContain( + target.hostId + ) + }, + { + id: 'delete folder workspace', + source: 'store/folder-workspaces/folder-workspace-mutations.ts deleteFolderWorkspace', + appliesTo: isFolderKind, + run: (f, target) => + f.store.getState().deleteFolderWorkspace(f.projectId, { executionHostId: target.hostId }), + applied: (f, target) => + expect( + (f.store.getState().folderWorkspaces ?? []).map((folder) => folder.executionHostId) + ).not.toContain(target.hostId) + }, + { + id: 'purge terminal state', + source: 'store/slices/worktrees.ts purgeWorktreeTerminalState', + appliesTo: () => true, + run: async (f, target) => + f.store + .getState() + .purgeWorktreeTerminalState([{ id: f.workspaceKey, hostId: target.hostId }]), + applied: (f, target) => + expect(f.store.getState().ptyIdsByTabId[target.terminalTabId]).toBeUndefined() + }, + { + id: 'kill terminals', + source: 'store/terminals/terminal-shutdown.ts shutdownWorktreeTerminals', + appliesTo: () => true, + // Why raw id: the entry point cannot name a host yet (Stage 1.3 HostWorktreeTarget). + run: (f) => settle(() => f.store.getState().shutdownWorktreeTerminals(f.workspaceKey)), + applied: (f, target) => + expect(f.store.getState().ptyIdsByTabId[target.terminalTabId] ?? []).toEqual([]) + }, + { + id: 'sleep', + source: 'components/sidebar/sleep-worktree-flow.ts sleepWorktreesReportingFailures', + appliesTo: () => true, + run: (f) => settle(() => sleepWorktreesReportingFailures([f.workspaceKey])), + applied: (f, target) => + expect(f.store.getState().ptyIdsByTabId[target.terminalTabId] ?? []).toEqual([]) + }, + { + id: 'select', + source: 'store/slices/worktrees.ts setActiveWorktree / setActiveFolderWorkspace', + appliesTo: () => true, + run: async (f, target) => + isFolderKind(f.kind) + ? f.store.getState().setActiveFolderWorkspace(f.projectId, target.hostId) + : f.store.getState().setActiveWorktree(f.workspaceKey, target.hostId), + applied: (f, target) => + expect(f.store.getState().activeWorkspaceExecutionHostId).toBe(target.hostId) + } +] + +const TWIN_KINDS: readonly HostTwinKind[] = ['local+ssh', 'local+runtime', 'folder local+ssh'] + +function everyCell(actionId: string, reason: string, kinds = TWIN_KINDS): [string, string][] { + return kinds.flatMap((kind) => + (['remote', 'local'] as const).map((target): [string, string] => [ + `${actionId} | ${kind} | ${target}`, + reason + ]) + ) +} + +/** ` | | ` → why the survivor is touched today (tracker Stage 1 worklist). */ +const KNOWN_TWIN_LEAKS: Readonly> = Object.fromEntries([ + ...everyCell( + 'purge terminal state', + 'the purge drops every bucket under the raw id; the target host only scopes visit recency' + ), + ...everyCell('kill terminals', 'takes a raw worktree id, so it stops both twins’ PTYs'), + ...everyCell('sleep', 'takes a raw worktree id, so it sleeps both twins'), + [ + 'remove project | local+runtime | local', + 'a sleeping-agent record with no host evidence is dropped instead of kept for the survivor' + ], + ...everyCell( + 'select', + 'activating a slept twin clears the shared raw-id sleep marker and re-arms both twins’ tabs' + ) +]) + +function cellId(action: TwinAction, kind: HostTwinKind, target: 'local' | 'remote'): string { + return `${action.id} | ${kind} | ${target}` +} + +describe('an action on one twin leaves the other twin alone', () => { + for (const kind of TWIN_KINDS) { + for (const action of TWIN_ACTIONS.filter((a) => a.appliesTo(kind))) { + for (const targetName of ['remote', 'local'] as const) { + const id = cellId(action, kind, targetName) + const knownLeak = KNOWN_TWIN_LEAKS[id] + it(knownLeak ? `${id} (known leak: ${knownLeak})` : id, async () => { + const fixture = createHostTwinFixture(kind) + current = fixture + appStore.current = fixture.store + const target = fixture[targetName] + const survivor = targetName === 'remote' ? fixture.local : fixture.remote + const before = projectHostTwinState(fixture, survivor) + + await action.run(fixture, target) + + const after = projectHostTwinState(fixture, survivor) + const changed = changedHostTwinState(before, after) + action.applied(fixture, target) + if (knownLeak) { + // Fixed? Delete this cell's KNOWN_TWIN_LEAKS entry. + expect(changed).not.toEqual([]) + return + } + expect(changed).toEqual([]) + if (survivor.hostId === 'local') { + expect(ptyKill).not.toHaveBeenCalledWith(survivor.ptyId) + } + }) + } + } + } +}) + +describe('the twin fixture', () => { + it('seeds two hosts under one workspace key', () => { + const fixture = createHostTwinFixture('local+ssh') + current = fixture + const hosts: ExecutionHostId[] = [fixture.local.hostId, fixture.remote.hostId] + expect(hosts).toEqual(['local', 'ssh:conn-b']) + expect(fixture.store.getState().tabsByWorktree[fixture.workspaceKey]).toHaveLength(3) + }) +}) diff --git a/src/renderer/src/store/host-twin-state-projection.test-fixture.ts b/src/renderer/src/store/host-twin-state-projection.test-fixture.ts new file mode 100644 index 00000000000..6144c7cfa75 --- /dev/null +++ b/src/renderer/src/store/host-twin-state-projection.test-fixture.ts @@ -0,0 +1,100 @@ +/** What one twin owns, read back out of a host-twin fixture's store and module stores. */ +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import type { AppState } from './types' +import { + readNativeChatComposerDraft, + updateNativeChatComposerDraft +} from '@/components/native-chat/native-chat-composer-draft-store' +import { forgetWorktreeSleepIntent, hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + clearHugeRepoWarningDismissalsForTests, + hasDismissedHugeRepoWarning +} from '@/lib/source-control-huge-repo-warning-dismissals' +import type { HostTwinFixture, HostTwinSide } from './host-twin.test-fixture' + +export function resetHostTwinModuleStores(fixture: HostTwinFixture): void { + forgetWorktreeSleepIntent(fixture.workspaceKey) + clearHugeRepoWarningDismissalsForTests() + for (const s of [fixture.local, fixture.remote]) { + updateNativeChatComposerDraft(s.paneKey, { text: '' }, 'deferred') + } +} + +function pick( + record: Record | undefined, + keys: readonly string[] +): Record { + return Object.fromEntries( + keys.flatMap((key) => (record && key in record ? [[key, record[key]] as const] : [])) + ) +} + +/** + * Everything the fixture attributes to `owner`, plus the unattributable shared entries that must + * survive while a twin does. An action on the other twin must leave this unchanged. + */ +export function projectHostTwinState(fixture: HostTwinFixture, owner: HostTwinSide) { + const state: AppState = fixture.store.getState() + const key = fixture.workspaceKey + const tabIds = [owner.terminalTabId, fixture.dormantTabId] + const ownedIds = new Set([...tabIds, owner.fileId]) + return { + repos: (state.repos ?? []).filter((repo) => getRepoExecutionHostId(repo) === owner.hostId), + worktrees: (state.worktreesByRepo[fixture.projectId] ?? []).filter( + (worktree) => worktree.hostId === owner.hostId + ), + folderWorkspaces: (state.folderWorkspaces ?? []).filter( + (folder) => folder.executionHostId === owner.hostId + ), + projectGroups: (state.projectGroups ?? []).filter( + (group) => group.executionHostId === owner.hostId + ), + tabs: (state.tabsByWorktree[key] ?? []).filter((tab) => tabIds.includes(tab.id)), + ptyIds: pick(state.ptyIdsByTabId, tabIds), + layouts: pick(state.terminalLayoutsByTabId, tabIds), + unifiedTabs: (state.unifiedTabsByWorktree[key] ?? []).filter((tab) => ownedIds.has(tab.id)), + openFiles: state.openFiles.filter((file) => file.id === owner.fileId), + editorDrafts: pick(state.editorDrafts, [owner.fileId]), + sleepingAgentSessions: pick(state.sleepingAgentSessionsByPaneKey, [owner.paneKey]), + moduleStores: snapshotHostTwinModuleStores(fixture, owner) + } +} + +/** + * Module-level stores the purge reaches outside the zustand state. Registered by hand: a store + * added to `buildWorktreePurgeState`'s forgetters belongs here too. + */ +const HOST_TWIN_MODULE_STORES: readonly { + name: string + read: (fixture: HostTwinFixture, owner: HostTwinSide) => unknown +}[] = [ + { + name: 'worktree-sleep-intent', + read: (fixture) => hasWorktreeSleepIntent(fixture.workspaceKey) + }, + { + name: 'huge-repo-warning-dismissal', + read: (fixture) => hasDismissedHugeRepoWarning(fixture.hugeRepoWarningProbe) + }, + { + name: 'native-chat-composer-draft', + read: (_fixture, owner) => readNativeChatComposerDraft(owner.paneKey).text + } +] + +function snapshotHostTwinModuleStores( + fixture: HostTwinFixture, + owner: HostTwinSide +): Record { + return Object.fromEntries(HOST_TWIN_MODULE_STORES.map((s) => [s.name, s.read(fixture, owner)])) +} + +/** Which projected entries differ; compared by value, as persisted state would be. */ +export function changedHostTwinState( + before: Readonly>, + after: Readonly> +): string[] { + return Object.keys(before).filter( + (key) => JSON.stringify(after[key]) !== JSON.stringify(before[key]) + ) +} diff --git a/src/renderer/src/store/host-twin.test-fixture.ts b/src/renderer/src/store/host-twin.test-fixture.ts new file mode 100644 index 00000000000..bad4c633c74 --- /dev/null +++ b/src/renderer/src/store/host-twin.test-fixture.ts @@ -0,0 +1,267 @@ +/** + * Twins: one repo (or folder workspace) registered on two execution hosts. They share the repo + * id, the worktree id and every raw-id-keyed store bucket, so an action aimed at one twin can + * silently reach the other. The fixture seeds both twins with live shells, a dormant tab, dirty + * documents, drafts and sleeping-agent records; `host-twin-state-projection.test-fixture.ts` reads one host back. + */ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { + createTestStore, + makeOpenFile, + makeTab, + makeTabGroup, + makeUnifiedTab, + makeWorktree +} from './slices/store-test-helpers' +import { makeFolderWorkspace } from './slices/worktrees-slice-test-fixtures' +import { updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + beginHugeRepoWarningProbe, + markHugeRepoWarningDismissed, + type HugeRepoWarningProbe +} from '@/lib/source-control-huge-repo-warning-dismissals' + +export type HostTwinKind = 'local+ssh' | 'local+runtime' | 'folder local+ssh' + +export const TWIN_SSH_TARGET = 'conn-b' +export const TWIN_RUNTIME_ENV = 'env-b' +const PROJECT_ID = 'twin-repo' +const FOLDER_ID = 'twin-folder' +const GROUP_ID = 'twin-group' +const TAB_GROUP_ID = 'twin-tab-group' +const LEAF = 'leaf-1' + +/** What one host owns in the fixture; everything else in a shared bucket is the other host's or shared. */ +export type HostTwinSide = { + hostId: ExecutionHostId + terminalTabId: string + ptyId: string + fileId: string + paneKey: string +} + +export type HostTwinFixture = { + kind: HostTwinKind + store: ReturnType + /** Repo id, or the folder workspace id for folder twins. */ + projectId: string + /** The key both twins' tabs live under: the worktree id or the folder workspace key. */ + workspaceKey: string + local: HostTwinSide + remote: HostTwinSide + /** A tab with no PTY: no evidence names its host, so it is kept while either twin survives. */ + dormantTabId: string + hugeRepoWarningProbe: HugeRepoWarningProbe +} + +function remoteHostFor(kind: HostTwinKind): ExecutionHostId { + return kind === 'local+runtime' ? `runtime:${TWIN_RUNTIME_ENV}` : `ssh:${TWIN_SSH_TARGET}` +} + +function remotePtyIdFor(kind: HostTwinKind): string { + return kind === 'local+runtime' + ? toRemoteRuntimePtyId('handle-b', TWIN_RUNTIME_ENV) + : toAppSshPtyId(TWIN_SSH_TARGET, 'pty-b') +} + +function side(hostId: ExecutionHostId, tag: string, ptyId: string, root: string): HostTwinSide { + const terminalTabId = `tab-${tag}` + return { + hostId, + terminalTabId, + ptyId, + fileId: `${root}/${tag}.ts`, + paneKey: `${terminalTabId}:${LEAF}` + } +} + +function repoRow(executionHostId: ExecutionHostId, root: string): Repo { + return { + id: PROJECT_ID, + path: root, + displayName: 'Twin', + badgeColor: '#000', + addedAt: 1, + executionHostId, + ...(executionHostId.startsWith('ssh:') ? { connectionId: TWIN_SSH_TARGET } : {}) + } +} + +function projectGroupRow(executionHostId: ExecutionHostId): ProjectGroup { + return { + id: GROUP_ID, + name: 'Twin group', + parentPath: '/work/twin', + executionHostId, + ...(executionHostId.startsWith('ssh:') ? { connectionId: TWIN_SSH_TARGET } : {}), + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function sleepingRecord( + sideOf: HostTwinSide, + workspaceKey: string, + connectionId: string | null +): SleepingAgentSessionRecord { + return { + paneKey: sideOf.paneKey, + worktreeId: workspaceKey, + agent: 'claude', + providerSession: { key: 'session_id', id: sideOf.paneKey }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId + } +} + +export function createHostTwinFixture(kind: HostTwinKind): HostTwinFixture { + const store = createTestStore() + const root = '/work/twin' + const isFolder = kind === 'folder local+ssh' + const projectId = isFolder ? FOLDER_ID : PROJECT_ID + const workspaceKey = isFolder ? folderWorkspaceKey(FOLDER_ID) : `${PROJECT_ID}::${root}` + const remoteHostId = remoteHostFor(kind) + const local = side('local', 'local', 'pty-local', root) + const remote = side(remoteHostId, 'remote', remotePtyIdFor(kind), root) + const dormantTabId = 'tab-dormant' + const sides = [local, remote] + const tab = (id: string, ptyId: string | null) => makeTab({ id, worktreeId: workspaceKey, ptyId }) + const unified = ( + id: string, + executionHostId: ExecutionHostId, + contentType: 'terminal' | 'editor' + ) => + makeUnifiedTab({ + id, + worktreeId: workspaceKey, + groupId: TAB_GROUP_ID, + executionHostId, + contentType + }) + + store.setState({ + ...(isFolder + ? { + projectGroups: [projectGroupRow('local'), projectGroupRow(remoteHostId)], + folderWorkspaces: [ + makeFolderWorkspace({ + id: FOLDER_ID, + projectGroupId: GROUP_ID, + folderPath: root, + executionHostId: 'local' + }), + makeFolderWorkspace({ + id: FOLDER_ID, + projectGroupId: GROUP_ID, + folderPath: root, + executionHostId: remoteHostId, + connectionId: TWIN_SSH_TARGET + }) + ] + } + : { + repos: [repoRow('local', root), repoRow(remoteHostId, root)], + worktreesByRepo: { + [PROJECT_ID]: [ + makeWorktree({ + id: workspaceKey, + repoId: PROJECT_ID, + path: root, + hostId: 'local' + }), + makeWorktree({ + id: workspaceKey, + repoId: PROJECT_ID, + path: root, + hostId: remoteHostId + }) + ] + } + }), + tabsByWorktree: { + [workspaceKey]: [...sides.map((s) => tab(s.terminalTabId, s.ptyId)), tab(dormantTabId, null)] + }, + ptyIdsByTabId: Object.fromEntries(sides.map((s) => [s.terminalTabId, [s.ptyId]])), + terminalLayoutsByTabId: Object.fromEntries( + sides.map((s) => [ + s.terminalTabId, + { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: s.ptyId } + } + ]) + ), + unifiedTabsByWorktree: { + [workspaceKey]: [ + ...sides.flatMap((s) => [ + unified(s.terminalTabId, s.hostId, 'terminal'), + unified(s.fileId, s.hostId, 'editor') + ]), + makeUnifiedTab({ + id: dormantTabId, + worktreeId: workspaceKey, + groupId: TAB_GROUP_ID + }) + ] + }, + groupsByWorktree: { + [workspaceKey]: [ + makeTabGroup({ + id: TAB_GROUP_ID, + worktreeId: workspaceKey, + activeTabId: local.terminalTabId, + tabOrder: [...sides.flatMap((s) => [s.terminalTabId, s.fileId]), dormantTabId] + }) + ] + }, + openFiles: sides.map((s) => + makeOpenFile({ + id: s.fileId, + worktreeId: workspaceKey, + isDirty: true, + ...(s.hostId.startsWith('runtime:') ? { runtimeEnvironmentId: TWIN_RUNTIME_ENV } : {}) + }) + ), + editorDrafts: Object.fromEntries(sides.map((s) => [s.fileId, `unsaved ${s.hostId} edit`])), + sleepingAgentSessionsByPaneKey: Object.fromEntries( + sides.map((s) => [ + s.paneKey, + sleepingRecord(s, workspaceKey, s.hostId.startsWith('ssh:') ? TWIN_SSH_TARGET : null) + ]) + ) + }) + for (const s of sides) { + updateNativeChatComposerDraft(s.paneKey, { text: `${s.hostId} chat draft` }, 'deferred') + } + // Raw-id module stores: neither can say which twin wrote it, so each belongs to both. + markWorktreeSleepIntent(workspaceKey) + const hugeRepoWarningProbe = beginHugeRepoWarningProbe({ id: workspaceKey }) + markHugeRepoWarningDismissed(hugeRepoWarningProbe) + return { + kind, + store, + projectId, + workspaceKey, + local, + remote, + dormantTabId, + hugeRepoWarningProbe + } +}