test(wsl-mover): prove an update install is held during a real move and proceeds after it settles (#35345)

This commit is contained in:
Neil
2026-10-28 14:08:15 -07:00
committed by GitHub
parent 7646849038
commit 351d05b962
@@ -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<Mover> {
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<typeof vi.fn>; updater: typeof UpdaterModule; mover: Mover }
/** An interactive install with a downloaded update, left on real timers for the move. */
async function stagedUpdate(): Promise<Staged> {
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<void> {
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<WslMoveResult | 'killed'>
release: () => void
} {
let release!: () => void
const reached = new Promise<void>((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)
})
})