diff --git a/src/main/updater.wsl-move-install-hold-driver.test.ts b/src/main/updater.wsl-move-install-hold-driver.test.ts new file mode 100644 index 00000000000..d4940165beb --- /dev/null +++ b/src/main/updater.wsl-move-install-hold-driver.test.ts @@ -0,0 +1,272 @@ +/** + * #34813 end to end in-process: the real updater install path against a real move run of the WSL + * move driver over a real orcad profile store (the fault-injection rig). An install attempted while + * the move runs is held with its message; once the move settles (moved, failed, cancelled or killed + * mid-run and recovered on relaunch) the same staged update installs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as UpdaterModule from './updater' +import type { UpdateStatus } from '../shared/update-status-types' +import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../shared/wsl-host-internal-gate' +import type { WslMoveResult } from '../shared/wsl-move/wsl-move-driver-types' +import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' +import { updaterMainWindow } from './updater-test-main-window' +import type * as RigModule from './persistence/__fixtures__/wsl-mover/wsl-move-kill-rig' +import type * as RewriteStores from './persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores' +import type * as Driver from './persistence/moving-wsl-projects/wsl-move-driver' +import type * as RunningMoves from './persistence/moving-wsl-projects/wsl-move-running-moves' +import { WSL_MOVE_INSTALL_HELD_MESSAGE } from './updater/updater-install-execution' + +const { + nativeUpdaterMock, + autoUpdaterMock, + killAllPtyMock, + getLinuxRootPackageTypeMock, + recordUpdaterLifecycleMock, + fetchNewerReleaseTagsMock, + moduleFactories, + resetUpdaterMocks +} = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) +// The host store's profile dir, from the persistence harness loaded beside the updater. +const profile = vi.hoisted(() => ({ state: { dir: '' } })) + +// The updater's Electron fakes, plus the profile path and keychain the move's host store reads. +vi.mock('electron', () => { + const electron = moduleFactories.electron() + return { + ...electron, + app: Object.assign(electron.app, { getPath: () => profile.state.dir }), + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } + } +}) +vi.mock('electron-updater', () => moduleFactories.electronUpdater()) +vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) +vi.mock('./ipc/pty/kill-all', () => moduleFactories.ptyKillAll()) +vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) +vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) +vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) +vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) +vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) +vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) +vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) +vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 })) +})) + +warmUpdaterModule() + +/** The mover, loaded after the updater so both read one running-moves registry. */ +type Mover = { + rig: RigModule.WslMoveRig + fixture: typeof RigModule + driver: typeof Driver + moves: typeof RunningMoves + rewriteStores: typeof RewriteStores +} + +let mover: Mover | null = null + +async function loadMover(): Promise { + const harness = await import('./persistence/persistence-test-harness') + // Read live: the rig points the harness at a fresh profile dir before it opens the host store. + profile.state = harness.testState + const fixture = await import('./persistence/__fixtures__/wsl-mover/wsl-move-kill-rig') + const loaded: Mover = { + rig: fixture.createWslMoveKillRig(), + fixture, + driver: await import('./persistence/moving-wsl-projects/wsl-move-driver'), + moves: await import('./persistence/moving-wsl-projects/wsl-move-running-moves'), + rewriteStores: await import('./persistence/moving-wsl-projects/wsl-move-desktop-rewrite-stores') + } + mover = loaded + return loaded +} + +beforeEach(() => { + resetUpdaterMocks() + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + // A killed run's catch handlers warn about the calls that now throw. + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllEnvs() + mover?.rewriteStores.resetWslMoveDesktopRewriteStoresForTests() + mover?.fixture.removeWslMoveRigDirs() + mover = null +}) + +type Staged = { send: ReturnType; updater: typeof UpdaterModule; mover: Mover } + +/** An interactive install with a downloaded update, left on real timers for the move. */ +async function stagedUpdate(): Promise { + getLinuxRootPackageTypeMock.mockReturnValue(null) + vi.useFakeTimers() + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const updater = await loadUpdaterModule() + updater.setupAutoUpdater(updaterMainWindow(send), { + getLastUpdateCheckAt: () => Date.now(), + installMode: 'interactive' + }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + updater.downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { + version: '1.0.61', + downloadedFile: join(tmpdir(), 'Orca-1.0.61.AppImage'), + files: [] + }) + if (process.platform === 'darwin') { + const ready = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] + if (typeof ready === 'function') { + ready() + } + } + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + return { send, updater, mover: await loadMover() } +} + +/** The user presses Install; the install path's own timers run on fake time. */ +async function pressInstall({ updater }: Staged): Promise { + vi.useFakeTimers() + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + vi.useRealTimers() +} + +/** A move started and stopped where it reaches for the host, until `release`. */ +function pausedMove({ rig: r, driver, fixture }: Mover): { + run: Promise + release: () => void +} { + let release!: () => void + const reached = new Promise((resolve) => { + release = resolve + }) + const connect = r.world.deps.connectHost + r.world.deps.connectHost = async (distro) => { + await reached + return connect(distro) + } + const run = driver + .moveProjectsToWslHost(r.world.deps, fixture.WSL_MOVE_RIG_REQUEST) + .catch(() => 'killed' as const) + return { run, release } +} + +function expectHeld({ send }: Staged): void { + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + const status: UpdateStatus | undefined = send.mock.calls.findLast( + ([channel]) => channel === 'updater:status' + )?.[1] + expect(status).toEqual({ + state: 'error', + message: WSL_MOVE_INSTALL_HELD_MESSAGE, + retryAction: 'install', + version: '1.0.61' + }) + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith('quit_and_install_held_for_wsl_move', { + version: '1.0.61' + }) +} + +function expectInstalled({ send }: Staged): void { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + // Only the attempt made during the move was aborted. + expect( + send.mock.calls.filter(([channel]) => channel === 'updater:quitAndInstallAborted') + ).toHaveLength(1) +} + +describe('an app update while a real WSL project move runs (#34813)', () => { + it('is held while the move runs, and installs once it has moved', async () => { + const staged = await stagedUpdate() + const move = pausedMove(staged.mover) + expect(staged.mover.moves.isAnyWslMoveRunning()).toBe(true) + + await pressInstall(staged) + expectHeld(staged) + + move.release() + expect(await move.run).toMatchObject({ outcome: 'moved' }) + expect(staged.mover.moves.isAnyWslMoveRunning()).toBe(false) + await pressInstall(staged) + expectInstalled(staged) + }) + + it('installs once a move fails because its host does not answer', async () => { + const staged = await stagedUpdate() + const move = pausedMove(staged.mover) + + await pressInstall(staged) + expectHeld(staged) + + // The host goes away while the move waits to reach it. + staged.mover.rig.hostPresent = false + move.release() + expect(await move.run).toMatchObject({ outcome: 'refused' }) + expect(staged.mover.moves.isAnyWslMoveRunning()).toBe(false) + await pressInstall(staged) + expectInstalled(staged) + }) + + it('installs once the user cancels the move', async () => { + const staged = await stagedUpdate() + const move = pausedMove(staged.mover) + + await pressInstall(staged) + expectHeld(staged) + + expect(staged.mover.moves.cancelWslMove('Ubuntu')).toBe(true) + move.release() + expect(await move.run).toMatchObject({ outcome: 'refused', code: 'wsl_move_cancelled' }) + await pressInstall(staged) + expectInstalled(staged) + }) + + it('installs after the desktop is killed mid-move and the relaunch recovers it', async () => { + const staged = await stagedUpdate() + // Killed at the pause, after the move has journaled its cutover. + staged.mover.rig.kill.killAt = 'sleep' + const move = pausedMove(staged.mover) + + await pressInstall(staged) + expectHeld(staged) + + move.release() + await move.run + expect(staged.mover.rig.kill.dead).toBe(true) + // The next launch starts with no move running and settles the interrupted one. + await staged.mover.fixture.restartWslMoveRig(staged.mover.rig, { + hostUp: true, + orcadRestarted: false + }) + expect(staged.mover.fixture.wslMoveRigOutcome(staged.mover.rig)).not.toBe('stuck') + expect(staged.mover.moves.isAnyWslMoveRunning()).toBe(false) + await pressInstall(staged) + expectInstalled(staged) + }) +})