mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
test(layout): pin the layout-owner save format for downgrade, and keep main's touched-records save with an exhaustive guard (dark, layout-owner build only) (#31341)
* test(layout): downgrade probe for the layout-owner session format, and the touched-records patch's guard Downgrade probe (ledger L8): a checked-in golden of the session main writes in a layout-owner build (layout commands through the Store plus a window save), matched byte for byte; a switch-off Store hands it on unchanged and a switch-off window restores its tabs, panes, groups, editor file and browser tab. Regenerate with ORCA_UPDATE_LAYOUT_OWNER_GOLDEN=1. L104: for each writer of the touched-records patch (layout.apply, the cross-partition move, the window save), the patch equals a full save on every partition the load leaves alone, and differs only on records the load normalizes otherwise. Two concrete legacy cases are pinned, and a switch-off window restores each differently after a full save, so the patch stays. * test(layout): type the guard's partition ids and locate the golden without import.meta * test(layout): pin every legacy fixture's patch difference, assert restored panes and group selection, keep the golden deterministic
This commit is contained in:
@@ -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<typeof RecordPatch>()
|
||||
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<ReturnType<typeof firstTerminal>>
|
||||
): 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<WriterName, Writer> = {
|
||||
[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<Record<string, string>> = {
|
||||
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<string>()
|
||||
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<Record<string, Partial<Record<WriterName, readonly string[]>>>> = {
|
||||
[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] ?? [])
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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<typeof NodeCrypto>()
|
||||
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<void> {
|
||||
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`)
|
||||
})
|
||||
})
|
||||
+42
@@ -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()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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<typeof AgentStatusModule>()
|
||||
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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<typeof AgentStatusModule>()
|
||||
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']]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user