diff --git a/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts deleted file mode 100644 index ad44f1203ce..00000000000 --- a/tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -/** - * The session-tabs retirement-proof surface, paired across two builds. - * - * `cross-version-terminal-wire` covers the terminal binary stream and - * `cross-version-agent-session-wire` covers `agentSession.*`; neither reaches the - * session-tabs frame, which is where a paired client learns that a mirrored terminal - * is gone. This pairs the two halves of that surface across versions: - * - * - the HOST half changed — a host now ships a retirement proof on its own frame when - * no surface removal carries one (`attachRetirementProofsToSnapshot`); - * - the CLIENT half did not change, which this asserts by running both builds' ledger - * over the same frames rather than by reading the diff. - * - * The claim under test is the one written into the change: that this is Rule 1, because - * `retiredTerminalSurfaces` is an existing optional field on an existing path. Rule 3's - * fourth bullet says "a frame the host ... starts sending, on an existing path" is a wire - * change even with no codec movement, so the claim is checked against an actual old - * build rather than accepted. - * - * The pre-stack ref is pinned rather than derived: this contract needs a release from - * before the proof-only frame existed, which is the fallback - * docs/reference/remote-wire-compatibility.md sanctions for exactly this case. - */ -const PRE_STACK_REF = 'v1.4.199' - -const SUITE_TIMEOUT_MS = 180_000 - -const WORKTREE_ID = 'repo::/worktree' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const PARENT_TAB_ID = 'tab' -const PTY_ID = 'pty-left' -const TERMINAL_HANDLE = 'remote:terminal-handle-1' - -type Snapshot = { - worktree: string - publicationEpoch: string - snapshotVersion: number - activeGroupId: null - activeTabId: string | null - activeTabType: string | null - tabs: Record[] - retiredTerminalSurfaces?: Record[] -} - -type ProofLedger = { - appendRetiredTerminalSurfaceProofs: ( - existing: readonly Record[] | undefined, - retired: readonly Record[] - ) => Record[] - dropRetirementProofsForLiveSurfaces: ( - retired: readonly Record[], - tabs: readonly Record[] - ) => Record[] -} - -type HostProofPublisher = { - attachRetirementProofsToSnapshot?: ( - snapshot: Snapshot, - proofs: readonly Record[] - ) => Snapshot | null - retireTerminalSurfacesFromSnapshot: ( - args: Record - ) => { snapshot: Snapshot } | null -} - -type Build = { - label: string - ledger: ProofLedger - host: HostProofPublisher -} - -/** The surface as the host still holds it, before the close's two halves land. */ -function liveSnapshot(): Snapshot { - return { - worktree: WORKTREE_ID, - publicationEpoch: 'renderer', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: `tab::${LEAF_ID}`, - activeTabType: 'terminal', - tabs: [ - { - type: 'terminal', - id: `tab::${LEAF_ID}`, - parentTabId: PARENT_TAB_ID, - leafId: LEAF_ID, - ptyId: PTY_ID, - title: 'Left', - isActive: true - } - ] - } -} - -/** - * The renderer-first ordering, which is the one users hit: the close transaction already - * de-persisted the surface and republished without it, so the PTY exit that follows finds - * nothing left for persistence to accept. - */ -function snapshotAfterRendererRepublished(): Snapshot { - return { ...liveSnapshot(), snapshotVersion: 2, tabs: [], activeTabId: null, activeTabType: null } -} - -function exitProof(): Record { - return { - parentTabId: PARENT_TAB_ID, - leafId: LEAF_ID, - ptyId: PTY_ID, - terminal: TERMINAL_HANDLE, - incarnationId: 'inc-1' - } -} - -async function loadBuild(ref: string | null): Promise { - if (ref === null) { - const [ledger, proof, retirement] = await Promise.all([ - import('../../../src/shared/terminal-retirement-proof-ledger'), - import('../../../src/main/runtime/mobile-session-terminal-retirement-proof'), - import('../../../src/main/runtime/mobile-session-terminal-retirement') - ]) - return { - label: 'stack', - ledger: ledger as unknown as ProofLedger, - host: { ...proof, ...retirement } as unknown as HostProofPublisher - } - } - const checkout = await materializeReleaseCheckout(ref) - const [ledger, proof, retirement] = await Promise.all([ - importReleaseCheckoutModule(checkout, 'src/shared/terminal-retirement-proof-ledger.ts'), - importReleaseCheckoutModule( - checkout, - 'src/main/runtime/mobile-session-terminal-retirement-proof.ts' - ), - importReleaseCheckoutModule(checkout, 'src/main/runtime/mobile-session-terminal-retirement.ts') - ]) - return { - label: ref, - ledger: ledger as unknown as ProofLedger, - host: { ...proof, ...retirement } as unknown as HostProofPublisher - } -} - -/** - * What a host of this build publishes when the PTY exit lands after the renderer already - * dropped the surface. `null` means it publishes nothing, which is the stuck-pane defect. - */ -function hostPublishesOnExit(build: Build, snapshot: Snapshot): Snapshot | null { - const attach = build.host.attachRetirementProofsToSnapshot - if (typeof attach !== 'function') { - // Derived, not written down: this build's only route to a proof is the removal helper, - // and with nothing left to remove it declines to produce a frame. - return ( - build.host.retireTerminalSurfacesFromSnapshot({ - snapshot, - ptyId: PTY_ID, - exactSurfaces: [], - exactOnly: true, - retirementProofs: [exitProof()] - })?.snapshot ?? null - ) - } - return attach(snapshot, [exitProof()]) -} - -/** What this build's client retains after the host frame, i.e. the evidence it can act on. */ -function clientRetains(build: Build, frame: Snapshot | null): Record[] { - if (frame === null) { - return [] - } - return build.ledger.dropRetirementProofsForLiveSurfaces( - build.ledger.appendRetiredTerminalSurfaceProofs(undefined, frame.retiredTerminalSurfaces ?? []), - frame.tabs - ) -} - -let preStack: Build -let stack: Build - -beforeAll(async () => { - ;[preStack, stack] = await Promise.all([loadBuild(PRE_STACK_REF), loadBuild(null)]) -}, SUITE_TIMEOUT_MS) - -describe('cross-version session-tabs retirement proof', () => { - it('pairs the stack against a real pre-stack release', () => { - expect(preStack.label).toBe(PRE_STACK_REF) - expect(typeof preStack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function') - expect(typeof stack.ledger.dropRetirementProofsForLiveSurfaces).toBe('function') - // The anti-vacuous-pass oracle. Two builds that resolved to one module would make every - // pairing below a same-version run wearing a skew label, and all of them would pass. - expect(preStack.ledger).not.toBe(stack.ledger) - expect(preStack.ledger.dropRetirementProofsForLiveSurfaces).not.toBe( - stack.ledger.dropRetirementProofsForLiveSurfaces - ) - // Load-bearing for reading the old-host cells: they mean "this release cannot publish a - // proof-only frame", not "the helper happened to decline". Safe to state against a pinned - // legacy ref, which is what PRE_STACK_REF is. - expect(preStack.host.attachRetirementProofsToSnapshot).toBeUndefined() - expect(typeof stack.host.attachRetirementProofsToSnapshot).toBe('function') - }) - - it('old host against old client publishes no proof on the renderer-first close (the defect)', () => { - const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished()) - expect(frame).toBeNull() - expect(clientRetains(preStack, frame)).toEqual([]) - }) - - it('new host against new client publishes a proof the client retains (the fix)', () => { - const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished()) - expect(frame).not.toBeNull() - expect(clientRetains(stack, frame)).toEqual([exitProof()]) - }) - - it('new host against OLD client: the old client acts on the proof-only frame', () => { - const frame = hostPublishesOnExit(stack, snapshotAfterRendererRepublished()) - expect(frame).not.toBeNull() - // The claim under test. An old client that cannot act on this frame would leave the - // dead pane in its tab bar exactly as before the fix. - expect(clientRetains(preStack, frame)).toEqual([exitProof()]) - }) - - it('new host bumps snapshotVersion so a version-gating old client accepts the frame', () => { - const before = snapshotAfterRendererRepublished() - const frame = hostPublishesOnExit(stack, before) - // A client that drops a frame whose version did not advance would silently ignore the - // proof; this is what makes the proof-only frame reachable at all. - expect(frame?.snapshotVersion).toBeGreaterThan(before.snapshotVersion) - }) - - it('old host against NEW client degrades to the two-inventory route, with no crash', () => { - const frame = hostPublishesOnExit(preStack, snapshotAfterRendererRepublished()) - expect(frame).toBeNull() - expect(clientRetains(stack, frame)).toEqual([]) - }) - - it('both builds drop a proof whose surface is published live again, identically', () => { - const stillLive = liveSnapshot() - const proofs = [exitProof()] - // Rule 3 hazard: a proof naming a surface the host is still publishing must not retire - // it. Both builds must agree, or a skewed pairing retires a live pane. - expect(preStack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([]) - expect(stack.ledger.dropRetirementProofsForLiveSurfaces(proofs, stillLive.tabs)).toEqual([]) - }) - - it('re-delivering the same exit does not fan out a second frame', () => { - const first = hostPublishesOnExit(stack, snapshotAfterRendererRepublished()) - expect(first).not.toBeNull() - // A version bump carrying nothing new would wake every paired client for no reason. - expect(hostPublishesOnExit(stack, first as Snapshot)).toBeNull() - }) -}) diff --git a/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts deleted file mode 100644 index 6bd21f1ee8c..00000000000 --- a/tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -/** - * The downgrade direction for persisted worktree identity. - * - * Upgrade is the easy direction. The risk PR #19955 records is the other one: a user runs a new - * build, it writes durable state, then they roll back. State the new build wrote must stay - * readable by the old one. - * - * The stack widens `migrateWorktreeIdentity` to repoint the `worktreeId` INSIDE session rows the - * pre-stack build leaves pointing at the old id. A renamed worktree therefore leaves different - * bytes on disk depending on which build did the rename, with no wire change anywhere — Rule 3's - * shape applied to persistence, which is why it is measured here rather than reasoned about. - */ -const PRE_STACK_REF = 'v1.4.199' -const SUITE_TIMEOUT_MS = 180_000 - -const OLD_ID = 'repo::/worktrees/before' -const NEW_ID = 'repo::/worktrees/after' -const THIRD_ID = 'repo::/worktrees/third' -const PANE_KEY = 'pane-1' - -type Migrate = (state: Record, oldId: string, newId: string) => boolean -type Row = { worktreeId?: string } - -function sessionWithRows(): Record { - return { - tabsByWorktree: { [OLD_ID]: [] }, - sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, agent: 'claude' } }, - terminalSurfaceTombstonesByPaneKey: { [PANE_KEY]: { worktreeId: OLD_ID, retiredAt: 1 } }, - closedTerminalTabTombstonesByTabId: { tab: { worktreeId: OLD_ID, closedAt: 1 } }, - clientHostedBrowserCloseIntentsByEnvironment: { - env: [{ worktreeId: OLD_ID, url: 'https://example.test' }] - } - } -} - -function persistedStateAfterRename(): Record { - return { - worktreeMeta: { [OLD_ID]: { createdAt: 1 } }, - worktreeLineageById: {}, - workspaceLineageByChildKey: {}, - workspaceSession: sessionWithRows(), - workspaceSessionsByHostId: {}, - mobileClientTabSelectionsByDeviceId: {}, - ui: { showDotfilesByWorktree: {} } - } -} - -/** The `worktreeId` each row kind names after a migration, which is what downgrade turns on. */ -function rowsById(state: Record): Record { - const session = state.workspaceSession as Record - const record = (field: string, key: string): string | undefined => - (session[field] as Record | undefined)?.[key]?.worktreeId - return { - sleepingAgentSessionsByPaneKey: record('sleepingAgentSessionsByPaneKey', PANE_KEY), - terminalSurfaceTombstonesByPaneKey: record('terminalSurfaceTombstonesByPaneKey', PANE_KEY), - closedTerminalTabTombstonesByTabId: record('closedTerminalTabTombstonesByTabId', 'tab'), - clientHostedBrowserCloseIntentsByEnvironment: ( - session.clientHostedBrowserCloseIntentsByEnvironment as Record | undefined - )?.env?.[0]?.worktreeId - } -} - -let preStackMigrate: Migrate -let stackMigrate: Migrate - -beforeAll(async () => { - const checkout = await materializeReleaseCheckout(PRE_STACK_REF) - const [oldModule, newModule] = await Promise.all([ - importReleaseCheckoutModule( - checkout, - 'src/main/persistence/tracking-repos/worktree-identity-migration.ts' - ), - import('../../../src/main/persistence/tracking-repos/worktree-identity-migration') - ]) - preStackMigrate = oldModule.migrateWorktreeIdentity as Migrate - stackMigrate = newModule.migrateWorktreeIdentity as Migrate -}, SUITE_TIMEOUT_MS) - -describe('cross-version worktree identity downgrade', () => { - it('pairs two real builds', () => { - expect(typeof preStackMigrate).toBe('function') - expect(typeof stackMigrate).toBe('function') - // Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version. - expect(preStackMigrate).not.toBe(stackMigrate) - }) - - it('the pre-stack build repoints two of the four row kinds, and strands two', () => { - const state = persistedStateAfterRename() - expect(preStackMigrate(state, OLD_ID, NEW_ID)).toBe(true) - // Measured, not assumed: an earlier draft of this suite asserted the old build repointed - // nothing at all, and the probe that produced these four values is what corrected it. - expect(rowsById(state)).toEqual({ - sleepingAgentSessionsByPaneKey: NEW_ID, - terminalSurfaceTombstonesByPaneKey: NEW_ID, - closedTerminalTabTombstonesByTabId: OLD_ID, - clientHostedBrowserCloseIntentsByEnvironment: OLD_ID - }) - }) - - it('the stack repoints all four', () => { - const state = persistedStateAfterRename() - expect(stackMigrate(state, OLD_ID, NEW_ID)).toBe(true) - expect(rowsById(state)).toEqual({ - sleepingAgentSessionsByPaneKey: NEW_ID, - terminalSurfaceTombstonesByPaneKey: NEW_ID, - closedTerminalTabTombstonesByTabId: NEW_ID, - clientHostedBrowserCloseIntentsByEnvironment: NEW_ID - }) - }) - - it('DOWNGRADE: the old build reads new-build state without loss or throw', () => { - const state = persistedStateAfterRename() - stackMigrate(state, OLD_ID, NEW_ID) - // The rolled-back build renames again over state the new build wrote. Nothing it does not - // understand may throw, and no row may vanish. - expect(() => preStackMigrate(state, NEW_ID, THIRD_ID)).not.toThrow() - expect(rowsById(state)).toEqual({ - sleepingAgentSessionsByPaneKey: THIRD_ID, - terminalSurfaceTombstonesByPaneKey: THIRD_ID, - // The two this build cannot repoint stay where the NEW build put them — stale, but present, - // and no worse than this build's own renames already leave them. That is the #19955 check: - // new-build state does not break the old build. - closedTerminalTabTombstonesByTabId: NEW_ID, - clientHostedBrowserCloseIntentsByEnvironment: NEW_ID - }) - }) - - it('UPGRADE: the stack inherits, and does not resurrect, rows an old build stranded', () => { - const state = persistedStateAfterRename() - preStackMigrate(state, OLD_ID, NEW_ID) - stackMigrate(state, NEW_ID, THIRD_ID) - expect(rowsById(state)).toEqual({ - sleepingAgentSessionsByPaneKey: THIRD_ID, - terminalSurfaceTombstonesByPaneKey: THIRD_ID, - // Still on the id the old build stranded them under: the stack repoints from the id it is - // renaming, and these never reached it. It fixes new renames, not damage already on disk. - closedTerminalTabTombstonesByTabId: OLD_ID, - clientHostedBrowserCloseIntentsByEnvironment: OLD_ID - }) - }) - - it('neither build drops a row shape it does not recognise', () => { - const state = persistedStateAfterRename() - const session = state.workspaceSession as Record - session.someFutureFieldByKey = { k: { worktreeId: OLD_ID, fromANewerBuild: true } } - preStackMigrate(state, OLD_ID, NEW_ID) - expect((state.workspaceSession as Record).someFutureFieldByKey).toEqual({ - k: { worktreeId: OLD_ID, fromANewerBuild: true } - }) - }) -})