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.
This commit is contained in:
Neil
2026-10-16 23:26:46 -07:00
committed by GitHub
parent a349bfa6cc
commit 7bb83b47c8
3 changed files with 626 additions and 0 deletions
@@ -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<unknown>
/** 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
])
)
}
/** `<action> | <kind> | <target>` → why the survivor is touched today (tracker Stage 1 worklist). */
const KNOWN_TWIN_LEAKS: Readonly<Record<string, string>> = 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)
})
})
@@ -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<T>(
record: Record<string, T> | undefined,
keys: readonly string[]
): Record<string, T> {
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<string, unknown> {
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<Record<string, unknown>>,
after: Readonly<Record<string, unknown>>
): string[] {
return Object.keys(before).filter(
(key) => JSON.stringify(after[key]) !== JSON.stringify(before[key])
)
}
@@ -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<typeof createTestStore>
/** 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
}
}