diff --git a/src/renderer/src/app-shell/startup-window-parity.test.ts b/src/renderer/src/app-shell/startup-window-parity.test.ts new file mode 100644 index 00000000000..8fd5d1b5774 --- /dev/null +++ b/src/renderer/src/app-shell/startup-window-parity.test.ts @@ -0,0 +1,141 @@ +// Window parity: for each stored profile, the window's startup hydration of the session as stored +// and of the shared Loader's rewrite of it (load then save) must show the same thing. Where they +// differ today, the difference is listed below with the Loader rule that causes it. + +import { describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture' +import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load' +import { WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import { + bootWindowFromPartitions, + diffStartupWindowViews, + projectStartupWindowView +} from './startup-window-view.test-fixture' + +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() + +const GIT = 'repo-1::/Users/dev/orca' +const terminal = (tabId: string) => + ['title', 'panes', 'focused pane', 'reattaches'].map( + (field) => `${GIT} terminal ${tabId} ${field}` + ) + +type KnownDifference = { + fixture: string + /** The Loader rule that makes the window show something else. */ + rule: string + /** View paths that differ; see `projectStartupWindowView`. */ + paths: string[] +} + +/** Today's differences. Entries may only be removed: fix the rule, then delete its entry. */ +const KNOWN_DIFFERENCES: readonly KnownDifference[] = [ + { + fixture: 'row without a tab-bar entry or a terminal', + rule: 'ghost tab: the Loader gives every row a tab-bar entry; the window adopts only rows it can reattach', + paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-b`, ...terminal('tab-b')] + }, + { + fixture: 'legacy row repeating a tab-bar tab’s terminal', + rule: 'duplicate tab: the window drops a legacy row whose terminal a tab-bar tab holds; the Loader keeps it as a tab with an unbound pane', + paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-legacy`, ...terminal('tab-legacy')] + }, + { + fixture: 'row terminal differs from its focused pane’s', + rule: 'row.ptyId rewrite: the Loader sets the row terminal to its focused pane’s, which changes what the tab reattaches', + paths: [`${GIT} terminal tab-a reattaches`] + }, + { + fixture: 'row with no terminal while its pane has one', + rule: 'row.ptyId rewrite: the Loader sets the row terminal to its focused pane’s, which changes what the tab reattaches', + paths: [`${GIT} terminal tab-a reattaches`] + }, + { + fixture: 'tab-bar entry declaring a group that does not list it', + rule: 'group owner: the window keeps a tab in the group its entry declares; the Loader moves it to the first group listing it', + paths: [ + `${GIT} groups`, + `${GIT} group 0 tabs`, + `${GIT} group 1 tabs`, + `${GIT} group 1 active tab`, + `${GIT} tab tab-b` + ] + }, + { + fixture: 'session from before the tab bar, with an open file', + rule: 'old sessions: the window builds tabs from rows and open files; the Loader builds them from rows only, losing editor tabs', + paths: [ + `${GIT} group 0 tabs`, + `${GIT} group 0 active tab`, + `${GIT} tab /Users/dev/orca/src/app.ts` + ] + }, + { + fixture: 'legacy surface tombstone on a pane still showing its terminal', + rule: 'tombstones: the Loader closes a pane a legacy surface tombstone names; the window ignores the tombstone and shows the pane', + paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-b`, ...terminal('tab-b')] + }, + { + fixture: 'tab-bar label differing from the row title', + rule: 'label over title: the Loader writes the tab-bar label into the row title the window shows', + paths: [`${GIT} terminal tab-a title`] + } +] + +/** The shared Loader's round trip, as main would persist it. */ +function normalize(hostId: ExecutionHostId, session: WorkspaceSessionState): WorkspaceSessionState { + let next = 0 + const loaded = loadWorkspaceLayout(hostId, structuredClone(session), { + mintId: () => `minted-${++next}`, + mintLeafId: () => `00000000-0000-4000-9000-${String(++next).padStart(12, '0')}` + }) + return saveWorkspaceLayout(loaded) +} + +const PROFILES = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES] + +describe('the window shows the same session before and after the shared Loader rewrites it', () => { + it.each(PROFILES.map((profile) => [profile.name, profile] as const))( + '%s', + async (name, { hostId, build }) => { + const stored = build() + const storedView = projectStartupWindowView( + await bootWindowFromPartitions(createTestStore(), [{ hostId, session: stored }]) + ) + const normalizedView = projectStartupWindowView( + await bootWindowFromPartitions(createTestStore(), [ + { hostId, session: normalize(hostId, stored) } + ]) + ) + // The window must show the stored workspaces at all, or an empty view hides differences. + for (const [key, rows] of Object.entries(stored.tabsByWorktree)) { + if (rows.length > 0) { + expect(storedView, key).toHaveProperty([`${key} group 0 tabs`]) + } + } + const differences = diffStartupWindowViews(storedView, normalizedView) + const known = KNOWN_DIFFERENCES.filter((entry) => entry.fixture === name) + expect( + differences.map((difference) => difference.path).sort(), + `window differences (stored vs normalized):\n${JSON.stringify(differences, null, 2)}` + ).toEqual(known.flatMap((entry) => entry.paths).sort()) + } + ) + + it('lists differences only for fixtures that exist', () => { + const names = new Set(PROFILES.map((profile) => profile.name)) + expect(KNOWN_DIFFERENCES.filter((entry) => !names.has(entry.fixture))).toEqual([]) + }) +}) diff --git a/src/renderer/src/app-shell/startup-window-view.test-fixture.ts b/src/renderer/src/app-shell/startup-window-view.test-fixture.ts new file mode 100644 index 00000000000..ae0267af904 --- /dev/null +++ b/src/renderer/src/app-shell/startup-window-view.test-fixture.ts @@ -0,0 +1,212 @@ +// Boots a test store through the window's startup session hydration and reduces the result to +// what the window shows, so two stored sessions can be compared by what a user would see. + +import { vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { TabGroupLayoutNode } from '../../../shared/tab-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '@/lib/workspace-session-host-hydration' +import { collectFolderWorkspaceKeysFromSession } from '@/lib/workspace-session-hydration-keys' +import type { AppState } from '../store/types' +import type { createTestStore } from '../store/slices/store-test-helpers' +import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' + +/** One stored session partition and the host it is stored under. */ +export type StoredPartition = { hostId: ExecutionHostId; session: WorkspaceSessionState } + +/** + * The local catalog at first paint: a repo for each git workspace the partitions name, with no + * worktree listing yet. Remote-server repos are absent, as startup skips remote catalogs. + */ +function firstPaintRepos(partitions: readonly StoredPartition[]): AppState['repos'] { + const repos = new Map() + for (const { hostId, session } of partitions) { + const host = parseExecutionHostId(hostId) + if (host?.kind === 'runtime') { + continue + } + for (const key of Object.keys(session.tabsByWorktree)) { + const id = getRepoIdFromWorktreeId(key) + if (!key.startsWith(`${id}::`) || repos.has(id)) { + continue + } + repos.set(id, { + id, + path: key.slice(id.length + 2), + displayName: id, + badgeColor: '#000', + addedAt: 0, + connectionId: host?.kind === 'ssh' ? host.targetId : null, + executionHostId: hostId + }) + } + } + return [...repos.values()] +} + +/** + * Runs the same hydration calls, in the same order, as `use-app-startup-hydration.ts`, on a fresh + * test store the caller creates. + */ +export async function bootWindowFromPartitions( + store: ReturnType, + partitions: readonly StoredPartition[] +): Promise { + let minted = 0 + // Group and tab ids the window mints must match across the two boots being compared. + const randomUUID = vi + .spyOn(globalThis.crypto, 'randomUUID') + .mockImplementation(() => `00000000-0000-4000-8000-${String(++minted).padStart(12, '0')}`) + try { + store.setState({ repos: firstPaintRepos(partitions) }) + const read = async (hostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID) => + structuredClone( + partitions.find((partition) => partition.hostId === hostId)?.session ?? + getDefaultWorkspaceSession() + ) + const hostIdsOfKind = (kind: 'ssh' | 'runtime') => + partitions + .map((partition) => partition.hostId) + .filter((hostId) => parseExecutionHostId(hostId)?.kind === kind) + const sessionRead = await fetchWorkspaceSessionWithRuntimeHostOwners( + { get: read, listHostIds: async () => hostIdsOfKind('ssh') }, + store.getState().repos, + hostIdsOfKind('runtime') + ) + const actions = store.getState() + const options = { + additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) + } + actions.hydrateWorkspaceSession(sessionRead.session, { + ...options, + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, + contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, + contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey + }) + actions.hydrateTabsSession(sessionRead.session, options) + actions.hydrateEditorSession(sessionRead.session, options) + actions.hydrateBrowserSession(sessionRead.session, options) + reconcileHydratedWorkspaceTabModels( + sessionRead.session, + store.getState().reconcileWorktreeTabModels + ) + return store.getState() + } finally { + randomUUID.mockRestore() + } +} + +type PaneView = { pane: string | null } | { split: string; first: PaneView; second: PaneView } + +/** Panes by position, each naming the terminal it binds; leaf ids are not shown. */ +function paneView(node: TerminalPaneLayoutNode, layout: TerminalLayoutSnapshot): PaneView { + if (node.type === 'leaf') { + return { pane: layout.ptyIdsByLeafId?.[node.leafId] ?? null } + } + return { + split: node.direction, + first: paneView(node.first, layout), + second: paneView(node.second, layout) + } +} + +function leafPosition( + node: TerminalPaneLayoutNode, + leafId: string | null, + path = '' +): string | null { + if (node.type === 'leaf') { + return node.leafId === leafId ? path || 'root' : null + } + return ( + leafPosition(node.first, leafId, `${path}1`) ?? leafPosition(node.second, leafId, `${path}2`) + ) +} + +type GroupLayoutView = number | { split: string; first: GroupLayoutView; second: GroupLayoutView } + +function groupLayoutView(node: TabGroupLayoutNode, groupIds: string[]): GroupLayoutView { + if (node.type === 'leaf') { + return groupIds.indexOf(node.groupId) + } + return { + split: node.direction, + first: groupLayoutView(node.first, groupIds), + second: groupLayoutView(node.second, groupIds) + } +} + +/** + * What the window shows after startup, as `path -> value`. Groups are named by position, since + * a group id is never shown; tab ids stay, since a tab keeps its id across restarts. + */ +export function projectStartupWindowView(state: AppState): Record { + const view: Record = { + 'active.workspace': state.activeWorktreeId, + 'active.terminal': state.activeTabId + } + const workspaceKeys = new Set([ + ...Object.keys(state.tabsByWorktree), + ...Object.keys(state.unifiedTabsByWorktree), + ...state.openFiles.map((file) => file.worktreeId), + ...Object.keys(state.browserTabsByWorktree) + ]) + for (const key of [...workspaceKeys].sort()) { + const groups = state.groupsByWorktree[key] ?? [] + const groupIds = groups.map((group) => group.id) + const layout = state.layoutByWorktree[key] + view[`${key} groups`] = layout ? groupLayoutView(layout, groupIds) : groupIds.length + groups.forEach((group, index) => { + view[`${key} group ${index} tabs`] = group.tabOrder + view[`${key} group ${index} active tab`] = group.activeTabId + }) + view[`${key} active group`] = groupIds.indexOf(state.activeGroupIdByWorktree[key] ?? '') + view[`${key} active terminal`] = state.activeTabIdByWorktree[key] ?? null + view[`${key} active file`] = state.activeFileIdByWorktree[key] ?? null + view[`${key} active kind`] = state.activeTabTypeByWorktree[key] ?? null + for (const tab of state.unifiedTabsByWorktree[key] ?? []) { + view[`${key} tab ${tab.id}`] = { + kind: tab.contentType, + shows: tab.entityId, + group: groupIds.indexOf(tab.groupId), + label: tab.customLabel ?? tab.label + } + } + for (const row of state.tabsByWorktree[key] ?? []) { + const panes = state.terminalLayoutsByTabId[row.id] + const terminal = `${key} terminal ${row.id}` + view[`${terminal} title`] = row.customTitle ?? row.title + view[`${terminal} panes`] = panes?.root ? paneView(panes.root, panes) : null + view[`${terminal} focused pane`] = panes?.root + ? leafPosition(panes.root, panes.activeLeafId) + : null + view[`${terminal} reattaches`] = state.pendingReconnectPtyIdByTabId[row.id] ?? null + } + view[`${key} open files`] = state.openFiles + .filter((file) => file.worktreeId === key) + .map((file) => file.filePath) + view[`${key} browser tabs`] = (state.browserTabsByWorktree[key] ?? []).map((tab) => tab.url) + } + return view +} + +/** Each path whose value differs between two views, with both values. */ +export function diffStartupWindowViews( + stored: Record, + normalized: Record +): { path: string; stored: unknown; normalized: unknown }[] { + const paths = [...new Set([...Object.keys(stored), ...Object.keys(normalized)])].sort() + return paths + .filter((path) => JSON.stringify(stored[path]) !== JSON.stringify(normalized[path])) + .map((path) => ({ path, stored: stored[path], normalized: normalized[path] })) +} diff --git a/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts b/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts new file mode 100644 index 00000000000..dadd428053a --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts @@ -0,0 +1,121 @@ +// Stored partitions older writers leave disagreeing with themselves, one Loader rule each, so a +// test can compare what the window shows for each before and after the Loader rewrites it. + +import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import type { WorkspaceLayoutProfile } from './workspace-layout-profile.test-fixture' +import { addWorkspace, emptySession, GIT_KEY, leaf } from './workspace-layout-session.test-fixture' + +function twoTabs(): WorkspaceSessionState { + return addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'g1', + tabs: [ + { id: 'tab-a', leaves: [[leaf(1), 'pty-a']] }, + { id: 'tab-b', leaves: [[leaf(2), 'pty-b']] } + ] + } + ]) +} + +/** Drops a terminal's tab-bar entry, leaving its row and pane layout. */ +function withoutTabBarEntry(session: WorkspaceSessionState, tabId: string): WorkspaceSessionState { + session.unifiedTabs![GIT_KEY] = session.unifiedTabs![GIT_KEY]!.filter((tab) => tab.id !== tabId) + const group = session.tabGroups![GIT_KEY]![0]! + group.tabOrder = group.tabOrder.filter((id) => id !== tabId) + return session +} + +const local = (name: string, build: () => WorkspaceSessionState): WorkspaceLayoutProfile => ({ + name, + hostId: LOCAL_EXECUTION_HOST_ID, + build +}) + +export const LEGACY_WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[] = [ + local('row without a tab-bar entry or a terminal', () => { + const session = withoutTabBarEntry(twoTabs(), 'tab-b') + session.tabsByWorktree[GIT_KEY]![1]!.ptyId = null + session.terminalLayoutsByTabId['tab-b']!.ptyIdsByLeafId = {} + return session + }), + // Control for the case above: the window adopts a row it can reattach, as the Loader does. + local('row without a tab-bar entry, still bound to a terminal', () => + withoutTabBarEntry(twoTabs(), 'tab-b') + ), + local('legacy row repeating a tab-bar tab’s terminal', () => { + const session = twoTabs() + session.tabsByWorktree[GIT_KEY]!.push({ + ...session.tabsByWorktree[GIT_KEY]![0]!, + id: 'tab-legacy', + title: 'Terminal 3', + sortOrder: 2, + createdAt: 1_700_000_009_000 + }) + session.terminalLayoutsByTabId['tab-legacy'] = { + root: { type: 'leaf', leafId: leaf(3) }, + activeLeafId: leaf(3), + expandedLeafId: null, + ptyIdsByLeafId: { [leaf(3)]: 'pty-a' } + } + return session + }), + local('row terminal differs from its focused pane’s', () => { + const session = twoTabs() + session.tabsByWorktree[GIT_KEY]![0]!.ptyId = 'pty-stale' + return session + }), + local('row with no terminal while its pane has one', () => { + const session = twoTabs() + session.tabsByWorktree[GIT_KEY]![0]!.ptyId = null + return session + }), + local('tab-bar entry declaring a group that does not list it', () => { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { id: 'g1', tabs: [{ id: 'tab-a', leaves: [[leaf(1), 'pty-a']] }] }, + { id: 'g2', tabs: [{ id: 'tab-b', leaves: [[leaf(2), 'pty-b']] }] } + ]) + // g1 lists tab-b; its entry still says g2, and g2 lists it too. + session.tabGroups![GIT_KEY]![0]!.tabOrder.push('tab-b') + return session + }), + local('session from before the tab bar, with an open file', () => { + const session = twoTabs() + delete session.unifiedTabs + delete session.tabGroups + delete session.tabGroupLayouts + delete session.activeGroupIdByWorktree + session.openFilesByWorktree = { + [GIT_KEY]: [ + { + filePath: '/Users/dev/orca/src/app.ts', + relativePath: 'src/app.ts', + worktreeId: GIT_KEY, + language: 'typescript' + } + ] + } + session.activeFileIdByWorktree = { [GIT_KEY]: '/Users/dev/orca/src/app.ts' } + session.activeTabTypeByWorktree = { [GIT_KEY]: 'editor' } + return session + }), + local('legacy surface tombstone on a pane still showing its terminal', () => { + const session = twoTabs() + session.terminalSurfaceTombstonesByPaneKey = { + [`tab-b:${leaf(2)}`]: { + worktreeId: GIT_KEY, + parentTabId: 'tab-b', + leafId: leaf(2), + ptyId: 'pty-b', + incarnationId: 'inc', + retiredAt: 1 + } + } + return session + }), + local('tab-bar label differing from the row title', () => { + const session = twoTabs() + session.unifiedTabs![GIT_KEY]![0]!.label = 'vim' + return session + }) +] diff --git a/src/shared/workspace-layout/workspace-layout-profile.test-fixture.ts b/src/shared/workspace-layout/workspace-layout-profile.test-fixture.ts index b6d326fc563..58b009f2576 100644 --- a/src/shared/workspace-layout/workspace-layout-profile.test-fixture.ts +++ b/src/shared/workspace-layout/workspace-layout-profile.test-fixture.ts @@ -1,7 +1,13 @@ // Real-shaped stored partitions: the desktop's own, a relay-fallback SSH host's, a remote server's. import type { SleepingAgentSessionRecord } from '../agent-session-resume' -import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../execution-host' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../constants' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../execution-host' import { toRemoteRuntimePtyId } from '../remote-runtime-pty-id' import { toAppSshPtyId } from '../ssh-pty-id' import type { WorkspaceSessionState } from '../workspace-session-state-types' @@ -242,3 +248,38 @@ export function serverRuntimeSession(environmentId: string): WorkspaceSessionSta toRuntimeExecutionHostId(environmentId) ) } + +export type WorkspaceLayoutProfile = { + name: string + /** The partition the session is stored under. */ + hostId: ExecutionHostId + build: () => WorkspaceSessionState +} + +/** One stored partition per kind of writer today. */ +export const WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[] = [ + { + name: 'local desktop (git worktree, folder workspace, all tab kinds)', + hostId: LOCAL_EXECUTION_HOST_ID, + build: localDesktopSession + }, + { + name: 'floating terminal', + hostId: LOCAL_EXECUTION_HOST_ID, + build: () => + addWorkspace(emptySession(), FLOATING_TERMINAL_WORKTREE_ID, [ + { id: 'group-floating', tabs: [{ id: 'tab-floating', leaves: [[leaf(1), 'floating@@1']] }] } + ]) + }, + { + name: 'relay SSH host partition', + hostId: toSshExecutionHostId('target-1'), + build: () => relaySshSession('target-1') + }, + { + name: 'remote server partition', + hostId: toRuntimeExecutionHostId('env-1'), + build: () => serverRuntimeSession('env-1') + }, + { name: 'empty profile', hostId: LOCAL_EXECUTION_HOST_ID, build: emptySession } +] diff --git a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts index 6cee2624e51..96567a9fe64 100644 --- a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts +++ b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../constants' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../execution-host' import type { WorkspaceSessionState } from '../workspace-session-state-types' import { loadWorkspaceLayout } from './workspace-layout-load' @@ -8,10 +7,9 @@ import { checkWorkspaceLayoutRules } from './workspace-layout-rules' import { saveWorkspaceLayout } from './workspace-layout-save' import { localDesktopSession, - relaySshSession, - serverRuntimeSession + WORKSPACE_LAYOUT_PROFILES } from './workspace-layout-profile.test-fixture' -import { addWorkspace, emptySession, leaf } from './workspace-layout-session.test-fixture' +import { leaf } from './workspace-layout-session.test-fixture' /** What reaches disk: the profile documents are JSON, so an undefined field is a missing one. */ const onDisk = (session: WorkspaceSessionState): unknown => JSON.parse(JSON.stringify(session)) @@ -34,24 +32,9 @@ function roundTrip(hostId: ExecutionHostId, session: WorkspaceSessionState) { return { loaded, saved: saveWorkspaceLayout(loaded) } } -const PROFILES: [string, ExecutionHostId, () => WorkspaceSessionState][] = [ - [ - 'local desktop (git worktree, folder workspace, all tab kinds)', - LOCAL_EXECUTION_HOST_ID, - localDesktopSession - ], - [ - 'floating terminal', - LOCAL_EXECUTION_HOST_ID, - () => - addWorkspace(emptySession(), FLOATING_TERMINAL_WORKTREE_ID, [ - { id: 'group-floating', tabs: [{ id: 'tab-floating', leaves: [[leaf(1), 'floating@@1']] }] } - ]) - ], - ['relay SSH host partition', 'ssh:target-1', () => relaySshSession('target-1')], - ['remote server partition', 'runtime:env-1', () => serverRuntimeSession('env-1')], - ['empty profile', LOCAL_EXECUTION_HOST_ID, emptySession] -] +const PROFILES = WORKSPACE_LAYOUT_PROFILES.map( + (profile) => [profile.name, profile.hostId, profile.build] as const +) describe('workspace layout Loader and Serializer', () => { it.each(PROFILES)(