From 84af6ca6f4ce55adb45fa7703f7d4bbafded607a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 22 Oct 2026 02:47:26 -0700 Subject: [PATCH] test(wsl-mover): registry-complete lifecycle, stale-focus restart, unload re-apply (D1b-7f-2) --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/ipc/app.test.ts | 1 + .../ipc/renderer-shutdown-checkpoint.test.ts | 25 ++ ...wsl-move-automation-filter-rewrite.test.ts | 22 ++ .../wsl-move-main-rewrite-stores.test.ts | 5 +- .../wsl-move-registry-complete.test.ts | 300 ++++++++++++++++++ .../wsl-move-rewrite-ledger.test.ts | 1 + 7 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index bc2d93ad150..8d348f67473 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -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', diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e9c684e0dc6..111c1cc51eb 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -186,6 +186,7 @@ describe('registerAppHandlers', () => { expect(registerRendererShutdownCheckpointHandlerMock).toHaveBeenCalledWith( store, undefined, + undefined, undefined ) expect(registerMacKeyboardLayoutChangeNotificationsMock).toHaveBeenCalledOnce() diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index 14b0819ded4..7c175f8369f 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -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()), diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts new file mode 100644 index 00000000000..6cc6f873b47 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-automation-filter-rewrite.test.ts @@ -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() + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts index 3f741457d09..62d9861735b 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-main-rewrite-stores.test.ts @@ -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() diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts new file mode 100644 index 00000000000..3762933fa68 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-registry-complete.test.ts @@ -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 + +/** 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 { + await runWslMoveRetentionPass({ userDataPath: testState.dir, store }) +} + +/** Quit and launch again: a fresh store from disk, the registry registered anew. */ +async function restart(): Promise { + 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') + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts index 95791002ffc..00884f9355a 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-ledger.test.ts @@ -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'