Files
goodbuddy/src/main/window.test.ts
T
mesalogo 61590395ef fix: retain renderer exit details in diagnostic logs
Persist Electron renderer exit reasons and exit codes through diagnostic writes, reads, and exports so abnormal window exits can be investigated. Keep older records readable and existing log bounds unchanged.

Validation: 18 focused tests, typecheck, lint, and diff checks passed. Full test run timed out after 60 seconds with a SettingsPanel test failure.

Release note: Diagnostic logs now retain renderer exit reasons and exit codes to help distinguish out-of-memory exits from other window failures.
2026-09-28 11:36:01 +08:00

207 lines
7.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMainWindow, loadMainWindow, resolveWindowIcon } from './window'
import { dialog } from 'electron'
const electronMocks = vi.hoisted(() => ({
options: [] as Array<Record<string, unknown>>,
closeListeners: [] as Array<(event: { preventDefault: () => void }) => void>
}))
vi.mock('electron', () => ({
app: {
isPackaged: false,
getLocale: vi.fn(() => 'en-US'),
getAppPath: vi.fn(() => 'C:\\source')
},
BrowserWindow: class {
webContents = {
on: vi.fn(),
reload: vi.fn(),
isDestroyed: vi.fn(() => false),
setWindowOpenHandler: vi.fn()
}
constructor(options: Record<string, unknown>) {
electronMocks.options.push(options)
}
setIcon = vi.fn()
isDestroyed = vi.fn(() => false)
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
once = vi.fn()
hide = vi.fn()
on = vi.fn(
(
event: string,
listener: (event: { preventDefault: () => void }) => void
) => {
if (event === 'close') {
electronMocks.closeListeners.push(listener)
}
}
)
},
nativeImage: {
createFromPath: vi.fn(() => ({
isEmpty: vi.fn(() => false)
}))
},
dialog: { showMessageBox: vi.fn(async () => ({ response: 1 })) },
shell: {
openExternal: vi.fn()
}
}))
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('window recovery', () => {
function setup(shouldQuit = () => false) {
const observe = vi.fn()
const observedWindow = createMainWindow(shouldQuit, observe)
const listeners = vi.mocked(observedWindow.webContents.on).mock.calls as unknown as Array<[string, unknown]>
const gone = listeners.find(([event]) => event === 'render-process-gone')![1] as
(event: unknown, details: { reason: string; exitCode: number }) => void
vi.spyOn(console, 'error').mockImplementation(() => undefined)
return { window: observedWindow, gone, observe, listeners }
}
it('logs a crash and reloads only after explicit confirmation, without duplicate dialogs', async () => {
let respond!: (value: { response: number; checkboxChecked: boolean }) => void
vi.mocked(dialog.showMessageBox).mockReturnValueOnce(new Promise(resolve => { respond = resolve }))
const { window, gone, observe } = setup()
const before = vi.mocked(dialog.showMessageBox).mock.calls.length
gone({}, { reason: 'oom', exitCode: 9 })
gone({}, { reason: 'crashed', exitCode: 1 })
expect(dialog.showMessageBox).toHaveBeenCalledTimes(before + 1)
expect(window.webContents.reload).not.toHaveBeenCalled()
expect(observe).toHaveBeenNthCalledWith(1, expect.objectContaining({
code: 'desktop.renderer.gone', reason: 'oom', exitCode: 9
}))
expect(observe).toHaveBeenNthCalledWith(2, expect.objectContaining({
code: 'desktop.renderer.gone', reason: 'crashed', exitCode: 1
}))
respond({ response: 0, checkboxChecked: false })
await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce())
gone({}, { reason: 'crashed', exitCode: 1 })
await Promise.resolve()
expect(window.webContents.reload).toHaveBeenCalledOnce()
})
it('does not recover clean exits or shutdown, or reload a window destroyed during the dialog', async () => {
let quitting = false
const { window, gone, observe } = setup(() => quitting)
gone({}, { reason: 'clean-exit', exitCode: 0 })
quitting = true
gone({}, { reason: 'crashed', exitCode: 1 })
expect(observe).not.toHaveBeenCalled()
quitting = false
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
gone({}, { reason: 'crashed', exitCode: 1 })
vi.mocked(window.isDestroyed).mockReturnValue(true)
await Promise.resolve()
expect(window.webContents.reload).not.toHaveBeenCalled()
})
it('handles dialog rejection and allows a later user-driven recovery', async () => {
const { window, gone } = setup()
vi.mocked(dialog.showMessageBox).mockRejectedValueOnce(new Error('dialog unavailable'))
gone({}, { reason: 'crashed', exitCode: 1 })
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith('GoodBuddy renderer recovery failed', expect.any(Error)))
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
gone({}, { reason: 'crashed', exitCode: 1 })
await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce())
})
it('records main-frame load failures but ignores aborted navigation and subframes', () => {
const { listeners, observe } = setup()
const failed = listeners.find(([event]) => event === 'did-fail-load')![1] as
(event: unknown, code: number, description: string, url: string, main: boolean) => void
failed({}, -3, 'aborted', '', true)
failed({}, -6, 'missing', '', false)
expect(observe).not.toHaveBeenCalled()
failed({}, -6, 'missing', '', true)
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ code: 'desktop.renderer.load-failed' }))
})
it.each([true, false])('consumes rejected load promises (development=%s)', async development => {
vi.stubEnv('ELECTRON_RENDERER_URL', development ? 'http://localhost:5173' : '')
const { window } = setup()
vi.mocked(development ? window.loadURL : window.loadFile).mockRejectedValueOnce(new Error('load failed'))
loadMainWindow(window, true)
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith('GoodBuddy window load rejected', expect.any(Error)))
if (development) expect(window.loadURL).toHaveBeenCalledWith('http://localhost:5173/?storageUpgrade=1')
else expect(window.loadFile).toHaveBeenCalledWith(expect.any(String), { query: { storageUpgrade: '1' } })
})
})
describe('createMainWindow', () => {
it('disables the system frame while preserving renderer isolation', () => {
createMainWindow(() => false)
expect(electronMocks.options.at(-1)).toMatchObject({
frame: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
})
it('keeps the custom close control aligned with close-to-tray behavior', () => {
const window = createMainWindow(() => false) as unknown as {
hide: () => void
}
const event = { preventDefault: vi.fn() }
electronMocks.closeListeners.at(-1)?.(event)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(window.hide).toHaveBeenCalledOnce()
})
})
describe('resolveWindowIcon', () => {
it('uses the packaged Windows taskbar icon', () => {
expect(
resolveWindowIcon({
platform: 'win32',
isPackaged: true,
appPath: 'C:\\app',
resourcesPath: 'C:\\app\\resources'
})
).toBe('C:\\app\\resources\\icon.ico')
})
it('uses build assets during development and leaves macOS unset', () => {
expect(
resolveWindowIcon({
platform: 'win32',
isPackaged: false,
appPath: 'C:\\source',
resourcesPath: 'C:\\source\\resources'
})
).toBe('C:\\source\\build\\icon-taskbar.ico')
expect(
resolveWindowIcon({
platform: 'linux',
isPackaged: false,
appPath: '/opt/goodbuddy',
resourcesPath: '/opt/goodbuddy/resources'
})
).toBe('/opt/goodbuddy/build/icon.png')
expect(
resolveWindowIcon({
platform: 'darwin',
isPackaged: true,
appPath: '/Applications/GoodBuddy.app',
resourcesPath: '/Applications/GoodBuddy.app/Contents/Resources'
})
).toBeUndefined()
})
})