mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(updater): keep macOS Orca open when background instances block updates (#24952)
* fix(updater): guard macOS installs against running app instances * fix(updater): match native app blockers and preserve quit lifecycle * fix(updater): keep ordinary macOS quit on Squirrel's install-on-exit path Converting every quit with a staged update into quitAndInstall made Cmd+Q relaunch Orca, refused the quit when background instances existed, and hijacked app.relaunch()+app.quit() restart flows (profile switch, admin restart) into an update install racing the relaunched old app. Only Update & Restart runs the running-instance preflight now; the quit-without-install allowance is no longer reachable and is removed. * fix(updater): preserve quit intent through macOS staging * test(native-chat): explicitly model legacy published tab ownership --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
name: macOS updater regression tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/macos-updater-tests.yml'
|
||||
- 'src/main/macos-update-running-instances*'
|
||||
- 'src/main/updater*'
|
||||
- 'src/main/updater/**'
|
||||
- 'src/main/startup/main-process-quit*'
|
||||
- 'src/main/window/main-window-state-lifecycle*'
|
||||
- 'src/main/window/dashboard-popout-window*'
|
||||
- 'src/shared/child-process/**'
|
||||
- 'src/shared/update-status-types.ts'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: macos-updater-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
updater:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
- name: Exercise native application registry and update shutdown
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/macos-update-running-instances.test.ts
|
||||
src/main/macos-update-running-instances.integration.test.ts
|
||||
src/main/updater.mac-install.test.ts
|
||||
src/main/updater.headless-serve-install.test.ts
|
||||
src/main/updater-mac-quit-guard.test.ts
|
||||
src/main/startup/desktop-startup-ordering.test.ts
|
||||
src/main/startup/main-process-quit-update-veto.test.ts
|
||||
src/main/window/main-window-state-lifecycle.test.ts
|
||||
src/main/window/dashboard-popout-window.test.ts
|
||||
@@ -9,6 +9,7 @@ import type { StructuredAgentSessionEventSink } from '../native-chat/agent-sessi
|
||||
import { ClaudeChildWorkDecoder } from './claude-child-work-decoder'
|
||||
import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import {
|
||||
claudeToolResults,
|
||||
claudeToolUses,
|
||||
@@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => {
|
||||
translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 })
|
||||
const join = vi.spyOn(Array.prototype, 'join')
|
||||
const evidence = drainClaudeChildWork(
|
||||
{ childWork: new ClaudeChildWorkDecoder(), translator },
|
||||
{
|
||||
childWork: new ClaudeChildWorkDecoder(),
|
||||
translator,
|
||||
prompts: new ClaudePromptRegistry()
|
||||
},
|
||||
message,
|
||||
1_000
|
||||
)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { once } from 'node:events'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { runProcess, spawnProcess } from '../shared/child-process/run-process'
|
||||
import { getMacUpdateRunningInstances } from './macos-update-running-instances'
|
||||
|
||||
const APPLICATION_SOURCE = `
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <unistd.h>
|
||||
int main(int argc, const char *argv[]) {
|
||||
if (argc > 1) { sleep(30); return 0; }
|
||||
@autoreleasepool {
|
||||
NSApplication *app = [NSApplication sharedApplication];
|
||||
[app setActivationPolicy:NSApplicationActivationPolicyProhibited];
|
||||
[app run];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
`
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'matches ShipIt with registered sibling apps, excluding same-executable workers and other bundle copies',
|
||||
async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-update-instances-'))
|
||||
const bundle = path.join(root, 'Orca Test.app')
|
||||
const executable = path.join(bundle, 'Contents', 'MacOS', 'Orca Test')
|
||||
mkdirSync(path.dirname(executable), { recursive: true })
|
||||
const sourcePath = path.join(root, 'application.m')
|
||||
writeFileSync(sourcePath, APPLICATION_SOURCE)
|
||||
writeFileSync(
|
||||
path.join(bundle, 'Contents', 'Info.plist'),
|
||||
`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict>
|
||||
<key>CFBundleIdentifier</key><string>com.stablyai.${path.basename(root)}</string>
|
||||
<key>CFBundleExecutable</key><string>Orca Test</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
</dict></plist>`
|
||||
)
|
||||
const children: ReturnType<typeof spawnProcess>[] = []
|
||||
const closed: Promise<unknown>[] = []
|
||||
try {
|
||||
const compilation = await runProcess({
|
||||
program: '/usr/bin/clang',
|
||||
args: ['-framework', 'AppKit', sourcePath, '-o', executable],
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(compilation.code, compilation.stderr).toBe(0)
|
||||
const otherBundle = path.join(root, 'Other Orca.app')
|
||||
cpSync(bundle, otherBundle, { recursive: true })
|
||||
for (const [program, args] of [
|
||||
[executable, []],
|
||||
[executable, []],
|
||||
[executable, ['--worker']],
|
||||
[path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), []]
|
||||
] satisfies [string, string[]][]) {
|
||||
const child = spawnProcess({
|
||||
program,
|
||||
args,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
|
||||
})
|
||||
children.push(child)
|
||||
closed.push(once(child, 'close'))
|
||||
}
|
||||
const [self, sibling, worker, otherCopy] = children
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(self.pid).toBeTypeOf('number')
|
||||
expect(sibling.pid).toBeTypeOf('number')
|
||||
expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([sibling.pid])
|
||||
expect(
|
||||
await getMacUpdateRunningInstances(
|
||||
path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'),
|
||||
0
|
||||
)
|
||||
).toEqual([otherCopy.pid])
|
||||
const workerListing = await runProcess({
|
||||
program: '/bin/ps',
|
||||
args: ['-p', String(worker.pid), '-ww', '-o', 'comm=']
|
||||
})
|
||||
expect(workerListing.stdout.trim()).toBe(executable)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
sibling.kill('SIGTERM')
|
||||
await closed[1]
|
||||
expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([])
|
||||
} finally {
|
||||
for (const child of children) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
await Promise.all(closed)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
25_000
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getMacUpdateRunningInstances,
|
||||
parseMacUpdateRunningInstances
|
||||
} from './macos-update-running-instances'
|
||||
|
||||
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
|
||||
vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
const executable = '/Applications/Orca Test.app/Contents/MacOS/Orca Test'
|
||||
|
||||
describe('macOS update running instances', () => {
|
||||
beforeEach(() => {
|
||||
runProcessMock.mockReset()
|
||||
})
|
||||
|
||||
it('excludes the current process from the native registry result', () => {
|
||||
expect(parseMacUpdateRunningInstances('[100,101,102]\n', 100)).toEqual([101, 102])
|
||||
expect(parseMacUpdateRunningInstances('[]\n', 100)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'not JSON',
|
||||
'',
|
||||
'{}',
|
||||
'null',
|
||||
'[0]',
|
||||
'[-1]',
|
||||
'[1.5]',
|
||||
'["101"]',
|
||||
'[null]',
|
||||
'[9007199254740992]'
|
||||
])('rejects invalid registry output: %s', (listing) => {
|
||||
expect(() => parseMacUpdateRunningInstances(listing, 100)).toThrow()
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'passes the bundle path as an argument rather than executable script',
|
||||
async () => {
|
||||
const unusualExecutable = '/Applications/Orca "Test" $HOME.app/Contents/MacOS/Orca Test'
|
||||
runProcessMock.mockResolvedValue({ code: 0, stdout: '[]', timedOut: false })
|
||||
await expect(getMacUpdateRunningInstances(unusualExecutable)).resolves.toEqual([])
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: [
|
||||
'-l',
|
||||
'JavaScript',
|
||||
'-e',
|
||||
expect.not.stringContaining('$HOME'),
|
||||
'/Applications/Orca "Test" $HOME.app'
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('skips development runtimes without probing the host', async () => {
|
||||
expect(await getMacUpdateRunningInstances('/usr/local/bin/node')).toEqual([])
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['linux', 'win32'])('does not probe on %s', async (platform) => {
|
||||
vi.stubGlobal('process', { ...process, platform })
|
||||
try {
|
||||
expect(await getMacUpdateRunningInstances(executable)).toEqual([])
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'uses the bounded native application registry query and preserves paths with spaces',
|
||||
async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: '[100,101]\n',
|
||||
timedOut: false
|
||||
})
|
||||
expect(await getMacUpdateRunningInstances(executable, 100)).toEqual([101])
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: '/usr/bin/osascript',
|
||||
args: [
|
||||
'-l',
|
||||
'JavaScript',
|
||||
'-e',
|
||||
expect.stringContaining('runningApplicationsWithBundleIdentifier'),
|
||||
'/Applications/Orca Test.app'
|
||||
],
|
||||
timeoutMs: 5000,
|
||||
killOnOutputLimit: true
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin').each([
|
||||
{ code: 1, timedOut: false },
|
||||
{ code: null, timedOut: true },
|
||||
{ code: 0, timedOut: false, outputTruncated: true }
|
||||
])('fails closed for incomplete query results: %j', async (result) => {
|
||||
runProcessMock.mockResolvedValue({ stdout: '', ...result })
|
||||
await expect(getMacUpdateRunningInstances(executable)).rejects.toThrow('Could not check')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from 'node:path'
|
||||
import { runProcess } from '../shared/child-process/run-process'
|
||||
|
||||
const RUNNING_INSTANCES_SCRIPT = `function run(argv) {
|
||||
ObjC.import('AppKit');
|
||||
const bundle = $.NSBundle.bundleWithPath(argv[0]);
|
||||
const identifier = ObjC.unwrap(bundle.bundleIdentifier);
|
||||
if (typeof identifier !== 'string' || !identifier) throw new Error('Missing bundle identifier');
|
||||
const target = $.NSURL.fileURLWithPath(argv[0]).URLByStandardizingPath;
|
||||
const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(identifier);
|
||||
const pids = [];
|
||||
for (let i = 0; i < apps.count; i++) {
|
||||
const app = apps.objectAtIndex(i);
|
||||
if (app.bundleURL && app.bundleURL.URLByStandardizingPath.isEqual(target)) {
|
||||
pids.push(Number(app.processIdentifier));
|
||||
}
|
||||
}
|
||||
return JSON.stringify(pids);
|
||||
}`
|
||||
|
||||
/** Squirrel waits for every main application process from the target bundle. */
|
||||
export async function getMacUpdateRunningInstances(
|
||||
executable = process.execPath,
|
||||
currentPid = process.pid
|
||||
): Promise<number[]> {
|
||||
if (process.platform !== 'darwin' || !executable.includes('.app/Contents/MacOS/')) {
|
||||
return []
|
||||
}
|
||||
const bundlePath = path.dirname(path.dirname(path.dirname(executable)))
|
||||
// Match ShipIt's registry query; run-as-node workers share the executable but do not block it.
|
||||
const result = await runProcess({
|
||||
program: '/usr/bin/osascript',
|
||||
args: ['-l', 'JavaScript', '-e', RUNNING_INSTANCES_SCRIPT, bundlePath],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024,
|
||||
killOnOutputLimit: true
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
|
||||
throw new Error('Could not check running Orca instances')
|
||||
}
|
||||
return parseMacUpdateRunningInstances(result.stdout, currentPid)
|
||||
}
|
||||
|
||||
export function parseMacUpdateRunningInstances(listing: string, currentPid: number): number[] {
|
||||
const pids: unknown = JSON.parse(listing)
|
||||
if (
|
||||
!Array.isArray(pids) ||
|
||||
!pids.every((pid: unknown) => typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0)
|
||||
) {
|
||||
throw new Error('Invalid macOS application listing')
|
||||
}
|
||||
return pids.filter((pid: number) => pid !== currentPid)
|
||||
}
|
||||
@@ -19,6 +19,10 @@ describe('startup ordering', () => {
|
||||
expect(beforeQuitStart).toBeGreaterThanOrEqual(0)
|
||||
expect(willQuitStart).toBeGreaterThan(beforeQuitStart)
|
||||
expect(windowAllClosedStart).toBeGreaterThan(willQuitStart)
|
||||
expect(beforeQuit.indexOf('event.defaultPrevented')).toBeGreaterThanOrEqual(0)
|
||||
expect(beforeQuit.indexOf('event.defaultPrevented')).toBeLessThan(
|
||||
beforeQuit.indexOf('state.isQuitting = true')
|
||||
)
|
||||
expect(beforeQuit).not.toContain('unsubscribeSystemResumeBroadcast')
|
||||
expect(commitIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(disposeIndex).toBeGreaterThan(commitIndex)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const dependencyExports: [string, string[]][] = [
|
||||
['../ipc/filesystem-watcher', ['closeAllWatchers']],
|
||||
['../ipc/worktree-base-directory-watcher', ['disposeWorktreeBaseDirectoryWatchers']],
|
||||
['../ipc/folder-repo-git-upgrade', ['stopFolderRepoGitUpgradeWatch']],
|
||||
['../ipc/pty', ['killAllPty']],
|
||||
['../daemon/daemon-init', ['disconnectDaemon', 'shutdownDaemon']],
|
||||
['../ipc/ssh-shutdown-drain', ['beginSshShutdown']],
|
||||
['../agent-hooks/server', ['agentHookServer']],
|
||||
['../agent-hooks/wsl-hook-relay-manager', ['wslHookRelayManager']],
|
||||
['../agent-hooks/managed-agent-hook-controls', ['removeManagedAgentHooksAsync']],
|
||||
['../runtime/structured-agent-session-runtime', ['stopStructuredAgentSessionRuntime']],
|
||||
[
|
||||
'../runtime/structured-agent-session-runtime-teardown',
|
||||
['setStructuredAgentSessionTeardownTrigger']
|
||||
],
|
||||
['../runtime/orca-runtime-files', ['awaitRuntimeFileWatcherUnsubscribes']],
|
||||
['../runtime/runtime-metadata', ['clearRuntimeMetadataIfOwned']],
|
||||
[
|
||||
'../browser/paired-runtime-browser-client-host-runtime',
|
||||
['shutdownPairedRuntimeBrowserClientHosts']
|
||||
],
|
||||
['../browser/browser-manager', ['browserManager']],
|
||||
['../codex/codex-state-db-backfill-recovery', ['stopCodexStateDbBackfillRecoveries']],
|
||||
['../codex/codex-account-session-bridge', ['stopCodexAccountSessionBridges']],
|
||||
['../git/local-repo-ref-maintenance', ['awaitPackedRefsLockRelease']],
|
||||
['../worktree-background-removal', ['stopBackgroundWorktreeRemovals']],
|
||||
['../quit-teardown-deadline', ['settleTeardownWithinDeadline', 'settleWithinMs']],
|
||||
['../quit-teardown-start-gate', ['quitTeardownStartGate']],
|
||||
['../dock/unread-badge', ['setUnreadDockBadgeCount']],
|
||||
['../tray/system-tray', ['destroySystemTray']],
|
||||
['../telemetry/client', ['shutdownTelemetry']],
|
||||
['../observability', ['shutdownObservability']],
|
||||
['../updater', ['isQuittingForUpdate']],
|
||||
['../updater-lifecycle-diagnostics', ['recordUpdaterLifecycle']],
|
||||
['../macos-tcc-prompt-notice', ['stopTccPromptNotice']],
|
||||
['../terminal-history-gc', ['cancelHistoryGc']],
|
||||
['./window-all-closed-quit-policy', ['shouldQuitWhenAllWindowsClosed']],
|
||||
['./configure-process', ['isDevParentShutdownRequested']],
|
||||
['../persistence', ['getCanonicalUserDataPath']]
|
||||
]
|
||||
|
||||
it('keeps startup services live when the updater has vetoed before-quit', async () => {
|
||||
vi.resetModules()
|
||||
const app = new EventEmitter()
|
||||
const fenceAndCloseNow = vi.fn()
|
||||
const setMobileRelayPairingProvider = vi.fn()
|
||||
const unsubscribeAgentAwakeStatusChanges = vi.fn()
|
||||
const dispose = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const state = {
|
||||
isQuitting: false,
|
||||
desktopRelayService: { fenceAndCloseNow },
|
||||
runtimeRpc: { setMobileRelayPairingProvider },
|
||||
unsubscribeAgentAwakeStatusChanges,
|
||||
agentAwakeService: { dispose },
|
||||
rateLimits: { stop }
|
||||
}
|
||||
vi.doMock('electron', () => ({ app }))
|
||||
vi.doMock('./main-process-state', () => ({ mainProcessState: state }))
|
||||
for (const [moduleName, exports] of dependencyExports) {
|
||||
vi.doMock(moduleName, () => Object.fromEntries(exports.map((name) => [name, vi.fn()])))
|
||||
}
|
||||
const exitListenersBefore = process.listeners('exit')
|
||||
const { installMainProcessQuitHandlers } = await import('./main-process-quit')
|
||||
installMainProcessQuitHandlers()
|
||||
|
||||
app.emit('before-quit', { defaultPrevented: true })
|
||||
|
||||
expect(state.isQuitting).toBe(false)
|
||||
expect(fenceAndCloseNow).not.toHaveBeenCalled()
|
||||
expect(setMobileRelayPairingProvider).not.toHaveBeenCalled()
|
||||
expect(unsubscribeAgentAwakeStatusChanges).not.toHaveBeenCalled()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
expect(state.agentAwakeService).toEqual({ dispose })
|
||||
expect(state.unsubscribeAgentAwakeStatusChanges).toBe(unsubscribeAgentAwakeStatusChanges)
|
||||
|
||||
app.emit('before-quit', { defaultPrevented: false })
|
||||
|
||||
expect(state.isQuitting).toBe(true)
|
||||
expect(fenceAndCloseNow).toHaveBeenCalledOnce()
|
||||
expect(setMobileRelayPairingProvider).toHaveBeenCalledWith(null)
|
||||
expect(unsubscribeAgentAwakeStatusChanges).toHaveBeenCalledOnce()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(stop).toHaveBeenCalledOnce()
|
||||
for (const listener of process.listeners('exit')) {
|
||||
if (!exitListenersBefore.includes(listener)) {
|
||||
process.removeListener('exit', listener)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('electron')
|
||||
vi.doUnmock('./main-process-state')
|
||||
for (const [moduleName] of dependencyExports) {
|
||||
vi.doUnmock(moduleName)
|
||||
}
|
||||
vi.resetModules()
|
||||
})
|
||||
@@ -67,7 +67,10 @@ function shutdownWatchersOnce(): Promise<void> {
|
||||
}
|
||||
|
||||
function installBeforeQuitHandler(): void {
|
||||
app.on('before-quit', () => {
|
||||
app.on('before-quit', (event: Event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
if (isQuittingForUpdate()) {
|
||||
recordUpdaterLifecycle('before_quit_allowed', undefined, {
|
||||
message: 'before-quit allowed for update install'
|
||||
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
appMock: {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: vi.fn()
|
||||
on: vi.fn(),
|
||||
prependListener: vi.fn()
|
||||
},
|
||||
nativeUpdaterMock: { on: vi.fn() },
|
||||
getLinuxPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | 'non-root' | 'unusable'>(() => 'deb'),
|
||||
|
||||
@@ -51,6 +51,7 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean
|
||||
left.message === right.message &&
|
||||
left.version === right.version &&
|
||||
left.retryable === right.retryable &&
|
||||
left.retryAction === right.retryAction &&
|
||||
left.userInitiated === right.userInitiated &&
|
||||
left.activeNudgeId === right.activeNudgeId &&
|
||||
// Recovery identity fences async actions, so same-valued recaptures must reach the renderer.
|
||||
|
||||
@@ -38,7 +38,13 @@ const {
|
||||
}
|
||||
}
|
||||
return {
|
||||
appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: vi.fn(), quit: vi.fn() },
|
||||
appMock: {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: vi.fn(),
|
||||
prependListener: vi.fn(),
|
||||
quit: vi.fn()
|
||||
},
|
||||
autoUpdaterMock,
|
||||
clearTrackedLinuxPackageArtifactMock: vi.fn(),
|
||||
getTrackedLinuxPackageArtifactMock: vi.fn(),
|
||||
|
||||
@@ -34,11 +34,17 @@ export function registerMacUpdaterEvents({
|
||||
})
|
||||
}
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
// Why: veto before startup listeners begin shutting down services.
|
||||
app.prependListener('before-quit', (event) => {
|
||||
if (!shouldDeferMacQuitForInstall()) {
|
||||
return
|
||||
}
|
||||
if (consumeMacInstallGuardBypass()) {
|
||||
// Why: an Update & Restart is checking blockers or cleaning up; a second quit must not tear down underneath it.
|
||||
if (macInstallPreflightInProgress) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (shouldBypassMacInstallGuard()) {
|
||||
recordUpdaterLifecycle('macos_before_quit_guard_bypassed')
|
||||
return
|
||||
}
|
||||
@@ -50,7 +56,8 @@ export function registerMacUpdaterEvents({
|
||||
getCurrentStatus(),
|
||||
hasInstallableDownloadedVersion(),
|
||||
getPendingInstallVersion,
|
||||
sendStatus
|
||||
sendStatus,
|
||||
'quit'
|
||||
)
|
||||
) {
|
||||
recordUpdaterLifecycle('macos_before_quit_deferred', {
|
||||
@@ -63,16 +70,23 @@ export function registerMacUpdaterEvents({
|
||||
|
||||
/** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */
|
||||
let squirrelReady = false
|
||||
let macInstallPreflightInProgress = false
|
||||
|
||||
export function setMacInstallPreflightInProgress(value: boolean): void {
|
||||
macInstallPreflightInProgress = value
|
||||
if (value) {
|
||||
bypassMacInstallGuardUntilNextAttempt = false
|
||||
}
|
||||
}
|
||||
/** Remembers a user/app quit request that arrived before Squirrel.Mac had a
|
||||
* staged update ready to apply. Without this handoff, quitting during the
|
||||
* localhost-proxy phase exits back into the old app and the update is lost. */
|
||||
let installRequestedAfterSquirrelReady = false
|
||||
let requestedActionAfterSquirrelReady: 'quit' | 'install' | null = null
|
||||
/** Prevents the updater-specific before-quit guard from re-blocking the
|
||||
* quitAndInstall-triggered shutdown that is supposed to apply the update. */
|
||||
let quitAndInstallInFlight = false
|
||||
/** Lets a timed-out quit attempt proceed exactly once so the app never gets
|
||||
* trapped open if Squirrel.Mac stops short of the native ready signal. */
|
||||
let bypassMacInstallGuardOnce = false
|
||||
/** Both quit passes must proceed when native readiness times out. */
|
||||
let bypassMacInstallGuardUntilNextAttempt = false
|
||||
let pendingInstallTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearPendingInstallTimeout(): void {
|
||||
@@ -83,9 +97,10 @@ function clearPendingInstallTimeout(): void {
|
||||
}
|
||||
|
||||
export function resetMacInstallState(): void {
|
||||
installRequestedAfterSquirrelReady = false
|
||||
macInstallPreflightInProgress = false
|
||||
requestedActionAfterSquirrelReady = null
|
||||
quitAndInstallInFlight = false
|
||||
bypassMacInstallGuardOnce = false
|
||||
bypassMacInstallGuardUntilNextAttempt = false
|
||||
clearPendingInstallTimeout()
|
||||
}
|
||||
|
||||
@@ -95,18 +110,18 @@ export function beginMacUpdateDownload(): void {
|
||||
}
|
||||
|
||||
export function markMacQuitAndInstallInFlight(): void {
|
||||
installRequestedAfterSquirrelReady = false
|
||||
requestedActionAfterSquirrelReady = null
|
||||
quitAndInstallInFlight = true
|
||||
bypassMacInstallGuardOnce = false
|
||||
bypassMacInstallGuardUntilNextAttempt = false
|
||||
clearPendingInstallTimeout()
|
||||
}
|
||||
|
||||
export function consumeMacInstallGuardBypass(): boolean {
|
||||
if (!bypassMacInstallGuardOnce) {
|
||||
return false
|
||||
}
|
||||
bypassMacInstallGuardOnce = false
|
||||
return true
|
||||
function shouldBypassMacInstallGuard(): boolean {
|
||||
return bypassMacInstallGuardUntilNextAttempt
|
||||
}
|
||||
|
||||
export function isMacInstallRequested(): boolean {
|
||||
return requestedActionAfterSquirrelReady === 'install'
|
||||
}
|
||||
|
||||
export function isMacQuitAndInstallInFlight(): boolean {
|
||||
@@ -135,13 +150,20 @@ export function deferMacQuitUntilInstallerReady(
|
||||
currentStatus: UpdateStatus,
|
||||
hasNewerDownloadedVersion: boolean,
|
||||
getPendingInstallVersion: () => string,
|
||||
sendStatus: (status: UpdateStatus) => void
|
||||
sendStatus: (status: UpdateStatus) => void,
|
||||
intent: 'quit' | 'install' = 'install'
|
||||
): boolean {
|
||||
if (!isWaitingForMacInstallerReadiness(currentStatus, hasNewerDownloadedVersion)) {
|
||||
return false
|
||||
}
|
||||
|
||||
installRequestedAfterSquirrelReady = true
|
||||
if (intent === 'install') {
|
||||
bypassMacInstallGuardUntilNextAttempt = false
|
||||
}
|
||||
// Why: an ordinary retry of quit must not downgrade a pending Update & Restart.
|
||||
if (intent === 'install' || requestedActionAfterSquirrelReady !== 'install') {
|
||||
requestedActionAfterSquirrelReady = intent
|
||||
}
|
||||
sendStatus({ state: 'downloading', percent: 100, version: getPendingInstallVersion() })
|
||||
|
||||
if (pendingInstallTimeout) {
|
||||
@@ -150,7 +172,7 @@ export function deferMacQuitUntilInstallerReady(
|
||||
|
||||
pendingInstallTimeout = setTimeout(() => {
|
||||
pendingInstallTimeout = null
|
||||
if (!installRequestedAfterSquirrelReady || quitAndInstallInFlight) {
|
||||
if (!requestedActionAfterSquirrelReady || quitAndInstallInFlight) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,11 +184,11 @@ export function deferMacQuitUntilInstallerReady(
|
||||
message: `macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install`
|
||||
}
|
||||
)
|
||||
installRequestedAfterSquirrelReady = false
|
||||
requestedActionAfterSquirrelReady = null
|
||||
// This is a safety valve. The updater path should wait for ShipIt so the
|
||||
// staged update can apply, but if the native ready signal never arrives we
|
||||
// must let the app close instead of trapping the user in a blocked quit.
|
||||
bypassMacInstallGuardOnce = true
|
||||
bypassMacInstallGuardUntilNextAttempt = true
|
||||
app.quit()
|
||||
}, MAC_INSTALL_READY_TIMEOUT_MS)
|
||||
|
||||
@@ -181,14 +203,24 @@ export function handleMacInstallerReady(
|
||||
squirrelReady = true
|
||||
clearPendingInstallTimeout()
|
||||
recordUpdaterLifecycle('macos_installer_ready', {
|
||||
deferredInstallRequested: installRequestedAfterSquirrelReady,
|
||||
deferredInstallRequested: requestedActionAfterSquirrelReady === 'install',
|
||||
deferredQuitRequested: requestedActionAfterSquirrelReady === 'quit',
|
||||
hasNewerDownloadedVersion
|
||||
})
|
||||
|
||||
if (installRequestedAfterSquirrelReady && hasNewerDownloadedVersion) {
|
||||
if (requestedActionAfterSquirrelReady === 'quit') {
|
||||
requestedActionAfterSquirrelReady = null
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedActionAfterSquirrelReady === 'install' && hasNewerDownloadedVersion) {
|
||||
setMacInstallPreflightInProgress(true)
|
||||
void Promise.resolve()
|
||||
.then(() => onReadyToInstall())
|
||||
.catch((error) => {
|
||||
requestedActionAfterSquirrelReady = null
|
||||
setMacInstallPreflightInProgress(false)
|
||||
recordUpdaterLifecycle(
|
||||
'macos_deferred_install_handoff_failed',
|
||||
{ errorType: error instanceof Error ? error.name : typeof error },
|
||||
@@ -198,6 +230,7 @@ export function handleMacInstallerReady(
|
||||
return
|
||||
}
|
||||
|
||||
requestedActionAfterSquirrelReady = null
|
||||
if (hasNewerDownloadedVersion) {
|
||||
onReadyToReportDownloaded()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { appMock, nativeUpdaterMock } = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
return {
|
||||
appMock: Object.assign(new EventEmitter(), { quit: vi.fn() }),
|
||||
nativeUpdaterMock: new EventEmitter()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({ app: appMock, autoUpdater: nativeUpdaterMock }))
|
||||
vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: vi.fn() }))
|
||||
|
||||
import {
|
||||
beginMacUpdateDownload,
|
||||
deferMacQuitUntilInstallerReady,
|
||||
handleMacInstallerReady,
|
||||
isMacInstallRequested,
|
||||
markMacQuitAndInstallInFlight,
|
||||
registerMacUpdaterEvents,
|
||||
resetMacInstallState,
|
||||
setMacInstallPreflightInProgress
|
||||
} from './updater-mac-install'
|
||||
|
||||
function quitEvent(): { defaultPrevented: boolean; preventDefault: () => void } {
|
||||
const event = {
|
||||
defaultPrevented: false,
|
||||
preventDefault: () => {
|
||||
event.defaultPrevented = true
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function registerGuard(
|
||||
hasUpdate: boolean,
|
||||
performQuitAndInstall = vi.fn(),
|
||||
status: 'downloaded' | 'downloading' = 'downloaded'
|
||||
): void {
|
||||
registerMacUpdaterEvents({
|
||||
getCurrentStatus: () =>
|
||||
status === 'downloaded'
|
||||
? { state: 'downloaded', version: '2.0.0' }
|
||||
: { state: 'downloading', percent: 100, version: '2.0.0' },
|
||||
hasInstallableDownloadedVersion: () => hasUpdate,
|
||||
getPendingInstallVersion: () => '2.0.0',
|
||||
getKnownReleaseUrl: () => undefined,
|
||||
performQuitAndInstall,
|
||||
shouldDeferMacQuitForInstall: () => true,
|
||||
sendStatus: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
describe.runIf(process.platform === 'darwin')('macOS quit guard ordering', () => {
|
||||
beforeEach(() => {
|
||||
appMock.removeAllListeners()
|
||||
nativeUpdaterMock.removeAllListeners()
|
||||
beginMacUpdateDownload()
|
||||
appMock.quit.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetMacInstallState()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resumes an ordinary quit after native readiness without starting a relaunching install', () => {
|
||||
const install = vi.fn()
|
||||
registerGuard(true, install, 'downloading')
|
||||
const firstQuit = quitEvent()
|
||||
appMock.emit('before-quit', firstQuit)
|
||||
expect(firstQuit.defaultPrevented).toBe(true)
|
||||
|
||||
nativeUpdaterMock.emit('update-downloaded')
|
||||
|
||||
expect(appMock.quit).toHaveBeenCalledOnce()
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const resumedQuit = quitEvent()
|
||||
appMock.emit('before-quit', resumedQuit)
|
||||
expect(resumedQuit.defaultPrevented).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an explicit deferred install when an ordinary quit arrives afterward', async () => {
|
||||
const install = vi.fn()
|
||||
registerGuard(true, install, 'downloading')
|
||||
expect(
|
||||
deferMacQuitUntilInstallerReady(
|
||||
{ state: 'downloading', percent: 100, version: '2.0.0' },
|
||||
true,
|
||||
() => '2.0.0',
|
||||
vi.fn()
|
||||
)
|
||||
).toBe(true)
|
||||
const ordinaryQuit = quitEvent()
|
||||
appMock.emit('before-quit', ordinaryQuit)
|
||||
expect(ordinaryQuit.defaultPrevented).toBe(true)
|
||||
nativeUpdaterMock.emit('update-downloaded')
|
||||
const quitBeforeInstallCallback = quitEvent()
|
||||
appMock.emit('before-quit', quitBeforeInstallCallback)
|
||||
expect(quitBeforeInstallCallback.defaultPrevented).toBe(true)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
expect(appMock.quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the requested install when readiness has no installable version', () => {
|
||||
const install = vi.fn()
|
||||
registerGuard(true, install, 'downloading')
|
||||
deferMacQuitUntilInstallerReady(
|
||||
{ state: 'downloading', percent: 100, version: '2.0.0' },
|
||||
true,
|
||||
() => '2.0.0',
|
||||
vi.fn()
|
||||
)
|
||||
expect(isMacInstallRequested()).toBe(true)
|
||||
handleMacInstallerReady(false, install, vi.fn())
|
||||
expect(isMacInstallRequested()).toBe(false)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the readiness handoff guard when the install callback rejects', async () => {
|
||||
registerGuard(true, vi.fn(), 'downloading')
|
||||
deferMacQuitUntilInstallerReady(
|
||||
{ state: 'downloading', percent: 100, version: '2.0.0' },
|
||||
true,
|
||||
() => '2.0.0',
|
||||
vi.fn()
|
||||
)
|
||||
handleMacInstallerReady(
|
||||
true,
|
||||
() => {
|
||||
throw new Error('handoff rejected')
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(isMacInstallRequested()).toBe(false)
|
||||
const ordinaryQuit = quitEvent()
|
||||
appMock.emit('before-quit', ordinaryQuit)
|
||||
expect(ordinaryQuit.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('allows both timeout shutdown passes and revokes that allowance for a new deferred install', async () => {
|
||||
vi.useFakeTimers()
|
||||
const install = vi.fn()
|
||||
registerGuard(true, install, 'downloading')
|
||||
appMock.emit('before-quit', quitEvent())
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(appMock.quit).toHaveBeenCalledOnce()
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const timeoutQuit = quitEvent()
|
||||
appMock.emit('before-quit', timeoutQuit)
|
||||
expect(timeoutQuit.defaultPrevented).toBe(false)
|
||||
}
|
||||
|
||||
deferMacQuitUntilInstallerReady(
|
||||
{ state: 'downloading', percent: 100, version: '2.0.0' },
|
||||
true,
|
||||
() => '2.0.0',
|
||||
vi.fn()
|
||||
)
|
||||
const retryQuit = quitEvent()
|
||||
appMock.emit('before-quit', retryQuit)
|
||||
expect(retryQuit.defaultPrevented).toBe(true)
|
||||
nativeUpdaterMock.emit('update-downloaded')
|
||||
await Promise.resolve()
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('vetoes a quit during install preflight before previously registered startup services shut down', () => {
|
||||
const shutdown = vi.fn()
|
||||
appMock.on('before-quit', (event) => {
|
||||
if (!event.defaultPrevented) {
|
||||
shutdown()
|
||||
}
|
||||
})
|
||||
registerGuard(true)
|
||||
handleMacInstallerReady(true, vi.fn(), vi.fn())
|
||||
setMacInstallPreflightInProgress(true)
|
||||
const event = quitEvent()
|
||||
|
||||
appMock.emit('before-quit', event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets an ordinary quit with a staged update exit instead of converting it into a relaunching install', () => {
|
||||
// Why: restart flows call app.relaunch() then app.quit(); converting that quit into
|
||||
// quitAndInstall would race the relaunched old app against ShipIt.
|
||||
const install = vi.fn()
|
||||
registerGuard(true, install)
|
||||
handleMacInstallerReady(true, vi.fn(), vi.fn())
|
||||
const event = quitEvent()
|
||||
|
||||
appMock.emit('before-quit', event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vetoes duplicate quits through cleanup and allows the native install shutdown', () => {
|
||||
registerGuard(true)
|
||||
handleMacInstallerReady(true, vi.fn(), vi.fn())
|
||||
setMacInstallPreflightInProgress(true)
|
||||
markMacQuitAndInstallInFlight()
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const event = quitEvent()
|
||||
appMock.emit('before-quit', event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
}
|
||||
|
||||
setMacInstallPreflightInProgress(false)
|
||||
const nativeQuit = quitEvent()
|
||||
appMock.emit('before-quit', nativeQuit)
|
||||
expect(nativeQuit.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('allows both ordinary quit passes after refusal and vetoes a new install attempt', () => {
|
||||
const install = vi.fn()
|
||||
registerGuard(true, install)
|
||||
handleMacInstallerReady(true, vi.fn(), vi.fn())
|
||||
resetMacInstallState()
|
||||
const normalQuit = quitEvent()
|
||||
appMock.emit('before-quit', normalQuit)
|
||||
expect(normalQuit.defaultPrevented).toBe(false)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
|
||||
const teardownQuit = quitEvent()
|
||||
appMock.emit('before-quit', teardownQuit)
|
||||
expect(teardownQuit.defaultPrevented).toBe(false)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
|
||||
setMacInstallPreflightInProgress(true)
|
||||
const retryQuit = quitEvent()
|
||||
appMock.emit('before-quit', retryQuit)
|
||||
expect(retryQuit.defaultPrevented).toBe(true)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows ordinary quits when no update is available', () => {
|
||||
const install = vi.fn()
|
||||
registerGuard(false, install)
|
||||
const event = quitEvent()
|
||||
appMock.emit('before-quit', event)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterAll, vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import { clearTrackedRealTimers, trackRealTimers } from './updater-test-timer-tracking'
|
||||
@@ -28,6 +29,7 @@ type AppMock = {
|
||||
isPackaged: boolean
|
||||
getVersion: Mock<() => string>
|
||||
on: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock>
|
||||
prependListener: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock>
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
quit: UpdaterSpy
|
||||
}
|
||||
@@ -102,33 +104,25 @@ export const PRE_COMMIT_INSTALL_FAILURE =
|
||||
* `vi.hoisted` block so the mocks exist before the mock factories run.
|
||||
*/
|
||||
export function createUpdaterMocks(): UpdaterMocks {
|
||||
const appEventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
const eventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
|
||||
const appEvents = new EventEmitter()
|
||||
const updaterEvents = new EventEmitter()
|
||||
const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = appEventHandlers.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
appEventHandlers.set(event, handlers)
|
||||
appEvents.on(event, handler)
|
||||
return appMock
|
||||
})
|
||||
|
||||
const appEmit = (event: string, ...args: unknown[]) => {
|
||||
for (const handler of appEventHandlers.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appEvents.prependListener(event, handler)
|
||||
return appMock
|
||||
})
|
||||
const appEmit = (event: string, ...args: unknown[]): void => {
|
||||
appEvents.emit(event, ...args)
|
||||
}
|
||||
|
||||
const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = eventHandlers.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
eventHandlers.set(event, handlers)
|
||||
updaterEvents.on(event, handler)
|
||||
return autoUpdaterMock
|
||||
})
|
||||
|
||||
const emit = (event: string, ...args: unknown[]) => {
|
||||
for (const handler of eventHandlers.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
const emit = (event: string, ...args: unknown[]): void => {
|
||||
updaterEvents.emit(event, ...args)
|
||||
}
|
||||
|
||||
// Why: `vi.resetModules()` abandons the previous test's `updater` module instance but cannot cancel
|
||||
@@ -161,9 +155,10 @@ export function createUpdaterMocks(): UpdaterMocks {
|
||||
|
||||
const reset = () => {
|
||||
currentGeneration += 1
|
||||
appEventHandlers.clear()
|
||||
appEvents.removeAllListeners()
|
||||
appOn.mockClear()
|
||||
eventHandlers.clear()
|
||||
appPrependListener.mockClear()
|
||||
updaterEvents.removeAllListeners()
|
||||
on.mockClear()
|
||||
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null)
|
||||
autoUpdaterMock.downloadUpdate.mockReset()
|
||||
@@ -201,6 +196,7 @@ export function createUpdaterMocks(): UpdaterMocks {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: appOn,
|
||||
prependListener: appPrependListener,
|
||||
emit: appEmit,
|
||||
quit: vi.fn()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock,
|
||||
return appMock
|
||||
})
|
||||
|
||||
const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = appEventHandlers.get(event) ?? []
|
||||
handlers.unshift(handler)
|
||||
appEventHandlers.set(event, handlers)
|
||||
return appMock
|
||||
})
|
||||
|
||||
const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = eventHandlers.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
@@ -36,6 +43,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock,
|
||||
const reset = () => {
|
||||
appEventHandlers.clear()
|
||||
appOn.mockClear()
|
||||
appPrependListener.mockClear()
|
||||
eventHandlers.clear()
|
||||
on.mockClear()
|
||||
autoUpdaterMock.checkForUpdates.mockReset()
|
||||
@@ -61,6 +69,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock,
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: appOn,
|
||||
prependListener: appPrependListener,
|
||||
quit: vi.fn()
|
||||
},
|
||||
browserWindowMock: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-load
|
||||
const {
|
||||
appMock,
|
||||
autoUpdaterMock,
|
||||
nativeUpdaterMock,
|
||||
fetchChangelogMock,
|
||||
fetchNewerReleaseTagsMock,
|
||||
moduleFactories,
|
||||
@@ -31,6 +32,93 @@ describe('updater', () => {
|
||||
resetUpdaterMocks()
|
||||
})
|
||||
|
||||
it('keeps the staged target when installation cancels a queued background feed check', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveQueuedTags: (value: { tags: string[]; state: 'ready' }) => void = () => {}
|
||||
fetchNewerReleaseTagsMock
|
||||
.mockResolvedValueOnce({ tags: ['v1.0.61'], state: 'ready' })
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ tags: string[]; state: 'ready' }>((resolve) => {
|
||||
resolveQueuedTags = resolve
|
||||
})
|
||||
)
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
autoUpdaterMock.downloadUpdate.mockResolvedValue([])
|
||||
let rejectCleanup: (error: Error) => void = () => {}
|
||||
const onBeforeQuit = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectCleanup = reject
|
||||
})
|
||||
)
|
||||
const send = vi.fn()
|
||||
const {
|
||||
setupAutoUpdater,
|
||||
checkForUpdatesFromMenu,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
quitAndInstall,
|
||||
getUpdateStatus
|
||||
} = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
onBeforeQuit,
|
||||
onBeforeQuitFailure: 'abort'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce())
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
const nativeReady = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1]
|
||||
if (typeof nativeReady === 'function') {
|
||||
nativeReady()
|
||||
}
|
||||
expect(getUpdateStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'downloaded',
|
||||
version: '1.0.61'
|
||||
})
|
||||
)
|
||||
const stagedFeed = autoUpdaterMock.setFeedURL.mock.calls.at(-1)
|
||||
|
||||
checkForUpdates()
|
||||
await vi.waitFor(() => expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2))
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(onBeforeQuit).toHaveBeenCalledOnce()
|
||||
resolveQueuedTags({ tags: ['v1.0.71'], state: 'ready' })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.setFeedURL.mock.calls.at(-1)).toEqual(stagedFeed)
|
||||
expect(getUpdateStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
state: 'downloaded',
|
||||
version: '1.0.61'
|
||||
})
|
||||
)
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
|
||||
rejectCleanup(new Error('required checkpoint failed'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(getUpdateStatus().state).toBe('error')
|
||||
autoUpdaterMock.downloadUpdate.mockClear()
|
||||
downloadUpdate()
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce()
|
||||
expect(send).toHaveBeenCalledWith('updater:status', {
|
||||
state: 'downloading',
|
||||
percent: 0,
|
||||
version: '1.0.61'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale updater events while a new check is still in feed preflight', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('statusesEqual', () => {
|
||||
|
||||
expect(statusesEqual(error, { ...error, version: '1.0.62' })).toBe(false)
|
||||
expect(statusesEqual(error, { ...error, retryable: true })).toBe(false)
|
||||
expect(statusesEqual(error, { ...error, retryAction: 'install' })).toBe(false)
|
||||
expect(statusesEqual(error, { ...error })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,10 @@ const {
|
||||
appHandlers.set(event, [...(appHandlers.get(event) ?? []), handler])
|
||||
return appMock
|
||||
}),
|
||||
prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appHandlers.set(event, [handler, ...(appHandlers.get(event) ?? [])])
|
||||
return appMock
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => emit(appHandlers, event, ...args),
|
||||
quit: vi.fn()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ const {
|
||||
return appMock
|
||||
})
|
||||
|
||||
const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = appEventHandlers.get(event) ?? []
|
||||
handlers.unshift(handler)
|
||||
appEventHandlers.set(event, handlers)
|
||||
return appMock
|
||||
})
|
||||
|
||||
const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = eventHandlers.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
@@ -39,6 +46,7 @@ const {
|
||||
const reset = () => {
|
||||
appEventHandlers.clear()
|
||||
appOn.mockClear()
|
||||
appPrependListener.mockClear()
|
||||
eventHandlers.clear()
|
||||
on.mockClear()
|
||||
autoUpdaterMock.checkForUpdates.mockReset()
|
||||
@@ -64,6 +72,7 @@ const {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.4.162'),
|
||||
on: appOn,
|
||||
prependListener: appPrependListener,
|
||||
quit: vi.fn(),
|
||||
exit: vi.fn()
|
||||
},
|
||||
|
||||
@@ -8,7 +8,8 @@ const {
|
||||
autoUpdaterMock,
|
||||
shellMock,
|
||||
isMock,
|
||||
killAllPtyMock
|
||||
killAllPtyMock,
|
||||
getMacUpdateRunningInstancesMock
|
||||
} = vi.hoisted(() => {
|
||||
const appEventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
const eventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
@@ -66,6 +67,9 @@ const {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: appOn,
|
||||
prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appEventHandlers.set(event, [handler, ...(appEventHandlers.get(event) ?? [])])
|
||||
}),
|
||||
emit: appEmit,
|
||||
quit: vi.fn()
|
||||
},
|
||||
@@ -80,6 +84,7 @@ const {
|
||||
openExternal: vi.fn()
|
||||
},
|
||||
isMock: { dev: false },
|
||||
getMacUpdateRunningInstancesMock: vi.fn(async (): Promise<number[]> => []),
|
||||
killAllPtyMock: vi.fn()
|
||||
}
|
||||
})
|
||||
@@ -118,8 +123,25 @@ vi.mock('./updater-nudge', () => ({
|
||||
shouldApplyNudge: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
vi.mock('./macos-update-running-instances', () => ({
|
||||
getMacUpdateRunningInstances: getMacUpdateRunningInstancesMock
|
||||
}))
|
||||
|
||||
warmUpdaterModule()
|
||||
|
||||
async function prepareStagedMacUpdate(downloadUpdate: () => void): Promise<void> {
|
||||
await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1))
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
const nativeReady = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1]
|
||||
nativeReady?.()
|
||||
}
|
||||
|
||||
describe('updater mac install handoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -134,18 +156,21 @@ describe('updater mac install handoff', () => {
|
||||
appMock.isPackaged = true
|
||||
isMock.dev = false
|
||||
killAllPtyMock.mockReset()
|
||||
getMacUpdateRunningInstancesMock.mockReset().mockResolvedValue([])
|
||||
autoUpdaterMock.downloadUpdate.mockResolvedValue([])
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'waits for Squirrel.Mac before honoring a manual quit that should install the update',
|
||||
'resumes an ordinary quit when Squirrel becomes ready without checking blockers or relaunching',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([9654])
|
||||
const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule()
|
||||
|
||||
setupAutoUpdater(mainWindow as never)
|
||||
@@ -156,7 +181,7 @@ describe('updater mac install handoff', () => {
|
||||
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
|
||||
// Why: the update-available handler is now async (it awaits fetchChangelog).
|
||||
// Flush microtasks so setAvailableVersion runs before update-downloaded fires.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
@@ -173,9 +198,14 @@ describe('updater mac install handoff', () => {
|
||||
|
||||
nativeDownloadedHandler?.()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(appMock.quit).toHaveBeenCalledOnce()
|
||||
expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
const resumedPreventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault: resumedPreventDefault })
|
||||
appMock.emit('before-quit', { preventDefault: resumedPreventDefault })
|
||||
expect(resumedPreventDefault).not.toHaveBeenCalled()
|
||||
expect(sendMock).toHaveBeenCalledWith('updater:status', {
|
||||
state: 'downloading',
|
||||
percent: 100,
|
||||
@@ -184,6 +214,60 @@ describe('updater mac install handoff', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'checks blockers for an explicit install requested before Squirrel becomes ready',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([9654])
|
||||
const {
|
||||
setupAutoUpdater,
|
||||
downloadUpdate,
|
||||
quitAndInstall,
|
||||
isQuittingForUpdate,
|
||||
checkForUpdates,
|
||||
checkForUpdatesFromMenu,
|
||||
getUpdateStatus
|
||||
} = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send } } as never)
|
||||
await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce())
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
quitAndInstall()
|
||||
checkForUpdatesFromMenu()
|
||||
checkForUpdatesFromMenu({ localBuild: true })
|
||||
checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' })
|
||||
checkForUpdates()
|
||||
downloadUpdate()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce()
|
||||
expect(getUpdateStatus()).toEqual(
|
||||
expect.objectContaining({ state: 'downloading', percent: 100, version: '1.0.61' })
|
||||
)
|
||||
const nativeReady = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1]
|
||||
nativeReady?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(appMock.quit).not.toHaveBeenCalled()
|
||||
expect(isQuittingForUpdate()).toBe(false)
|
||||
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'error', version: '1.0.61', retryAction: 'install' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'ignores duplicate quit requests while deferred mac install cleanup is running',
|
||||
async () => {
|
||||
@@ -211,6 +295,7 @@ describe('updater mac install handoff', () => {
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
quitAndInstall()
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
@@ -224,6 +309,7 @@ describe('updater mac install handoff', () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
|
||||
expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
|
||||
quitAndInstall()
|
||||
@@ -309,7 +395,305 @@ describe('updater mac install handoff', () => {
|
||||
const secondPreventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault: secondPreventDefault })
|
||||
expect(secondPreventDefault).not.toHaveBeenCalled()
|
||||
appMock.emit('before-quit', { preventDefault: secondPreventDefault })
|
||||
expect(secondPreventDefault).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'keeps the app and terminals open when another bundle instance blocks installation, then allows retry',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn()
|
||||
const onBeforeQuit = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } =
|
||||
await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit })
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([9654, 10718])
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(onBeforeQuit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(isQuittingForUpdate()).toBe(false)
|
||||
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
message: expect.stringContaining('9654, 10718'),
|
||||
retryAction: 'install'
|
||||
})
|
||||
)
|
||||
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([])
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'keeps the app open when process enumeration fails',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn()
|
||||
const onBeforeQuit = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } =
|
||||
await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit })
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
getMacUpdateRunningInstancesMock.mockRejectedValue(new Error('probe failed'))
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(onBeforeQuit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(isQuittingForUpdate()).toBe(false)
|
||||
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
message: expect.stringContaining('Could not check'),
|
||||
retryAction: 'install'
|
||||
})
|
||||
)
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({
|
||||
message: expect.stringMatching(/close the other Orca instances.*quit Orca/i)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'leaves an ordinary quit with a staged update to Squirrel instead of starting a relaunching install',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const onBeforeQuit = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit })
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled()
|
||||
expect(onBeforeQuit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'blocks duplicate quits while the running-instance check is pending',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const onBeforeQuit = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit })
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
|
||||
let finishProbe: (pids: number[]) => void = () => {}
|
||||
getMacUpdateRunningInstancesMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve
|
||||
})
|
||||
)
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2)
|
||||
expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledTimes(1)
|
||||
finishProbe([9654])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(onBeforeQuit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'allows ordinary quitting after a refused install without requiring background servers to exit',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never)
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([9654])
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
// The main will-quit handler requests quit again after asynchronous teardown.
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'prevents duplicate quits until asynchronous update cleanup finishes',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
let finishCleanup = (): void => {}
|
||||
const onBeforeQuit = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
)
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit })
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
finishCleanup()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const nativePreventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault: nativePreventDefault })
|
||||
expect(nativePreventDefault).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'cannot use a previous refusal to bypass a pending retry check',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never)
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([9654])
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
let finishProbe = (_pids: number[]): void => {}
|
||||
getMacUpdateRunningInstancesMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve
|
||||
})
|
||||
)
|
||||
quitAndInstall()
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2)
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
finishProbe([])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'permits ordinary shutdown after required update cleanup rejects',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const onBeforeQuit = vi.fn().mockRejectedValue(new Error('required cleanup failed'))
|
||||
const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never, {
|
||||
onBeforeQuit,
|
||||
onBeforeQuitFailure: 'abort'
|
||||
})
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(onBeforeQuit).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'keeps the install target and quit guard when checks or downloads are requested during a retry',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const {
|
||||
setupAutoUpdater,
|
||||
downloadUpdate,
|
||||
quitAndInstall,
|
||||
checkForUpdates,
|
||||
checkForUpdatesFromMenu,
|
||||
getUpdateStatus
|
||||
} = await loadUpdaterModule()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture.
|
||||
setupAutoUpdater({ webContents: { send: vi.fn() } } as never)
|
||||
await prepareStagedMacUpdate(downloadUpdate)
|
||||
getMacUpdateRunningInstancesMock.mockResolvedValue([9654])
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
let finishProbe = (_pids: number[]): void => {}
|
||||
getMacUpdateRunningInstancesMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve
|
||||
})
|
||||
)
|
||||
const failedStatus = getUpdateStatus()
|
||||
const checks = autoUpdaterMock.checkForUpdates.mock.calls.length
|
||||
const downloads = autoUpdaterMock.downloadUpdate.mock.calls.length
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const requestCompetingActions = (): void => {
|
||||
checkForUpdatesFromMenu()
|
||||
checkForUpdatesFromMenu({ localBuild: true })
|
||||
checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' })
|
||||
checkForUpdates()
|
||||
downloadUpdate()
|
||||
}
|
||||
quitAndInstall()
|
||||
requestCompetingActions()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
requestCompetingActions()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(checks)
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(downloads)
|
||||
expect(getUpdateStatus()).toEqual(failedStatus)
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
finishProbe([])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install'
|
||||
import {
|
||||
beginMacUpdateDownload,
|
||||
deferMacQuitUntilInstallerReady,
|
||||
isMacInstallRequested,
|
||||
setMacInstallPreflightInProgress
|
||||
} from '../updater-mac-install'
|
||||
import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics'
|
||||
import { isExternallyManagedLinuxInstall } from '../linux-update-package-type'
|
||||
import { LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE } from '../linux-package-downloaded-status'
|
||||
@@ -12,7 +17,8 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus {
|
||||
this.localBuildSelectionInProgress ||
|
||||
this.pinnedBuildSelectionInProgress ||
|
||||
this.pendingQuitAndInstallTimer ||
|
||||
this.quitAndInstallInProgress
|
||||
this.quitAndInstallInProgress ||
|
||||
isMacInstallRequested()
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -20,6 +26,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus {
|
||||
if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) {
|
||||
return
|
||||
}
|
||||
// A queued check must not repoint the feed while native staging or installation is pending.
|
||||
this.finishActiveUpdateCheckAttempt()
|
||||
this.clearBackgroundCheckLaunchPending()
|
||||
if (
|
||||
deferMacQuitUntilInstallerReady(
|
||||
this.currentStatus,
|
||||
@@ -31,6 +40,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
setMacInstallPreflightInProgress(true)
|
||||
}
|
||||
// Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing.
|
||||
this.pendingQuitAndInstallTimer = setTimeout(() => {
|
||||
void this.performQuitAndInstall()
|
||||
@@ -41,6 +53,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus {
|
||||
if (
|
||||
this.localBuildSelectionInProgress ||
|
||||
this.pinnedBuildSelectionInProgress ||
|
||||
this.pendingQuitAndInstallTimer ||
|
||||
this.quitAndInstallInProgress ||
|
||||
isMacInstallRequested() ||
|
||||
this.downloadInFlight
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -2,7 +2,12 @@ import { BrowserWindow } from 'electron'
|
||||
import { killAllPty } from '../ipc/pty'
|
||||
import { withUpdaterSpan } from '../observability/instrumentation'
|
||||
import { runWithLaunchPath } from '../startup/hydrate-shell-path'
|
||||
import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install'
|
||||
import {
|
||||
markMacQuitAndInstallInFlight,
|
||||
isMacInstallerReady,
|
||||
setMacInstallPreflightInProgress
|
||||
} from '../updater-mac-install'
|
||||
import { getMacUpdateRunningInstances } from '../macos-update-running-instances'
|
||||
import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog'
|
||||
import { getLinuxPackageType } from '../linux-update-package-type'
|
||||
import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-downloaded-status'
|
||||
@@ -51,14 +56,49 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery {
|
||||
})
|
||||
return
|
||||
}
|
||||
this.finishActiveUpdateCheckAttempt()
|
||||
this.clearBackgroundCheckLaunchPending()
|
||||
this.quitAndInstallInProgress = true
|
||||
|
||||
markMacQuitAndInstallInFlight()
|
||||
|
||||
// Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle.
|
||||
this.quittingForUpdate = true
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
setMacInstallPreflightInProgress(true)
|
||||
let blockers: number[]
|
||||
try {
|
||||
blockers = await getMacUpdateRunningInstances()
|
||||
} catch {
|
||||
this.resetQuitForUpdateState()
|
||||
this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted')
|
||||
this.sendInstallFailureStatus({
|
||||
state: 'error',
|
||||
version: pendingVersion,
|
||||
retryAction: 'install',
|
||||
message:
|
||||
'Could not check for other running Orca instances. Orca remains open. Try again. If the check keeps failing, close the other Orca instances and background orca serve servers, then quit Orca to let the update install on exit. Reopen Orca afterwards.'
|
||||
})
|
||||
recordUpdaterLifecycle('macos_running_instances_check_failed')
|
||||
return
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
this.resetQuitForUpdateState()
|
||||
this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted')
|
||||
this.sendInstallFailureStatus({
|
||||
state: 'error',
|
||||
version: pendingVersion,
|
||||
retryAction: 'install',
|
||||
message: `Close the other Orca instances (process IDs: ${blockers.slice(0, 10).join(', ')}) before installing this update. Background orca serve instances also need to stop. Orca remains open; retry the update after closing them.`
|
||||
})
|
||||
recordUpdaterLifecycle('macos_install_blocked_by_running_instances', {
|
||||
pids: blockers.slice(0, 10).join(', '),
|
||||
count: blockers.length
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
markMacQuitAndInstallInFlight()
|
||||
await withUpdaterSpan({ stage: 'install' }, async (span) => {
|
||||
span.setAttribute('updater.version', pendingVersion || 'unknown')
|
||||
span.setAttribute('updater.platform', process.platform)
|
||||
@@ -104,6 +144,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery {
|
||||
return
|
||||
}
|
||||
// Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure.
|
||||
setMacInstallPreflightInProgress(false)
|
||||
this.quitAndInstallNativeInvoked = true
|
||||
// Why: invoke before killAllPty/removing close listeners so a sync 'error' can recover while windows and PTYs are intact.
|
||||
const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isMacInstallRequested } from '../updater-mac-install'
|
||||
import type { UpdateCheckOptions } from '../../shared/update-status-types'
|
||||
import type { ReleaseChannel } from '../../shared/release-channel'
|
||||
import { UpdaterScheduling } from './updater-scheduling'
|
||||
@@ -7,6 +8,13 @@ import { UpdaterScheduling } from './updater-scheduling'
|
||||
/** Handles checks initiated from the desktop menu and modifier-key variants. */
|
||||
export abstract class UpdaterMenuChecks extends UpdaterScheduling {
|
||||
protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
|
||||
if (
|
||||
this.pendingQuitAndInstallTimer ||
|
||||
this.quitAndInstallInProgress ||
|
||||
isMacInstallRequested()
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!app.isPackaged || is.dev) {
|
||||
this.sendStatus({ state: 'not-available', userInitiated: true })
|
||||
return
|
||||
@@ -60,6 +68,13 @@ export abstract class UpdaterMenuChecks extends UpdaterScheduling {
|
||||
const attemptId = this.beginUpdateCheckAttempt()
|
||||
const autoUpdater = this.getAutoUpdater()
|
||||
const launch = (): Promise<unknown> | undefined => {
|
||||
if (
|
||||
this.pendingQuitAndInstallTimer ||
|
||||
this.quitAndInstallInProgress ||
|
||||
isMacInstallRequested()
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (!this.isActiveUpdateCheckAttempt(attemptId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isMacInstallRequested } from '../updater-mac-install'
|
||||
import { withUpdaterSpan } from '../observability/instrumentation'
|
||||
import {
|
||||
AUTO_UPDATE_CHECK_INTERVAL_MS,
|
||||
@@ -45,6 +46,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure {
|
||||
protected runBackgroundUpdateCheck(
|
||||
nudgeId: string | null = this.getPersistedPendingUpdateNudgeId()
|
||||
): boolean {
|
||||
if (
|
||||
this.pendingQuitAndInstallTimer ||
|
||||
this.quitAndInstallInProgress ||
|
||||
isMacInstallRequested()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build.
|
||||
if (
|
||||
this.activeUpdateSource !== 'release' ||
|
||||
@@ -69,6 +77,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure {
|
||||
const attemptId = this.beginUpdateCheckAttempt()
|
||||
const autoUpdater = this.getAutoUpdater()
|
||||
const launch = (): Promise<unknown> | undefined => {
|
||||
if (
|
||||
this.pendingQuitAndInstallTimer ||
|
||||
this.quitAndInstallInProgress ||
|
||||
isMacInstallRequested()
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (!this.isActiveUpdateCheckAttempt(attemptId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -329,6 +329,33 @@ describe('createOrFocusDashboardPopout', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps saving bounds after a vetoed quit and freezes them only on an allowed quit', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = makeStore()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The window fixture reads getUI, updateUI, and onUIChanged supplied by this store.
|
||||
createOrFocusDashboardPopout(store as never)
|
||||
const win = instances[0]
|
||||
const freeze = appOnMock.mock.calls.find(([event]) => event === 'before-quit')?.[1]
|
||||
expect(freeze).toBeTypeOf('function')
|
||||
freeze({ defaultPrevented: true })
|
||||
win.bounds = { x: 10, y: 20, width: 1200, height: 900 }
|
||||
win.emit('resize')
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(store.updateUI).toHaveBeenCalledWith({
|
||||
dashboardPopoutBounds: { x: 10, y: 20, width: 1200, height: 900 }
|
||||
})
|
||||
|
||||
store.updateUI.mockClear()
|
||||
freeze({ defaultPrevented: false })
|
||||
win.emit('resize')
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(store.updateUI).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('closeDashboardPopout closes an open window', () => {
|
||||
createOrFocusDashboardPopout(makeStore() as never)
|
||||
const win = instances[0]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron'
|
||||
import { app, BrowserWindow, nativeTheme, type WebContents, type Event } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -264,7 +264,10 @@ export function createOrFocusDashboardPopout(
|
||||
window.on('resize', saveBounds)
|
||||
window.on('move', saveBounds)
|
||||
|
||||
const freezeBounds = (): void => {
|
||||
const freezeBounds = (event?: Event): void => {
|
||||
if (event?.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
windowClosing = true
|
||||
if (boundsTimer) {
|
||||
clearTimeout(boundsTimer)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const { appMock } = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
return { appMock: new EventEmitter() }
|
||||
})
|
||||
vi.mock('electron', () => ({ app: appMock }))
|
||||
vi.mock('./foreground-activation-policy', () => ({
|
||||
isWindowlessLaunch: () => true,
|
||||
showWindowWithoutStealingFocus: vi.fn()
|
||||
}))
|
||||
vi.mock('./main-window-visual-lifecycle', () => ({
|
||||
MIN_WIDTH: 480,
|
||||
MIN_HEIGHT: 360,
|
||||
syncTrafficLightPosition: vi.fn()
|
||||
}))
|
||||
|
||||
import { installMainWindowStateLifecycle } from './main-window-state-lifecycle'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appMock.removeAllListeners()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('continues saving bounds after an updater quit veto and freezes them on allowed quit', async () => {
|
||||
const mainWindow = Object.assign(new EventEmitter(), {
|
||||
webContents: Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
setZoomLevel: vi.fn()
|
||||
}),
|
||||
isDestroyed: () => false,
|
||||
isFullScreen: () => false,
|
||||
isMaximized: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 })
|
||||
})
|
||||
const updateUI = vi.fn()
|
||||
const lifecycle = installMainWindowStateLifecycle({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the window members read by the bounds lifecycle.
|
||||
mainWindow: mainWindow as never,
|
||||
revealOnDidFinishLoad: false,
|
||||
savedMaximized: false,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bounds persistence reads only updateUI from this store fixture.
|
||||
store: { updateUI } as never
|
||||
})
|
||||
appMock.emit('before-quit', { defaultPrevented: true })
|
||||
expect(lifecycle.isWindowClosing()).toBe(false)
|
||||
mainWindow.emit('resize')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(updateUI).toHaveBeenCalledWith({
|
||||
windowMaximized: false,
|
||||
windowBounds: { x: 0, y: 0, width: 1200, height: 800 }
|
||||
})
|
||||
|
||||
updateUI.mockClear()
|
||||
appMock.emit('before-quit', { defaultPrevented: false })
|
||||
expect(lifecycle.isWindowClosing()).toBe(true)
|
||||
mainWindow.emit('resize')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(updateUI).not.toHaveBeenCalled()
|
||||
lifecycle.clearInitialRevealFallbackTimer()
|
||||
lifecycle.dispose()
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, type BrowserWindow } from 'electron'
|
||||
import { app, type BrowserWindow, type Event } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { uiZoomFactorFromLevel } from '../../shared/ui-zoom-level'
|
||||
import { isWindowlessLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy'
|
||||
@@ -7,7 +7,7 @@ import { MIN_HEIGHT, MIN_WIDTH, syncTrafficLightPosition } from './main-window-v
|
||||
export type MainWindowStateLifecycle = {
|
||||
clearInitialRevealFallbackTimer: () => void
|
||||
dispose: () => void
|
||||
freezeBoundsOnQuit: () => void
|
||||
freezeBoundsOnQuit: (event?: Event) => void
|
||||
isWindowClosing: () => boolean
|
||||
resumeBoundsPersistence: () => void
|
||||
}
|
||||
@@ -105,7 +105,10 @@ export function installMainWindowStateLifecycle(args: {
|
||||
mainWindow.on('move', saveBounds)
|
||||
|
||||
// Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown.
|
||||
const freezeBoundsOnQuit = (): void => {
|
||||
const freezeBoundsOnQuit = (event?: Event): void => {
|
||||
if (event?.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
windowClosing = true
|
||||
if (boundsTimer) {
|
||||
clearTimeout(boundsTimer)
|
||||
|
||||
@@ -95,6 +95,37 @@ afterEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('UpdateCard staged install recovery', () => {
|
||||
it.each([undefined, 'local'] as const)(
|
||||
'retries a blocked staged install without downloading again (source %s)',
|
||||
(source) => {
|
||||
renderWithInitialStatus({
|
||||
state: 'error',
|
||||
version: '1.4.200',
|
||||
message:
|
||||
'Close the other Orca instances (process IDs: 12345) before installing this update.',
|
||||
retryAction: 'install',
|
||||
...(source ? { source } : {})
|
||||
})
|
||||
|
||||
expect(screen.getByText(/process IDs: 12345/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }))
|
||||
expect(quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
expect(download).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Choose Another Build' })).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the download retry for errors from older hosts without an install action', () => {
|
||||
renderWithInitialStatus({ state: 'error', version: '1.4.200', message: 'Download failed' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry Download' }))
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
expect(quitAndInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UpdateCard Windows signature failures', () => {
|
||||
it('does not offer the rejected version as a manual publisher-check bypass', () => {
|
||||
const message =
|
||||
|
||||
@@ -47,6 +47,18 @@ export function buildUpdateCardErrorModel({
|
||||
}
|
||||
: null
|
||||
}
|
||||
if (status.retryAction === 'install' && status.retryable !== false) {
|
||||
return {
|
||||
title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'),
|
||||
summary: status.message,
|
||||
detail: status.message,
|
||||
releaseUrl: getReleaseNotesUrlForVersion(cachedVersion),
|
||||
primaryAction: {
|
||||
label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'),
|
||||
onClick: onInstallRetry
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLocalBuild) {
|
||||
return {
|
||||
title: cachedVersion
|
||||
|
||||
+8
-3
@@ -391,7 +391,7 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a published paired chat subscribed and delivers after a workspace-id collision', async () => {
|
||||
it('keeps a legacy published paired chat subscribed and delivers after a workspace-id collision', async () => {
|
||||
const store = mocks.store
|
||||
if (!store) {
|
||||
throw new Error('test store was not initialized')
|
||||
@@ -445,10 +445,15 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
100
|
||||
)
|
||||
)
|
||||
const tab = store.getState().unifiedTabsByWorktree[WORKSPACE][0]
|
||||
if (!tab) {
|
||||
const publishedTab = store.getState().unifiedTabsByWorktree[WORKSPACE][0]
|
||||
if (!publishedTab) {
|
||||
throw new Error('snapshot did not publish a chat tab')
|
||||
}
|
||||
expect(publishedTab.executionHostId).toBe('runtime:env-1')
|
||||
// Why: restored tabs from before host stamping can still have ambiguous catalog ownership.
|
||||
const tab = { ...publishedTab }
|
||||
delete tab.executionHostId
|
||||
store.setState({ unifiedTabsByWorktree: { [WORKSPACE]: [tab] } })
|
||||
expect(tab.contentType).toBe('agent-session')
|
||||
expect(tab.executionHostId).toBeUndefined()
|
||||
render(<StructuredAgentSessionAttentionBridge />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../../store'
|
||||
import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection'
|
||||
@@ -7,7 +7,10 @@ import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection'
|
||||
vi.mock('./GeneralRemoteServerUpdates', () => ({ GeneralRemoteServerUpdates: () => null }))
|
||||
vi.mock('./ReleaseChannelSection', () => ({ ReleaseChannelSection: () => null }))
|
||||
|
||||
const quitAndInstall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
quitAndInstall.mockReset().mockResolvedValue(undefined)
|
||||
useAppStore.setState({
|
||||
updateStatus: { state: 'available', version: '1.4.200', changelog: null }
|
||||
})
|
||||
@@ -17,6 +20,7 @@ beforeEach(() => {
|
||||
updater: {
|
||||
check: vi.fn(),
|
||||
download: vi.fn(),
|
||||
quitAndInstall,
|
||||
getVersion: vi.fn().mockResolvedValue('1.4.199')
|
||||
}
|
||||
}
|
||||
@@ -35,3 +39,36 @@ it('describes the available action as a download', () => {
|
||||
expect(screen.getByText(/is available\. Click "Download Update" to download it\./)).toBeTruthy()
|
||||
expect(screen.queryByText(/download and install it/)).toBeNull()
|
||||
})
|
||||
|
||||
it('retries installation from the settings panel when a staged update is blocked', () => {
|
||||
useAppStore.setState({
|
||||
updateStatus: {
|
||||
state: 'error',
|
||||
version: '1.4.200',
|
||||
message: 'Close the other Orca instances before installing this update.',
|
||||
retryAction: 'install'
|
||||
}
|
||||
})
|
||||
render(<GeneralUpdateSettingsSection />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }))
|
||||
expect(quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([undefined, false] as const)(
|
||||
'does not offer install retry without an install action or when retry is refused (%s)',
|
||||
(retryable) => {
|
||||
useAppStore.setState({
|
||||
updateStatus: {
|
||||
state: 'error',
|
||||
version: '1.4.200',
|
||||
message: 'Update failed.',
|
||||
...(retryable === false ? { retryAction: 'install', retryable } : {})
|
||||
}
|
||||
})
|
||||
render(<GeneralUpdateSettingsSection />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Try Again' })).toBeNull()
|
||||
expect(quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -138,6 +138,13 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
|
||||
)}
|
||||
{updateStatus.version})
|
||||
</Button>
|
||||
) : updateStatus.state === 'error' &&
|
||||
updateStatus.retryAction === 'install' &&
|
||||
updateStatus.retryable !== false ? (
|
||||
<Button variant="default" size="sm" onClick={handleRestartToUpdate} className="gap-2">
|
||||
<RefreshCw className="size-3.5" />
|
||||
{translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again')}
|
||||
</Button>
|
||||
) : updateStatus.state === 'downloaded' ? (
|
||||
<Button variant="default" size="sm" onClick={handleRestartToUpdate} className="gap-2">
|
||||
<Download className="size-3.5" />
|
||||
|
||||
@@ -88,6 +88,8 @@ export type UpdateStatus = (
|
||||
version?: string
|
||||
/** Omitted by older hosts and for failures whose retryability is unknown. */
|
||||
retryable?: boolean
|
||||
/** A staged update can retry installation without downloading again; older hosts omit this. */
|
||||
retryAction?: 'install'
|
||||
userInitiated?: boolean
|
||||
activeNudgeId?: string
|
||||
recovery?: LinuxPackageInstallRecovery
|
||||
|
||||
Reference in New Issue
Block a user