test(wsl-mover): registry-complete lifecycle, stale-focus restart, unload re-apply (D1b-7f-2)

This commit is contained in:
Neil
2026-10-22 02:47:26 -07:00
parent e3d305c843
commit 84af6ca6f4
7 changed files with 353 additions and 2 deletions
@@ -15,6 +15,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts',
'src/main/persistence/moving-wsl-projects/wsl-move-orchestration-history-rewrite.test.ts',
'src/main/persistence/moving-wsl-projects/wsl-move-retention.test.ts',
'src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts',
'src/main/wsl-move-scan-pruning.test.ts',
'src/main/repo-backfill-wsl-move.test.ts',
'src/main/acp/acp-structured-host-live-listing.test.ts',
+1
View File
@@ -186,6 +186,7 @@ describe('registerAppHandlers', () => {
expect(registerRendererShutdownCheckpointHandlerMock).toHaveBeenCalledWith(
store,
undefined,
undefined,
undefined
)
expect(registerMacKeyboardLayoutChangeNotificationsMock).toHaveBeenCalledOnce()
@@ -92,6 +92,31 @@ describe('registerRendererShutdownCheckpointHandler', () => {
expect(event.returnValue).toEqual({ ok: true })
})
it('corrects what the window staged before the flush', () => {
const callOrder: string[] = []
const store = {
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
stageWorkspaceSessionBeforeUnload: vi.fn(() => callOrder.push('session')),
updateUI: vi.fn(() => callOrder.push('ui')),
flushPendingOrThrowAsync: vi.fn(() => {
callOrder.push('persist')
return Promise.resolve()
})
}
registerRendererShutdownCheckpointHandler(store as never, undefined, undefined, () => {
callOrder.push('after-stage')
})
const event: { returnValue?: unknown } = {}
syncHandlers.get('app:stage-before-unload-sync')?.(event, {
sessions: [{ state: {} }],
ui: {}
})
expect(callOrder).toEqual(['session', 'ui', 'after-stage', 'persist'])
expect(event.returnValue).toEqual({ ok: true })
})
it('never stages the source partition of a fenced host', () => {
const store = {
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { toPersistedAutomationHostFilter } from '../../../shared/automation-host-filter'
import { wslMoveAutomationHostFilterUpdate } from './wsl-move-automation-filter-rewrite'
const desktop = toPersistedAutomationHostFilter({
kind: 'host',
host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }
})
const otherHost = { kind: 'host' as const, hostKey: 'host:ssh:staging' }
describe('wslMoveAutomationHostFilterUpdate', () => {
it('widens a filter on this desktop to all hosts when the move carried automations', () => {
expect(wslMoveAutomationHostFilterUpdate(desktop, 2)).toEqual({ kind: 'all' })
})
it('keeps the filter when nothing moved, or it shows all hosts or another host', () => {
expect(wslMoveAutomationHostFilterUpdate(desktop, 0)).toBeNull()
expect(wslMoveAutomationHostFilterUpdate({ kind: 'all' }, 2)).toBeNull()
expect(wslMoveAutomationHostFilterUpdate(otherHost, 2)).toBeNull()
expect(wslMoveAutomationHostFilterUpdate(undefined, 2)).toBeNull()
})
})
@@ -129,7 +129,7 @@ describe('WSL move main rewrite stores', () => {
)
})
it('rewrites and records each store on disk; the move stays committed until 7f', async () => {
it('rewrites and records each store on disk, then finishes the rewrite', async () => {
seedWslMoveCutover(testState.dir, committedMove())
loadWslMoveFence(testState.dir)
@@ -137,7 +137,8 @@ describe('WSL move main rewrite stores', () => {
const [record] = listWslMoveCutovers(testState.dir)
expect(record?.rewrittenStores).toEqual(STORE_IDS)
expect(record?.phase).toBe('destination-committed')
// Only these three are registered here, so the ledger is complete.
expect(record?.phase).toBe('client-rewritten')
expect(record?.seededHostWorkspaceIds).toEqual([MOVED_ON_HOST])
// Read back from the profile database: each store's flush made its write durable.
const saved = persisted()
@@ -0,0 +1,300 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate'
import { WSL_MOVE_RENDERER_REWRITE_STORE_IDS } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import type { Store } from '../../persistence'
import {
closeTestStores,
createStore,
testState,
writeDataFile
} from '../../persistence-test-harness'
import { wslMoveCutoverFixture } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { wslMoverProfile } from '../__fixtures__/wsl-mover/wsl-mover-profiles'
import {
listWslMoveCutovers,
wslMoveJournalDirectory,
writeWslMoveCutover
} from './wsl-move-cutover-journal'
import {
recordWslMoveRendererStoreRewritten,
runGatedWslMoveDesktopRewritePass,
runWslMoveDesktopRewritePass
} from './wsl-move-desktop-rewrite'
import { registerWslMoveDesktopRewriteRegistry } from './wsl-move-desktop-rewrite-registry'
import {
isWslMoveDesktopRewriteRegistryComplete,
listWslMoveDesktopRewriteStores,
resetWslMoveDesktopRewriteStoresForTests
} from './wsl-move-desktop-rewrite-stores'
import { reapplyWslMoveFocusRewrites } from './wsl-move-focus-reapply'
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
import { runWslMoveRetentionPass } from './wsl-move-retention'
import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard'
import { runWslMoveStartupRewrite } from './wsl-move-startup-rewrite'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../browser/browser-partition-cookie-copy', () => ({
copyBrowserPartitionCookies: vi.fn(),
flushBrowserPartitionCookies: vi.fn()
}))
vi.mock('../../browser/browser-session-registry', () => ({ browserSessionRegistry: {} }))
vi.mock('../../browser/browser-session-startup', () => ({ browserSessionsInitialized: vi.fn() }))
vi.mock('../../browser/browser-route-partition-binding-runtime', () => ({
activeBrowserRoutePartitionOrcaProfileId: vi.fn(),
currentBrowserRoutePartitionBindingStore: vi.fn()
}))
vi.mock('../../browser/browser-route-partition-storage-dependencies', () => ({
releaseEvictedBrowserRoutePartitionStorage: vi.fn()
}))
const HOST = toRuntimeExecutionHostId('wsl:Ubuntu')
const source = wslMoverProfile('mixed-windows-wsl', 'source')
const MOVED = source.session.activeWorktreeId ?? ''
const MOVED_ON_HOST = 'repo-a::/home/dev/app'
const committedMove = (migrationId = 'move-1') =>
wslMoveCutoverFixture(migrationId, 'destination-committed', {}, { repoIds: ['repo-a'] })
const journal = () => listWslMoveCutovers(testState.dir)
let store: Store
let enableAutomations: ReturnType<typeof vi.fn>
/** Startup's registration, against this test's profile. */
function registerRegistry(): void {
registerWslMoveDesktopRewriteRegistry({
profile: store,
orchestrationDatabasePath: join(testState.dir, 'orchestration.db'),
hostAutomations: { enable: enableAutomations },
browserCookies: {
userDataPath: testState.dir,
sessionsReady: () => true,
orcaProfileId: () => 'orca-profile-a',
// No environment: the cookie store records a fresh partition, the path 7f-1 tests in full.
listEnvironments: () => [],
listBrowserProfiles: () => [],
resolveHostStorage: vi.fn(),
bindHostPartition: vi.fn(),
copyCookies: vi.fn(),
flushCookies: vi.fn()
}
})
}
function recordRendererStores(migrationId = 'move-1'): void {
for (const storeId of WSL_MOVE_RENDERER_REWRITE_STORE_IDS) {
expect(recordWslMoveRendererStoreRewritten(testState.dir, { migrationId, storeId })).toEqual({
recorded: true
})
}
}
async function retain(): Promise<void> {
await runWslMoveRetentionPass({ userDataPath: testState.dir, store })
}
/** Quit and launch again: a fresh store from disk, the registry registered anew. */
async function restart(): Promise<void> {
await closeTestStores()
resetWslMoveDesktopRewriteStoresForTests()
store = createStore()
registerRegistry()
}
/** What an old window, hydrated before the rewrite, writes for the moved project. */
function staleWindowWrite(): void {
store.updateUI({ lastActiveWorktreeId: MOVED })
store.patchWorkspaceSession({
activeWorktreeId: MOVED,
activeWorkspaceKey: worktreeWorkspaceKey(MOVED),
activeWorkspaceExecutionHostId: 'local'
})
}
function expectFocusOnHost(): void {
expect(store.getUI().lastActiveWorktreeId).toBe(MOVED_ON_HOST)
const session = store.getWorkspaceSession()
expect(session.activeWorktreeId).toBe(MOVED_ON_HOST)
expect(session.activeWorkspaceKey).toBe(worktreeWorkspaceKey(MOVED_ON_HOST))
expect(session.activeWorkspaceExecutionHostId).toBe(HOST)
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-registry-complete-'))
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
writeDataFile({
...getDefaultPersistedState(testState.dir),
repos: source.state.repos,
ui: {
...getDefaultPersistedState(testState.dir).ui,
...source.ui,
workspaceHostScope: 'local'
},
workspaceSession: {
...getDefaultWorkspaceSession(),
...source.session,
activeWorkspaceKey: worktreeWorkspaceKey(MOVED),
activeWorkspaceExecutionHostId: 'local'
}
})
store = createStore()
enableAutomations = vi.fn(async () => {})
registerRegistry()
})
afterEach(async () => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
resetWslMoveDesktopRewriteStoresForTests()
resetWslMoveFenceForTests()
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
describe('WSL move with the complete desktop rewrite registry', () => {
it('takes a committed move through client-rewritten to retained', async () => {
expect(isWslMoveDesktopRewriteRegistryComplete()).toBe(true)
seedWslMoveCutover(testState.dir, committedMove())
loadWslMoveFence(testState.dir)
await runWslMoveDesktopRewritePass({ userDataPath: testState.dir })
// Every main store is in; the renderer's are still owed.
expect(journal()[0]).toMatchObject({
phase: 'destination-committed',
rewrittenStores: listWslMoveDesktopRewriteStores()
.filter((entry) => entry.side === 'main')
.map((entry) => entry.id),
browserCookieCopy: { outcome: 'fresh-partition' }
})
recordRendererStores()
const [rewritten] = journal()
expect(rewritten?.phase).toBe('client-rewritten')
// Each registered store exactly once.
expect([...(rewritten?.rewrittenStores ?? [])].sort()).toEqual(
listWslMoveDesktopRewriteStores()
.map((entry) => entry.id)
.sort()
)
expectFocusOnHost()
await retain()
expect(journal()[0]?.phase).toBe('retained')
expect(journal()[0]?.rewrittenStores).toEqual(rewritten?.rewrittenStores)
})
it('refuses a later move of the distro only until the earlier one is retained', async () => {
seedWslMoveCutover(testState.dir, committedMove())
const next = {
...wslMoveCutoverFixture('move-2', 'prepared', {}, { repoIds: ['repo-b'] }),
supersedesMigrationId: 'move-1'
}
expect(() => writeWslMoveCutover(testState.dir, next)).toThrow('wsl_move_already_in_flight')
await runWslMoveDesktopRewritePass({ userDataPath: testState.dir })
recordRendererStores()
await retain()
writeWslMoveCutover(testState.dir, next)
expect(journal().map((entry) => [entry.migrationId, entry.phase])).toEqual([
['move-1', 'retained'],
['move-2', 'prepared']
])
})
it('changes nothing with the gate closed', async () => {
seedWslMoveCutover(testState.dir, committedMove())
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
const ui = store.getUI()
const session = store.getWorkspaceSession()
await runGatedWslMoveDesktopRewritePass({ userDataPath: testState.dir })
await runWslMoveStartupRewrite(
{ userDataPath: testState.dir },
{ reapply: () => reapplyWslMoveFocusRewrites(testState.dir, store) }
)
expect(
recordWslMoveRendererStoreRewritten(testState.dir, {
migrationId: 'move-1',
storeId: 'quick-open-history'
})
).toEqual({ recorded: false, reason: 'gated-off' })
expect(reapplyWslMoveFocusRewrites(testState.dir, store)).toBe(0)
expect(journal()[0]).toMatchObject({ phase: 'destination-committed', rewrittenStores: [] })
expect(store.getUI()).toEqual(ui)
expect(store.getWorkspaceSession()).toEqual(session)
})
it('changes nothing without a journal', async () => {
const ui = store.getUI()
const session = store.getWorkspaceSession()
await runWslMoveStartupRewrite(
{ userDataPath: testState.dir },
{ reapply: () => reapplyWslMoveFocusRewrites(testState.dir, store) }
)
expect(existsSync(wslMoveJournalDirectory(testState.dir))).toBe(false)
expect(enableAutomations).not.toHaveBeenCalled()
expect(store.getUI()).toEqual(ui)
expect(store.getWorkspaceSession()).toEqual(session)
})
it.each(['client-rewritten', 'retained'] as const)(
'corrects a stale focus written after the rewrite on the next start (%s), without ledger writes',
async (phase) => {
seedWslMoveCutover(testState.dir, committedMove())
loadWslMoveFence(testState.dir)
await runWslMoveDesktopRewritePass({ userDataPath: testState.dir })
recordRendererStores()
if (phase === 'retained') {
await retain()
}
const [finished] = journal()
expect(finished?.phase).toBe(phase)
// The old window writes its focus back before it reloads, and restart checkpoints it.
staleWindowWrite()
await store.flushPendingOrThrowAsync()
await restart()
expect(store.getUI().lastActiveWorktreeId).toBe(MOVED)
await runWslMoveStartupRewrite(
{ userDataPath: testState.dir },
{ reapply: () => reapplyWslMoveFocusRewrites(testState.dir, store) }
)
expectFocusOnHost()
// Idempotent: a second start finds nothing to correct.
expect(reapplyWslMoveFocusRewrites(testState.dir, store)).toBe(0)
expect(journal()).toEqual([finished])
}
)
it('leaves a focus the user moved elsewhere alone', async () => {
seedWslMoveCutover(testState.dir, committedMove())
loadWslMoveFence(testState.dir)
await runWslMoveDesktopRewritePass({ userDataPath: testState.dir })
recordRendererStores()
const elsewhere = 'repo-b::/home/dev/lib'
store.updateUI({ lastActiveWorktreeId: elsewhere })
store.patchWorkspaceSession({
activeWorktreeId: elsewhere,
activeWorkspaceKey: worktreeWorkspaceKey(elsewhere),
activeWorkspaceExecutionHostId: 'local'
})
expect(reapplyWslMoveFocusRewrites(testState.dir, store)).toBe(0)
expect(store.getUI().lastActiveWorktreeId).toBe(elsewhere)
expect(store.getWorkspaceSession().activeWorkspaceExecutionHostId).toBe('local')
})
})
@@ -92,6 +92,7 @@ describe('WSL move rewrite ledger', () => {
})
it('refuses client-rewritten while the store registry is incomplete', () => {
setWslMoveDesktopRewriteRegistryCompleteForTests(false)
seedWslMoveCutover(userDataPath, committed([]))
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten'))).toThrow(
'wsl_move_rewrite_incomplete'