test(layout-owner): prove main and the window agree on a worktree identity rekey (#30005)

Main's Store.migrateWorktreeIdentity re-keys every stored layout field before it sends the
rename event the window re-keys on, so the window's save only mirrors it. The new oracle checks
main's migrated layout model equals the model of the window's post-rekey save and the model's
identityRenamed transition; the first-work test pins migrate-before-notify.
This commit is contained in:
Jinwoo Hong
2026-10-17 00:10:27 -07:00
committed by GitHub
parent f4aa82874b
commit 5733887967
3 changed files with 171 additions and 0 deletions
+1
View File
@@ -46,6 +46,7 @@
"../src/main/wsl-interop-spawn-directory.ts",
"../src/main/wsl-exe-process.ts",
"../src/main/persistence/applying-settings/ui-state-read.ts",
"../src/main/persistence/tracking-repos/worktree-identity-migration.ts",
"../src/main/persistence/applying-settings/ui-state-update.ts",
"../src/main/persistence/applying-settings/ui-selection-normalization.ts",
"../src/main/persistence/applying-settings/ui-interaction-merge.ts",
@@ -50,6 +50,11 @@ describe('renameWorktreeFolderOnFirstWork', () => {
OLD_ID,
'repo1::/ws/worktree-creation-spinner'
)
// Main re-keys its stored layout before the window hears of the rename, so the window's
// re-key and save only mirror it (worktree-identity-rename-main-agreement.test.ts).
expect(vi.mocked(deps.migrateWorktreeIdentity).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(deps.notifyWorktreeRenamed).mock.invocationCallOrder[0]!
)
})
it('preserves the folder-workspace instance suffix in the migrated identity', async () => {
@@ -0,0 +1,165 @@
// A worktree identity rename re-keys the layout twice: main migrates its stored sessions first,
// then the window re-keys its slices on the `worktrees:changed` rename event. The window's next
// save must leave main's layout model as main's own migration left it, and both must match the
// model's `identityRenamed` transition, so the save is never the only carrier of the rekey.
// The event's one producer is the first-work folder rename (first-work-folder-rename.ts), local
// only and compiled off (ENABLE_FIRST_WORK_FOLDER_RENAME); a paired server's event carries no rename.
import { describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import { getDefaultPersistedState } from '../../../../../../shared/constants'
import type { WorkspaceSessionState } from '../../../../../../shared/workspace-session-state-types'
import {
addWorkspace,
emptySession,
leaf
} from '../../../../../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { applyLayoutTransition } from '../../../../../../shared/workspace-layout/workspace-layout-transitions'
import { migrateWorktreeIdentity } from '../../../../../../main/persistence/tracking-repos/worktree-identity-migration'
import { loadWorkspaceLayoutWithoutReport } from '../../../../../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../../../../../shared/workspace-layout/workspace-layout-minted-ids'
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) }
})
// @ts-expect-error -- mocked browser preload API
globalThis.window = { api: {} }
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { createTestStore, makeWorktree } from '../../store-test-helpers'
const REPO = 'repo1'
const OLD = `${REPO}::/path/cunner`
const NEW = `${REPO}::/path/spinner`
const OTHER = `${REPO}::/path/other`
const EDITOR_FILE = '/path/cunner/src/App.tsx'
function sessionBeforeRename(): WorkspaceSessionState {
const session = addWorkspace(emptySession(), OLD, [
{
id: 'group-left',
tabs: [
{ id: 'tab-split', leaves: [[leaf(1), 'pty-1'], [leaf(2)]] },
{ id: 'tab-two', leaves: [[leaf(3), 'pty-3']] },
{ id: EDITOR_FILE, kind: 'editor' }
]
},
{
id: 'group-right',
tabs: [
{ id: 'tab-right', leaves: [[leaf(4)]] },
{ id: 'browser-1', kind: 'browser' }
]
}
])
session.openFilesByWorktree = {
[OLD]: [
{
filePath: EDITOR_FILE,
relativePath: 'src/App.tsx',
worktreeId: OLD,
language: 'typescript'
}
]
}
session.activeFileIdByWorktree = { [OLD]: EDITOR_FILE }
session.browserTabsByWorktree = {
[OLD]: [
{
id: 'browser-1',
worktreeId: OLD,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 3
}
]
}
addWorkspace(session, OTHER, [
{ id: 'group-other', tabs: [{ id: 'tab-other', leaves: [[leaf(5)]] }] }
])
session.activeRepoId = REPO
session.activeWorktreeId = OLD
return session
}
/** Main's layout model of a stored partition, loaded as main loads it. */
function layoutModelOf(session: WorkspaceSessionState) {
return loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext()).layout
}
function hydratedWindow(session: WorkspaceSessionState) {
const store = createTestStore()
store.setState({
repos: [{ id: REPO, path: '/path', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
worktreesByRepo: {
[REPO]: [OLD, OTHER].map((id) =>
makeWorktree({ id, repoId: REPO, path: id.slice(`${REPO}::`.length) })
)
}
})
store.getState().hydrateWorkspaceSession(session)
store.getState().hydrateTabsSession(session)
store.getState().hydrateEditorSession(session)
store.getState().hydrateBrowserSession(session)
store.setState({ workspaceSessionReady: true })
store.getState().setHydrationSucceeded(true)
return store
}
describe('worktree identity rename: window and main agree on the layout', () => {
it("leaves main's migrated layout model as it is when the window saves its rekey", () => {
const store = hydratedWindow(sessionBeforeRename())
// What main holds: the window's last save before the rename.
const held = buildWorkspaceSessionPayload(store.getState())
const before = layoutModelOf(held)
expect(Object.keys(before.workspaces).sort()).toEqual([OLD, OTHER].sort())
// Main: Store.migrateWorktreeIdentity, before it sends the rename event.
const persisted = {
...getDefaultPersistedState('/home'),
workspaceSession: structuredClone(held)
}
expect(migrateWorktreeIdentity(persisted, OLD, NEW)).toBe(true)
const mainModel = layoutModelOf(persisted.workspaceSession)
// Window: the rename event re-keys its slices; the worktree list then catches up.
store.getState().migrateWorktreeIdentity(OLD, NEW)
store.setState({
worktreesByRepo: {
[REPO]: [NEW, OTHER].map((id) =>
makeWorktree({ id, repoId: REPO, path: id.slice(`${REPO}::`.length) })
)
}
})
const windowModel = layoutModelOf(buildWorkspaceSessionPayload(store.getState()))
expect(Object.keys(mainModel.workspaces).sort()).toEqual([NEW, OTHER].sort())
const renamed = windowModel.workspaces[NEW]!
expect(renamed.tabs.length).toBe(5)
expect(renamed.groups?.length).toBe(2)
expect(renamed.editorFiles?.length).toBe(1)
expect(renamed.browserTabs?.length).toBe(1)
expect({ workspaces: windowModel.workspaces, records: windowModel.records }).toEqual({
workspaces: mainModel.workspaces,
records: mainModel.records
})
const transitioned = applyLayoutTransition(before, {
type: 'identityRenamed',
from: OLD,
to: NEW
})
expect(transitioned.ok).toBe(true)
if (transitioned.ok) {
expect(transitioned.model.workspaces).toEqual(mainModel.workspaces)
expect(transitioned.model.records).toEqual(mainModel.records)
}
})
})