Files
orca/src/main/window/createMainWindow-close-confirmation.test.ts
T

527 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', async () =>
(await import('./createMainWindow-test-harness')).electronModuleMock()
)
vi.mock('@electron-toolkit/utils', async () =>
(await import('./createMainWindow-test-harness')).electronToolkitUtilsMock()
)
vi.mock('./macos-tahoe-release', async () =>
(await import('./createMainWindow-test-harness')).macosTahoeReleaseMock()
)
vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock())
vi.mock('../browser/browser-manager', async () =>
(await import('./createMainWindow-test-harness')).browserManagerMock()
)
vi.mock('../browser/browser-route-session-runtime', async () => {
const harness = await import('./createMainWindow-test-harness')
return {
browserRouteSessionRegistry: { isAllowedPartition: harness.routePartitionAllowedMock },
browserRouteWebContentsRegistry: {
attachGuest: harness.attachRouteGuestMock,
retireRenderer: harness.retireRouteRendererMock
}
}
})
vi.mock('../browser/browser-client-page-renderer-runtime', async () => {
const harness = await import('./createMainWindow-test-harness')
return {
attachBrowserClientPageRenderer: harness.attachClientPageRendererMock,
retireBrowserClientPageRenderer: harness.retireClientPageRendererMock
}
})
import { createMainWindow, WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS } from './createMainWindow'
import { ipcMain } from 'electron'
import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state'
import {
attachClientPageRendererMock,
browserWindowMock,
resetMainWindowMocks,
retireClientPageRendererMock,
retireRouteRendererMock
} from './createMainWindow-test-harness'
describe('createMainWindow', () => {
beforeEach(() => {
resetMainWindowMocks()
resetExpectedTeardownStateForTest()
vi.useRealTimers()
})
it('clears the quit latch when the renderer prevents unload', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
const onQuitAborted = vi.fn()
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null, { getIsQuitting: () => true, onQuitAborted })
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: true,
requestId: expect.any(Number)
})
windowHandlers['will-prevent-unload']()
expect(onQuitAborted).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:unload-prevented')
})
it('allows close after the renderer process is gone', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
id: 71,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null, { getIsQuitting: () => true })
windowHandlers['render-process-gone']?.(
{} as never,
{
reason: 'crashed',
exitCode: 5
} as never
)
expect(retireRouteRendererMock).toHaveBeenCalledWith(71)
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith(
'window:close-requested',
expect.objectContaining({ isQuitting: true })
)
consoleError.mockRestore()
})
it('does not notify the crash recorder when renderer teardown follows a confirmed window close', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const ipcHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn(),
close: vi.fn(() => {
windowHandlers.close({} as never)
})
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(ipcMain.on).mockImplementation((channel, handler) => {
ipcHandlers[channel] = handler as (...args: any[]) => void
return ipcMain
})
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
const onRendererProcessGone = vi.fn()
createMainWindow(null, { onRendererProcessGone })
ipcHandlers['window:confirm-close']?.()
windowHandlers['render-process-gone']?.(
{} as never,
{
reason: 'killed',
exitCode: 9
} as never
)
expect(onRendererProcessGone).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('does not persist pending bounds after bypassing close for a gone renderer', () => {
vi.useFakeTimers()
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
getBounds: vi.fn(() => ({ x: 10, y: 20, width: 1000, height: 700 })),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
const updateUI = vi.fn()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow({
getUI: () => ({}),
getSettings: () => ({ windowBackgroundBlur: false }),
updateUI
} as never)
windowHandlers.resize()
windowHandlers['render-process-gone']?.(
{} as never,
{
reason: 'crashed',
exitCode: 5
} as never
)
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
vi.advanceTimersByTime(500)
expect(preventDefault).not.toHaveBeenCalled()
expect(updateUI).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('resumes close confirmation after a renderer process reloads', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null, { getIsQuitting: () => true })
windowHandlers['render-process-gone']?.(
{} as never,
{
reason: 'crashed',
exitCode: 5
} as never
)
windowHandlers['did-finish-load']?.()
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(retireClientPageRendererMock).toHaveBeenCalledWith(webContents)
expect(attachClientPageRendererMock).toHaveBeenCalledWith(webContents)
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: true,
requestId: expect.any(Number)
})
consoleError.mockRestore()
})
it('allows close when Electron reports a crashed webContents', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => true)
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null, { getIsQuitting: () => true })
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith(
'window:close-requested',
expect.objectContaining({ isQuitting: true })
)
})
// Why (#5787): a hung-but-ALIVE renderer (never gone, never crashed) must NOT
// silently bypass the close guard — force-killing it that way is what destroyed
// other sessions. It must route through window:close-requested so the
// save/running-process confirmation runs.
it('requests confirmation for a hung-but-alive renderer instead of bypassing', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const browserWindowInstance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null)
// No render-process-gone and isCrashed() === false: the renderer is alive.
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false,
requestId: expect.any(Number)
})
})
it('destroys an already-unresponsive renderer after an app-wide quit deadline', async () => {
vi.useFakeTimers()
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
id: 42,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const destroy = vi.fn()
browserWindowMock.mockImplementation(function () {
return {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
destroy,
loadFile: vi.fn(),
loadURL: vi.fn()
}
})
createMainWindow(null, { getIsQuitting: () => true })
windowHandlers.close({ preventDefault: vi.fn() } as never)
await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS - 1)
expect(destroy).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(destroy).toHaveBeenCalledOnce()
})
it('keeps the renderer-owned close flow after the quit request is acknowledged', async () => {
vi.useFakeTimers()
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const ipcHandlers: Record<string, (...args: any[]) => void> = {}
vi.mocked(ipcMain.on).mockImplementation((channel, handler) => {
ipcHandlers[channel] = handler as (...args: any[]) => void
return ipcMain
})
const webContents = {
id: 42,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false)
}
const destroy = vi.fn()
browserWindowMock.mockImplementation(function () {
return {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
destroy,
loadFile: vi.fn(),
loadURL: vi.fn()
}
})
createMainWindow(null, { getIsQuitting: () => true })
windowHandlers.close({ preventDefault: vi.fn() } as never)
windowHandlers.close({ preventDefault: vi.fn() } as never)
const closeRequests = vi
.mocked(webContents.send)
.mock.calls.filter(([channel]) => channel === 'window:close-requested')
.map(([, request]) => request as { requestId: number })
expect(closeRequests).toHaveLength(2)
const [staleRequest, currentRequest] = closeRequests
ipcHandlers['window:close-request-received']?.({ sender: { id: 99 } }, currentRequest.requestId)
ipcHandlers['window:close-request-received']?.({ sender: { id: 42 } }, staleRequest.requestId)
await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS - 1)
expect(destroy).not.toHaveBeenCalled()
ipcHandlers['window:close-request-received']?.({ sender: { id: 42 } }, currentRequest.requestId)
await vi.advanceTimersByTimeAsync(1)
expect(destroy).not.toHaveBeenCalled()
})
})