diff --git a/src/main/persistence/record-patch-versus-full-save.test.ts b/src/main/persistence/record-patch-versus-full-save.test.ts new file mode 100644 index 00000000000..370bde55dfd --- /dev/null +++ b/src/main/persistence/record-patch-versus-full-save.test.ts @@ -0,0 +1,536 @@ +// Ledger L104: each writer of the touched-records patch against a full save of main's model. They +// differ on 50 of the 51 legacy fixture x writer pairs, only on records the Loader rewrites; every +// pair is pinned exactly, so the patch goes only once no partition holds such rows. + +import { describe, expect, it, vi } from 'vitest' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { collectLayoutLeafIdsInOrder } from '../../shared/workspace-layout/terminal-pane-tree' +import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' +import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture' +import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load' +import { + diffStoredSession, + stableJson, + type LayoutLoadChange +} from '../../shared/workspace-layout/workspace-layout-load-report' +import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' +import { + relaySshSession, + WORKSPACE_LAYOUT_PROFILES, + type WorkspaceLayoutProfile +} from '../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import type * as RecordPatch from '../../shared/workspace-layout/workspace-layout-record-patch' +import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save' +import { SSH_KEY } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { moveTabAcrossPartitions } from './terminal-topology/cross-partition-tab-move' +import { + admitPaneToSession, + applyCommandToSession, + applyRenamesToSession +} from './terminal-topology/terminal-pane-admission' +import { windowViewPatchForMain, windowViewSaveForMain } from './window-save-view' + +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +/** Each patch a writer made: the stored partition, the full saves before and after, the patch. */ +type PatchCall = { + hostId: ExecutionHostId + stored: WorkspaceSessionState + before: WorkspaceSessionState + full: WorkspaceSessionState + patched: WorkspaceSessionState +} +const patches = vi.hoisted(() => { + const calls: PatchCall[] = [] + const hostIds: ExecutionHostId[] = [] + return { calls, hostIds } +}) +vi.mock('../../shared/workspace-layout/workspace-layout-record-patch', async (importOriginal) => { + const actual = await importOriginal() + return { + patchStoredSession: ( + stored: WorkspaceSessionState, + before: WorkspaceSessionState, + full: WorkspaceSessionState + ) => { + const patched = actual.patchStoredSession(stored, before, full) + patches.calls.push({ + hostId: patches.hostIds.shift() ?? LOCAL_EXECUTION_HOST_ID, + stored, + before, + full, + patched + }) + return patched + } + } +}) + +const SSH_HOST = toSshExecutionHostId('target-1') +const MOVED_FILE = { + filePath: '/Users/dev/api/readme.md', + relativePath: 'readme.md', + language: 'markdown' +} + +/** Records (or session fields) two partitions hold differently. */ +const recordsDiffering = (left: WorkspaceSessionState, right: WorkspaceSessionState) => + [ + ...new Set( + diffStoredSession(left, right).map(({ table, record, field }) => + table === 'session' ? `session/${field}` : `${table}/${record}` + ) + ) + ].sort() + +/** What the partition loads to; the revision is exempt, as every load's tombstone pass bumps it. */ +const reloaded = (hostId: ExecutionHostId, session: WorkspaceSessionState) => + stableJson({ + ...saveWorkspaceLayout(loadWorkspaceLayout(hostId, session, nameBasedLoadContext())), + terminalTopologyRevisionByRepoId: undefined + }) + +/** The patches one write made, each with the partition it wrote (in the order it patches them). */ +function captured(hostIds: readonly ExecutionHostId[], run: () => void): PatchCall[] { + patches.calls = [] + patches.hostIds = [...hostIds] + run() + return patches.calls +} + +type Writer = (profile: WorkspaceLayoutProfile) => PatchCall[][] + +function firstTerminal(profile: WorkspaceLayoutProfile) { + const stored = profile.build() + const loaded = loadWorkspaceLayout(profile.hostId, stored, nameBasedLoadContext()) + for (const [workspace, layout] of Object.entries(loaded.layout.workspaces)) { + const tab = layout.tabs.find((candidate) => candidate.kind === 'terminal') + if (tab?.kind === 'terminal') { + const leafId = collectLayoutLeafIdsInOrder(tab.panes.root)[0] + return { stored, workspace, tabId: tab.id, leafId, groupId: layout.groups[0]!.id } + } + } + return null +} + +const NEW_LEAF = '99999999-9999-4999-8999-999999999999' + +function layoutApplyCommands( + target: NonNullable> +): LayoutCommand[] { + const { workspace, tabId, leafId, groupId } = target + return [ + { type: 'renameTab', workspace, tabId, kind: 'custom', title: 'Renamed' }, + { + type: 'splitPane', + workspace, + tabId, + leafId: leafId!, + direction: 'vertical', + newLeafId: NEW_LEAF + }, + { type: 'closeTabs', workspace, tabIds: [tabId], force: true }, + { + type: 'createTerminalTab', + workspace, + tabId: 'tab-new', + leafId: NEW_LEAF, + groupId, + createdAt: 1 + } + ] +} + +const APPLY = 'layout.apply (terminal-pane-admission)' +const MOVE = 'cross-partition editor move' +const SAVE = 'window save (window-save-view)' +type WriterName = typeof APPLY | typeof MOVE | typeof SAVE + +const WRITERS: Record = { + [APPLY]: (profile) => { + const target = firstTerminal(profile) + if (!target) { + return [] + } + return [ + ...layoutApplyCommands(target).map((command) => + captured([profile.hostId], () => + applyCommandToSession(profile.hostId, target.stored, command) + ) + ), + captured([profile.hostId], () => + applyRenamesToSession(profile.hostId, target.stored, [ + { + type: 'renameTab', + workspace: target.workspace, + tabId: target.tabId, + kind: 'generated', + title: 'vim' + } + ]) + ), + captured([profile.hostId], () => + admitPaneToSession(profile.hostId, target.stored, { + type: 'splitPane', + workspace: target.workspace, + tabId: target.tabId, + leafId: target.leafId!, + direction: 'horizontal', + newLeafId: NEW_LEAF + }) + ) + ] + }, + [MOVE]: (profile) => { + const target = firstTerminal(profile) + if (!target || profile.hostId !== LOCAL_EXECUTION_HOST_ID) { + return [] + } + const opened = applyCommandToSession(profile.hostId, target.stored, { + type: 'openEditorTab', + workspace: target.workspace, + tabId: 'e-move', + fileId: MOVED_FILE.filePath, + contentType: 'editor', + groupId: target.groupId, + createdAt: 1, + file: MOVED_FILE + }).session! + return [ + captured([profile.hostId, SSH_HOST], () => + moveTabAcrossPartitions( + { hostId: profile.hostId, session: opened }, + { hostId: SSH_HOST, session: relaySshSession('target-1') }, + { + type: 'moveTabToWorkspace', + workspace: target.workspace, + toWorkspace: SSH_KEY, + tabs: [ + { + tabId: 'e-move', + newTabId: 'e-move', + newEntityId: `editor:${SSH_KEY}:${MOVED_FILE.filePath}` + } + ], + groupId: 'group-ssh', + file: MOVED_FILE + } + ) + ) + ] + }, + [SAVE]: (profile) => { + const stored = profile.build() + const firstTab = Object.values(stored.tabsByWorktree).flat()[0] + // The window echoes the stored partition with a new selection and a title its view reported. + const save: WorkspaceSessionState = { + ...stored, + activeTabId: firstTab?.id ?? null, + tabsByWorktree: Object.fromEntries( + Object.entries(stored.tabsByWorktree).map(([key, rows]) => [ + key, + rows.map((row, index) => (index === 0 ? { ...row, title: 'reported' } : row)) + ]) + ) + } + return [ + captured([profile.hostId], () => windowViewSaveForMain(stored, save, profile.hostId)), + captured([profile.hostId], () => + windowViewPatchForMain(stored, { tabsByWorktree: save.tabsByWorktree }, profile.hostId) + ) + ] + } +} + +/** The session list each record table is stored in: a record the load rewrites can change it. */ +const LIST_FIELD_OF_TABLE: Readonly> = { + row: 'tabsByWorktree', + terminalEntry: 'unifiedTabs', + entry: 'unifiedTabs', + group: 'tabGroups', + layout: 'terminalLayoutsByTabId', + file: 'openFilesByWorktree', + browser: 'browserTabsByWorktree' +} + +/** + * Whether a session list field's workspace keys differ only where the load rewrote a record of + * that list: a full save drops a workspace's emptied list that the patch keeps with its stored row. + */ +function listKeysFollowLoad(change: LayoutLoadChange, normalized: readonly string[]): boolean { + const tables = Object.keys(LIST_FIELD_OF_TABLE).filter( + (table) => LIST_FIELD_OF_TABLE[table] === change.field + ) + const keys = (value: unknown) => (Array.isArray(value) ? value.map(String) : []) + const before = keys(change.before) + const after = keys(change.after) + const moved = [ + ...before.filter((key) => !after.includes(key)), + ...after.filter((key) => !before.includes(key)) + ] + return ( + tables.length > 0 && + moved.length > 0 && + moved.every((key) => + normalized.some((record) => tables.some((table) => record.startsWith(`${table}/${key}|`))) + ) + ) +} + +/** The records a writer's patches differ from its full saves in. */ +function differingRecords(calls: readonly PatchCall[]): string[] { + const differing = new Set() + for (const call of calls) { + const normalized = recordsDiffering(call.stored, call.before) + const differs = recordsDiffering(call.full, call.patched) + const listKeyChanges = diffStoredSession(call.full, call.patched).filter( + (change) => change.table === 'session' && listKeysFollowLoad(change, normalized) + ) + const allowed = [...normalized, ...listKeyChanges.map((change) => `session/${change.field}`)] + // Only records the load rewrites from what the stored partition says can differ. + expect(differs.filter((record) => !allowed.includes(record))).toEqual([]) + // Either way the partition loads to main's model. + expect(reloaded(call.hostId, call.patched)).toBe(reloaded(call.hostId, call.full)) + differs.forEach((record) => differing.add(record)) + } + return [...differing].sort() +} + +const GIT = 'repo-1::/Users/dev/orca' +// The two a switch-off window restores differently from their full save, dropping tab-b or adding +// it (legacy-partition-full-save-hydration.test.ts); the others differ only in records on disk. +const SURFACE_TOMBSTONE = 'legacy surface tombstone on a pane still showing its terminal' +const HIDDEN_ROW = 'row without a tab-bar entry or a terminal' + +/** Every legacy fixture's difference per writer; absent is none. */ +const PINNED: Readonly>>> = { + [HIDDEN_ROW]: { + [APPLY]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`], + [MOVE]: [`entry/${GIT}|$order`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`] + }, + 'row without a tab-bar entry or a terminal, selected': { + [APPLY]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`], + [MOVE]: [`entry/${GIT}|$order`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`] + }, + 'row without a tab-bar entry, its terminal in a workspace slept at shutdown': { + [APPLY]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`], + [MOVE]: [`entry/${GIT}|$order`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`] + }, + 'row without a tab-bar entry, still bound to a terminal': { + [APPLY]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`], + [MOVE]: [`entry/${GIT}|$order`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`] + }, + 'row without a tab-bar entry, its terminal only on a relay': { + [APPLY]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`], + [MOVE]: [`entry/${GIT}|$order`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`] + }, + 'row without a tab-bar entry, with a sleeping agent': { + [APPLY]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`], + [MOVE]: [`entry/${GIT}|$order`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [`entry/${GIT}|$order`, `group/${GIT}|g1`, `terminalEntry/${GIT}|tab-b`] + }, + 'row bound only through its pane, without a tab-bar entry': { + [APPLY]: [ + `entry/${GIT}|$order`, + `group/${GIT}|g1`, + `row/${GIT}|tab-b`, + `terminalEntry/${GIT}|tab-b` + ], + [MOVE]: [`entry/${GIT}|$order`, `row/${GIT}|tab-b`, `terminalEntry/${GIT}|tab-b`], + [SAVE]: [ + `entry/${GIT}|$order`, + `group/${GIT}|g1`, + `row/${GIT}|tab-b`, + `terminalEntry/${GIT}|tab-b` + ] + }, + 'legacy row repeating a tab-bar tab’s terminal': { + [APPLY]: ['layout/tab-legacy', `row/${GIT}|$order`, `row/${GIT}|tab-legacy`], + [MOVE]: ['layout/tab-legacy', `row/${GIT}|$order`, `row/${GIT}|tab-legacy`], + [SAVE]: ['layout/tab-legacy', `row/${GIT}|$order`, `row/${GIT}|tab-legacy`] + }, + 'legacy row repeating a tab-bar tab’s terminal, selected': { + [APPLY]: [ + 'layout/tab-legacy', + `row/${GIT}|$order`, + `row/${GIT}|tab-legacy`, + 'session/activeTabId', + 'session/activeTabIdByWorktree' + ], + [MOVE]: [ + 'layout/tab-legacy', + `row/${GIT}|$order`, + `row/${GIT}|tab-legacy`, + 'session/activeTabId', + 'session/activeTabIdByWorktree' + ], + [SAVE]: [ + 'layout/tab-legacy', + `row/${GIT}|$order`, + `row/${GIT}|tab-legacy`, + 'session/activeTabId', + 'session/activeTabIdByWorktree' + ] + }, + 'legacy split row sharing one pane’s terminal with a tab-bar tab': { + [APPLY]: [ + `entry/${GIT}|$order`, + `group/${GIT}|g1`, + 'layout/tab-legacy', + `row/${GIT}|tab-legacy`, + `terminalEntry/${GIT}|tab-legacy` + ], + [MOVE]: [ + `entry/${GIT}|$order`, + 'layout/tab-legacy', + `row/${GIT}|tab-legacy`, + `terminalEntry/${GIT}|tab-legacy` + ], + [SAVE]: [ + `entry/${GIT}|$order`, + `group/${GIT}|g1`, + 'layout/tab-legacy', + `row/${GIT}|tab-legacy`, + `terminalEntry/${GIT}|tab-legacy` + ] + }, + 'row terminal differs from its focused pane’s': { + [APPLY]: [`row/${GIT}|tab-a`], + [MOVE]: [`row/${GIT}|tab-a`], + [SAVE]: [`row/${GIT}|tab-a`] + }, + 'row with no terminal while its pane has one': { + [APPLY]: [`row/${GIT}|tab-a`], + [MOVE]: [`row/${GIT}|tab-a`], + [SAVE]: [`row/${GIT}|tab-a`] + }, + 'tab listed by two groups, its tab-bar entry naming the second': { + [APPLY]: [`group/${GIT}|g1`], + [SAVE]: [`group/${GIT}|g1`] + }, + 'session from before the tab bar, with an open file': { + [APPLY]: [ + `entry/${GIT}|$order`, + `group/${GIT}|$order`, + `group/${GIT}|1a5387f9-46df-5b0c-807c-4cf31d90223e`, + 'session/tabGroupLayouts', + 'session/tabGroups', + 'session/unifiedTabs', + `terminalEntry/${GIT}|tab-a`, + `terminalEntry/${GIT}|tab-b` + ], + [MOVE]: [ + `entry/${GIT}|$order`, + 'session/tabGroupLayouts', + `terminalEntry/${GIT}|tab-a`, + `terminalEntry/${GIT}|tab-b` + ], + [SAVE]: [ + `entry/${GIT}|$order`, + `group/${GIT}|$order`, + `group/${GIT}|1a5387f9-46df-5b0c-807c-4cf31d90223e`, + 'session/tabGroupLayouts', + 'session/tabGroups', + `terminalEntry/${GIT}|tab-b` + ] + }, + 'session from before the tab bar, a row with no terminal': { + [APPLY]: [ + `entry/${GIT}|$order`, + `group/${GIT}|$order`, + `group/${GIT}|1a5387f9-46df-5b0c-807c-4cf31d90223e`, + 'session/tabGroupLayouts', + 'session/tabGroups', + 'session/unifiedTabs', + `terminalEntry/${GIT}|tab-a`, + `terminalEntry/${GIT}|tab-b` + ], + [MOVE]: [ + `entry/${GIT}|$order`, + 'session/tabGroupLayouts', + `terminalEntry/${GIT}|tab-a`, + `terminalEntry/${GIT}|tab-b` + ], + [SAVE]: [ + `entry/${GIT}|$order`, + `group/${GIT}|$order`, + `group/${GIT}|1a5387f9-46df-5b0c-807c-4cf31d90223e`, + 'session/tabGroupLayouts', + 'session/tabGroups', + `terminalEntry/${GIT}|tab-b` + ] + }, + [SURFACE_TOMBSTONE]: { + [APPLY]: [ + `entry/${GIT}|$order`, + `group/${GIT}|g1`, + 'layout/tab-b', + `row/${GIT}|$order`, + `row/${GIT}|tab-b`, + 'session/terminalSurfaceTombstonesByPaneKey', + 'session/terminalTopologyRevisionByRepoId', + 'session/unifiedTabs', + `terminalEntry/${GIT}|tab-b` + ], + [MOVE]: [ + `entry/${GIT}|$order`, + 'layout/tab-b', + `row/${GIT}|$order`, + `row/${GIT}|tab-b`, + 'session/terminalSurfaceTombstonesByPaneKey', + 'session/terminalTopologyRevisionByRepoId', + `terminalEntry/${GIT}|tab-b` + ], + [SAVE]: [ + `entry/${GIT}|$order`, + `group/${GIT}|g1`, + 'layout/tab-b', + `row/${GIT}|$order`, + `row/${GIT}|tab-b`, + 'session/terminalSurfaceTombstonesByPaneKey', + 'session/terminalTopologyRevisionByRepoId', + `terminalEntry/${GIT}|tab-b` + ] + }, + 'tab-bar label differing from the row title': { + [APPLY]: [`row/${GIT}|tab-a`], + [MOVE]: [`row/${GIT}|tab-a`], + [SAVE]: [`row/${GIT}|tab-a`] + } +} + +describe.each(([APPLY, MOVE, SAVE] as const).map((name) => [name, WRITERS[name]] as const))( + '%s: touched-records patch vs a full save', + (writer, write) => { + it.each(WORKSPACE_LAYOUT_PROFILES.map((profile) => [profile.name, profile] as const))( + '%s: the same session, key order aside, as its load normalizes nothing', + (_name, profile) => { + for (const call of write(profile).flat()) { + expect(recordsDiffering(call.stored, call.before)).toEqual([]) + expect(stableJson(call.patched)).toBe(stableJson(call.full)) + } + } + ) + + it.each(LEGACY_WORKSPACE_LAYOUT_PROFILES.map((profile) => [profile.name, profile] as const))( + '%s: differs only on records the load normalizes', + (_name, profile) => { + const calls = write(profile).flat() + expect(calls.length).toBeGreaterThan(0) + const differing = differingRecords(calls) + // Exact both ways: a fixture that converges, or a new divergence, fails here. + expect(differing).toEqual(PINNED[profile.name]?.[writer] ?? []) + } + ) + } +) diff --git a/src/main/persistence/terminal-topology/layout-owner-written-session-golden.test.ts b/src/main/persistence/terminal-topology/layout-owner-written-session-golden.test.ts new file mode 100644 index 00000000000..e3dbb18d924 --- /dev/null +++ b/src/main/persistence/terminal-topology/layout-owner-written-session-golden.test.ts @@ -0,0 +1,185 @@ +import type * as NodeCrypto from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, + LAYOUT_OWNER_WRITTEN_SESSION_WORKTREE_ID as WORKTREE +} from '../../../shared/__fixtures__/layout-owner-written-session-golden-paths' +import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { windowViewSaveForMain } from '../window-save-view' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) +// Ids main mints while it applies the commands are numbered, so the written bytes are stable. +const minting = vi.hoisted(() => ({ on: false, next: 0 })) +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + randomUUID: () => + minting.on + ? `00000000-0000-4000-8000-${String(++minting.next).padStart(12, '0')}` + : actual.randomUUID() + } +}) + +const UPDATE_ENV = 'ORCA_UPDATE_LAYOUT_OWNER_GOLDEN' +const leaf = (n: number): string => `${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111` +const on = { workspace: WORKTREE } +const directories: string[] = [] + +afterEach(() => { + minting.on = false + vi.useRealTimers() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** Two groups, a split, an editor file, a browser tab, a renamed tab and a closed tab. */ +const COMMANDS: LayoutCommand[] = [ + { ...on, type: 'createTerminalTab', tabId: 'tab-shell', leafId: leaf(1), createdAt: 1 }, + { ...on, type: 'createTerminalTab', tabId: 'tab-build', leafId: leaf(2), createdAt: 2 }, + { + ...on, + type: 'splitPane', + tabId: 'tab-shell', + leafId: leaf(1), + direction: 'vertical', + newLeafId: leaf(3) + }, + { ...on, type: 'renameTab', tabId: 'tab-build', kind: 'custom', title: 'Build' }, + { + ...on, + type: 'openEditorTab', + tabId: 'tab-readme', + fileId: '/fixture/local/README.md', + contentType: 'editor', + createdAt: 3, + file: { filePath: '/fixture/local/README.md', relativePath: 'README.md', language: 'markdown' } + }, + { + ...on, + type: 'openBrowserTab', + tabId: 'tab-docs', + browserTabId: 'browser-docs', + pageId: 'page-docs', + createdAt: 4, + browserCreatedAt: 4 + }, + { ...on, type: 'createTerminalTab', tabId: 'tab-scratch', leafId: leaf(4), createdAt: 5 }, + { ...on, type: 'closeTabs', tabIds: ['tab-scratch'], force: true, recordClose: 'user' } +] + +function windowSave(stored: WorkspaceSessionState): WorkspaceSessionState { + const page = { + id: 'page-docs', + workspaceId: 'browser-docs', + worktreeId: WORKTREE, + url: 'https://example.com/docs', + title: 'Docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 4 + } + return { + ...stored, + activeRepoId: 'repo-local', + activeWorktreeId: WORKTREE, + activeTabId: 'tab-shell', + browserPagesByWorkspace: { 'browser-docs': [page] }, + browserTabsByWorktree: { + [WORKTREE]: (stored.browserTabsByWorktree?.[WORKTREE] ?? []).map((tab) => ({ + ...tab, + url: page.url, + title: page.title + })) + } + } +} + +async function apply(store: Store, command: LayoutCommand): Promise { + const result = await store.applyLayoutCommand(command) + expect(result.ok, command.type).toBe(true) +} + +/** The session main wrote to disk, as the profile database holds it. */ +function writtenSession(directory: string): string { + const authority = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + const serialized = authority.readSerializedState() + authority.close() + const profile: { workspaceSession: unknown } = JSON.parse(serialized ?? '{}') + return `${JSON.stringify(profile.workspaceSession, null, 2)}\n` +} + +// Proves the current switch-off code reads a switch-on save, not that older released builds do. +describe('the session a layout-owner build writes (downgrade probe, ledger L8)', () => { + it('is the checked-in golden, byte for byte', async () => { + expect(WORKTREE).toBe(FIXTURE_GIT_WORKTREE_ID) + const directory = mkdtempSync(join(tmpdir(), 'orca-layout-owner-golden-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + vi.useFakeTimers({ toFake: ['Date'], now: 1_700_000_000_000 }) + minting.on = true + + for (const command of COMMANDS.slice(0, 4)) { + await apply(store, command) + } + const shellGroup = store.getWorkspaceSession().tabGroups?.[WORKTREE]?.[0]?.id + await apply(store, { + ...on, + type: 'splitGroup', + tabId: 'tab-build', + besideGroupId: shellGroup!, + direction: 'right' + }) + for (const command of COMMANDS.slice(4)) { + await apply(store, command) + } + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: 'tab-shell', + leafId: leaf(1), + ptyId: 'pty-shell', + incarnationId: 'inc-shell' + }) + minting.on = false + // The window's own save (session:set): its selection and the page its browser tab shows. + const stored = store.getWorkspaceSession() + store.setWorkspaceSession(windowViewSaveForMain(stored, windowSave(stored), undefined)) + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + + const written = writtenSession(directory) + // Times are pinned (fake Date) and minted ids numbered; no machine path may reach the golden. + expect(written).not.toContain(tmpdir()) + if (process.env[UPDATE_ENV] === '1') { + writeFileSync(LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, written) + } + const golden = JSON.parse(readFileSync(LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, 'utf8')) + // Re-serialized so the formatter's line breaks do not count; key order and values do. + expect( + written, + `the switch-on save format changed: regenerate with ${UPDATE_ENV}=1, then the switch-off ` + + 'restore tests (layout-owner-written-session-switch-off-load, ' + + 'layout-owner-written-session-hydration) must still pass: they are the downgrade proof' + ).toBe(`${JSON.stringify(golden, null, 2)}\n`) + }) +}) diff --git a/src/main/persistence/terminal-topology/layout-owner-written-session-switch-off-load.test.ts b/src/main/persistence/terminal-topology/layout-owner-written-session-switch-off-load.test.ts new file mode 100644 index 00000000000..956a965320f --- /dev/null +++ b/src/main/persistence/terminal-topology/layout-owner-written-session-switch-off-load.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH } from '../../../shared/__fixtures__/layout-owner-written-session-golden-paths' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { emptyTerminalSessionProfile, openTopologyStore } from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'a switch-off build loading the session a layout-owner build wrote (downgrade probe, L8)', + () => { + it('hands the window the session exactly as written', async () => { + const golden: WorkspaceSessionState = JSON.parse( + readFileSync(LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, 'utf8') + ) + const directory = mkdtempSync(join(tmpdir(), 'orca-layout-owner-downgrade-')) + directories.push(directory) + const profile = { ...JSON.parse(emptyTerminalSessionProfile()), workspaceSession: golden } + + const store = await openTopologyStore(directory, JSON.stringify(profile)) + + expect(stableJson(store.getWorkspaceSession())).toBe(stableJson(golden)) + await store.freezeWritesAsync() + }) + } +) diff --git a/src/renderer/src/store/slices/layout-owner-written-session-hydration.test.ts b/src/renderer/src/store/slices/layout-owner-written-session-hydration.test.ts new file mode 100644 index 00000000000..affc12a711b --- /dev/null +++ b/src/renderer/src/store/slices/layout-owner-written-session-hydration.test.ts @@ -0,0 +1,124 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { reconcileHydratedWorkspaceTabModels } from '../../app-shell/reconcile-hydrated-workspace-tab-models' +import { + LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, + LAYOUT_OWNER_WRITTEN_SESSION_WORKTREE_ID as WT +} from '../../../../shared/__fixtures__/layout-owner-written-session-golden-paths' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { createTestStore, makeWorktree } from './store-test-helpers' +import { createStoreSessionMockApi } from './store-session-test-harness' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreSessionMockApi() + +/** The startup hydration a switch-off window runs (use-app-startup-hydration.ts). */ +function restoredWindow(session: WorkspaceSessionState) { + const store = createTestStore() + store.setState({ + repos: [ + { + id: 'repo-local', + path: '/fixture/local', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + ], + worktreesByRepo: { + 'repo-local': [makeWorktree({ id: WT, repoId: 'repo-local', path: '/fixture/local' })] + } + }) + const actions = store.getState() + actions.hydrateWorkspaceSession(session, {}) + actions.hydrateTabsSession(session, {}) + actions.hydrateEditorSession(session, {}) + actions.hydrateBrowserSession(session, {}) + reconcileHydratedWorkspaceTabModels(session, store.getState().reconcileWorktreeTabModels) + return store.getState() +} + +const leaf = (n: number): string => `${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111` + +describe.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'a switch-off window restoring the session a layout-owner build wrote (downgrade probe, L8)', + () => { + it('shows the same tabs, panes, groups, editor file and browser tab', () => { + const golden: WorkspaceSessionState = JSON.parse( + readFileSync(LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, 'utf8') + ) + + const state = restoredWindow(golden) + + expect( + state.tabsByWorktree[WT]?.map((tab) => [tab.id, tab.customTitle ?? tab.title]) + ).toEqual([ + ['tab-shell', 'Terminal 1'], + ['tab-build', 'Build'] + ]) + const panes = (tabId: string) => { + const layout = state.terminalLayoutsByTabId[tabId] + return { + leaves: collectLayoutLeafIdsInOrder(layout?.root), + active: layout?.activeLeafId, + bindings: layout?.ptyIdsByLeafId + } + } + expect(state.terminalLayoutsByTabId['tab-shell']?.root).toMatchObject({ + type: 'split', + direction: 'vertical' + }) + // The pane binding must survive the downgrade, or the shell would not reattach. + expect(panes('tab-shell')).toEqual({ + leaves: [leaf(1), leaf(3)], + active: leaf(1), + bindings: { [leaf(1)]: 'pty-shell' } + }) + expect(panes('tab-build')).toEqual({ leaves: [leaf(2)], active: leaf(2), bindings: {} }) + const groups = state.groupsByWorktree[WT] ?? [] + expect(groups.map((group) => [group.tabOrder, group.activeTabId])).toEqual([ + [['tab-shell', 'tab-readme', 'tab-docs'], 'tab-shell'], + [['tab-build'], 'tab-build'] + ]) + expect(state.layoutByWorktree[WT]).toMatchObject({ type: 'split', direction: 'horizontal' }) + const byId = new Map(state.unifiedTabsByWorktree[WT]?.map((tab) => [tab.id, tab])) + expect( + groups.map((group) => + group.tabOrder.map((id) => [byId.get(id)?.contentType, byId.get(id)?.groupId]) + ) + ).toEqual([ + [ + ['terminal', groups[0]?.id], + ['editor', groups[0]?.id], + ['browser', groups[0]?.id] + ], + [['terminal', groups[1]?.id]] + ]) + expect(byId.size).toBe(4) + expect( + state.unifiedTabsByWorktree[WT]?.find((tab) => tab.id === 'tab-build')?.customLabel + ).toBe('Build') + expect(state.openFiles.map((file) => [file.filePath, file.mode])).toEqual([ + ['/fixture/local/README.md', 'edit'] + ]) + expect(state.browserTabsByWorktree[WT]?.map((tab) => [tab.id, tab.pageIds])).toEqual([ + ['browser-docs', ['page-docs']] + ]) + expect(state.browserPagesByWorkspace['browser-docs']?.map((page) => page.url)).toEqual([ + 'https://example.com/docs' + ]) + expect([state.activeWorktreeId, state.activeTabId]).toEqual([WT, 'tab-shell']) + // The closed tab stays closed. + expect(JSON.stringify(state.unifiedTabsByWorktree)).not.toContain('tab-scratch') + expect(state.tabsByWorktree[WT]?.some((tab) => tab.id === 'tab-scratch')).toBe(false) + }) + } +) diff --git a/src/renderer/src/store/slices/legacy-partition-full-save-hydration.test.ts b/src/renderer/src/store/slices/legacy-partition-full-save-hydration.test.ts new file mode 100644 index 00000000000..b458517943f --- /dev/null +++ b/src/renderer/src/store/slices/legacy-partition-full-save-hydration.test.ts @@ -0,0 +1,64 @@ +// Ledger L104's reason, as a switch-off window shows it: a stored partition an older writer left +// restores differently once a full save of main's model rewrites it, so main patches only the +// records a change touches (record-patch-versus-full-save.test.ts). + +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { reconcileHydratedWorkspaceTabModels } from '../../app-shell/reconcile-hydrated-workspace-tab-models' +import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture' +import { loadWorkspaceLayout } from '../../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../../shared/workspace-layout/workspace-layout-minted-ids' +import { saveWorkspaceLayout } from '../../../../shared/workspace-layout/workspace-layout-save' +import { GIT_KEY } from '../../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { createTestStore, makeWorktree } from './store-test-helpers' +import { createStoreSessionMockApi } from './store-session-test-harness' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreSessionMockApi() + +/** The terminal tabs a window restoring `session` shows, in tab-bar order. */ +function restoredTabs(session: WorkspaceSessionState): string[][] { + const store = createTestStore() + store.setState({ + repos: [ + { id: 'repo-1', path: '/Users/dev/orca', displayName: 'orca', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: GIT_KEY, repoId: 'repo-1', path: '/Users/dev/orca' })] + } + }) + store.getState().hydrateWorkspaceSession(structuredClone(session), {}) + store.getState().hydrateTabsSession(structuredClone(session), {}) + reconcileHydratedWorkspaceTabModels(session, store.getState().reconcileWorktreeTabModels) + return (store.getState().groupsByWorktree[GIT_KEY] ?? []).map((group) => group.tabOrder) +} + +function storedAndFullSave(name: string) { + const profile = LEGACY_WORKSPACE_LAYOUT_PROFILES.find((candidate) => candidate.name === name)! + const stored = profile.build() + const full = saveWorkspaceLayout( + loadWorkspaceLayout(profile.hostId, stored, nameBasedLoadContext()) + ) + return { stored: restoredTabs(stored), full: restoredTabs(full) } +} + +describe('a switch-off window restoring a legacy partition, as stored and after a full save', () => { + it('loses a tab whose pane kept an old surface tombstone', () => { + expect( + storedAndFullSave('legacy surface tombstone on a pane still showing its terminal') + ).toEqual({ stored: [['tab-a', 'tab-b']], full: [['tab-a']] }) + }) + + it('gains a row the tab bar never listed', () => { + expect(storedAndFullSave('row without a tab-bar entry or a terminal')).toEqual({ + stored: [['tab-a']], + full: [['tab-a', 'tab-b']] + }) + }) +}) diff --git a/src/shared/__fixtures__/layout-owner-written-session-golden-paths.ts b/src/shared/__fixtures__/layout-owner-written-session-golden-paths.ts new file mode 100644 index 00000000000..88a828a9434 --- /dev/null +++ b/src/shared/__fixtures__/layout-owner-written-session-golden-paths.ts @@ -0,0 +1,12 @@ +// The session a layout-owner build's main writes for a representative set of layout commands +// (layout-owner-written-session-golden.test.ts), which a switch-off build must restore unchanged. + +import { join } from 'node:path' + +export const LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH = join( + __dirname, + 'layout-owner-written-session.json' +) + +/** The fixture profile's registered git worktree (terminal-topology-profile-fixture.ts). */ +export const LAYOUT_OWNER_WRITTEN_SESSION_WORKTREE_ID = 'repo-local::/fixture/local' diff --git a/src/shared/__fixtures__/layout-owner-written-session.json b/src/shared/__fixtures__/layout-owner-written-session.json new file mode 100644 index 00000000000..e08e83a946c --- /dev/null +++ b/src/shared/__fixtures__/layout-owner-written-session.json @@ -0,0 +1,218 @@ +{ + "activeRepoId": "repo-local", + "activeWorktreeId": "repo-local::/fixture/local", + "activeTabId": "tab-shell", + "tabsByWorktree": { + "repo-local::/fixture/local": [ + { + "id": "tab-shell", + "ptyId": "pty-shell", + "title": "Terminal 1", + "defaultTitle": "Terminal 1", + "worktreeId": "repo-local::/fixture/local", + "customTitle": null, + "color": null, + "sortOrder": 0, + "createdAt": 1 + }, + { + "id": "tab-build", + "ptyId": null, + "title": "Terminal 2", + "defaultTitle": "Terminal 2", + "worktreeId": "repo-local::/fixture/local", + "customTitle": "Build", + "color": null, + "sortOrder": 1, + "createdAt": 2 + } + ] + }, + "terminalLayoutsByTabId": { + "tab-shell": { + "root": { + "type": "split", + "direction": "vertical", + "first": { + "type": "leaf", + "leafId": "11111111-1111-4111-8111-111111111111" + }, + "second": { + "type": "leaf", + "leafId": "33333333-1111-4111-8111-111111111111" + } + }, + "activeLeafId": "11111111-1111-4111-8111-111111111111", + "expandedLeafId": null, + "ptyIdsByLeafId": { + "11111111-1111-4111-8111-111111111111": "pty-shell" + } + }, + "tab-build": { + "root": { + "type": "leaf", + "leafId": "22222222-1111-4111-8111-111111111111" + }, + "activeLeafId": "22222222-1111-4111-8111-111111111111", + "expandedLeafId": null, + "ptyIdsByLeafId": {} + } + }, + "openFilesByWorktree": { + "repo-local::/fixture/local": [ + { + "filePath": "/fixture/local/README.md", + "relativePath": "README.md", + "language": "markdown", + "worktreeId": "repo-local::/fixture/local" + } + ] + }, + "markdownFrontmatterVisible": {}, + "browserTabsByWorktree": { + "repo-local::/fixture/local": [ + { + "id": "browser-docs", + "sessionProfileId": null, + "pageIds": ["page-docs"], + "createdAt": 4, + "worktreeId": "repo-local::/fixture/local", + "url": "https://example.com/docs", + "title": "Docs", + "loading": false, + "faviconUrl": null, + "canGoBack": false, + "canGoForward": false, + "loadError": null + } + ] + }, + "browserPagesByWorkspace": { + "browser-docs": [ + { + "id": "page-docs", + "workspaceId": "browser-docs", + "worktreeId": "repo-local::/fixture/local", + "url": "https://example.com/docs", + "title": "Docs", + "loading": false, + "faviconUrl": null, + "canGoBack": false, + "canGoForward": false, + "loadError": null, + "createdAt": 4 + } + ] + }, + "activeBrowserTabIdByWorktree": {}, + "activeFileIdByWorktree": {}, + "activeTabTypeByWorktree": {}, + "browserUrlHistory": [], + "defaultTerminalTabsAppliedByWorktreeId": { + "repo-local::/fixture/local": true + }, + "unifiedTabs": { + "repo-local::/fixture/local": [ + { + "id": "tab-shell", + "entityId": "tab-shell", + "groupId": "aa34db2e-c345-5d9d-a3e5-ddd4ac94492b", + "worktreeId": "repo-local::/fixture/local", + "executionHostId": "local", + "contentType": "terminal", + "label": "Terminal 1", + "customLabel": null, + "color": null, + "sortOrder": 0, + "createdAt": 1 + }, + { + "id": "tab-readme", + "entityId": "/fixture/local/README.md", + "groupId": "aa34db2e-c345-5d9d-a3e5-ddd4ac94492b", + "worktreeId": "repo-local::/fixture/local", + "executionHostId": "local", + "contentType": "editor", + "label": "", + "customLabel": null, + "color": null, + "sortOrder": 1, + "createdAt": 3 + }, + { + "id": "tab-docs", + "entityId": "browser-docs", + "groupId": "aa34db2e-c345-5d9d-a3e5-ddd4ac94492b", + "worktreeId": "repo-local::/fixture/local", + "executionHostId": "local", + "contentType": "browser", + "label": "", + "customLabel": null, + "color": null, + "sortOrder": 2, + "createdAt": 4 + }, + { + "id": "tab-build", + "entityId": "tab-build", + "groupId": "00000000-0000-4000-8000-000000000001", + "worktreeId": "repo-local::/fixture/local", + "executionHostId": "local", + "contentType": "terminal", + "label": "Terminal 2", + "customLabel": "Build", + "color": null, + "sortOrder": 0, + "createdAt": 2 + } + ] + }, + "tabGroups": { + "repo-local::/fixture/local": [ + { + "id": "aa34db2e-c345-5d9d-a3e5-ddd4ac94492b", + "worktreeId": "repo-local::/fixture/local", + "activeTabId": "tab-shell", + "tabOrder": ["tab-shell", "tab-readme", "tab-docs"] + }, + { + "id": "00000000-0000-4000-8000-000000000001", + "worktreeId": "repo-local::/fixture/local", + "activeTabId": "tab-build", + "tabOrder": ["tab-build"] + } + ] + }, + "tabGroupLayouts": { + "repo-local::/fixture/local": { + "type": "split", + "direction": "horizontal", + "first": { + "type": "leaf", + "groupId": "aa34db2e-c345-5d9d-a3e5-ddd4ac94492b" + }, + "second": { + "type": "leaf", + "groupId": "00000000-0000-4000-8000-000000000001" + }, + "ratio": 0.5 + } + }, + "terminalTopologyRevisionByRepoId": { + "repo-local": 5 + }, + "closedTerminalTabTombstonesByTabId": { + "tab-scratch": { + "reason": "user", + "closedAt": 1700000000000, + "worktreeId": "repo-local::/fixture/local" + } + }, + "terminalPtyIncarnationsByPaneKey": { + "tab-shell:11111111-1111-4111-8111-111111111111": "inc-shell" + }, + "terminalSurfaceTombstonesByPaneKey": {}, + "activeTabIdByWorktree": { + "repo-local::/fixture/local": "tab-shell" + } +}