cc-haha/desktop/electron/services/rendererLifecycle.test.ts
2026-07-20 22:06:34 +08:00

233 lines
8.6 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS,
installRendererLifecycle,
} from './rendererLifecycle'
class FakeWebContents extends EventEmitter {
destroyed = false
reload = vi.fn()
isDestroyed() {
return this.destroyed
}
}
class FakeWindow extends EventEmitter {
destroyed = false
webContents = new FakeWebContents()
isDestroyed() {
return this.destroyed
}
}
function createHarness(options: { quitting?: boolean } = {}) {
const window = new FakeWindow()
let quitting = options.quitting ?? false
const recordDiagnostic = vi.fn((detail: string) => `sanitized:${detail}`)
const writeSnapshot = vi.fn()
const onRendererProcessGone = vi.fn()
const onRecoveryExhausted = vi.fn()
installRendererLifecycle({
window: window as never,
isQuitting: () => quitting,
recordDiagnostic,
writeSnapshot,
onRendererProcessGone,
onRecoveryExhausted,
})
return {
window,
recordDiagnostic,
writeSnapshot,
onRendererProcessGone,
onRecoveryExhausted,
setQuitting(value: boolean) {
quitting = value
},
}
}
afterEach(() => {
vi.useRealTimers()
})
describe('Electron renderer lifecycle recovery', () => {
it('reloads after the first renderer exit and reports repeated failure only once', () => {
vi.useFakeTimers()
const harness = createHarness()
const details = { reason: 'crashed', exitCode: 139 }
harness.window.webContents.emit('render-process-gone', {}, details)
expect(harness.window.webContents.reload).not.toHaveBeenCalled()
expect(harness.recordDiagnostic).toHaveBeenCalledWith(
'[process-gone] reason=crashed exitCode=139',
)
expect(harness.recordDiagnostic).toHaveBeenCalledWith(
'[recovery-started] trigger=process-gone:crashed',
)
expect(harness.writeSnapshot).toHaveBeenCalledWith('render-process-gone:crashed:139')
expect(harness.onRendererProcessGone).toHaveBeenCalledWith(
'sanitized:[process-gone] reason=crashed exitCode=139',
)
harness.window.webContents.emit('render-process-gone', {}, details)
expect(harness.recordDiagnostic).toHaveBeenCalledWith(
'[recovery-already-scheduled] trigger=process-gone:crashed',
)
expect(harness.onRecoveryExhausted).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(1)
vi.runOnlyPendingTimers()
expect(harness.window.webContents.reload).toHaveBeenCalledTimes(1)
harness.window.webContents.emit('render-process-gone', {}, details)
harness.window.webContents.emit('render-process-gone', {}, details)
expect(harness.window.webContents.reload).toHaveBeenCalledTimes(1)
expect(harness.onRecoveryExhausted).toHaveBeenCalledTimes(1)
expect(harness.onRecoveryExhausted).toHaveBeenCalledWith(
'sanitized:[recovery-exhausted] trigger=process-gone:crashed',
)
})
it('reloads only after unresponsiveness persists for the full delay', () => {
vi.useFakeTimers()
const harness = createHarness()
harness.window.webContents.emit('unresponsive')
vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS / 2)
harness.window.webContents.emit('unresponsive')
vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS / 2 - 1)
expect(harness.window.webContents.reload).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(harness.window.webContents.reload).not.toHaveBeenCalled()
vi.runOnlyPendingTimers()
expect(harness.window.webContents.reload).toHaveBeenCalledTimes(1)
expect(harness.recordDiagnostic).toHaveBeenCalledWith(
'[recovery-started] trigger=unresponsive-timeout',
)
})
it('cancels pending recovery when the renderer becomes responsive or the window closes', () => {
vi.useFakeTimers()
const harness = createHarness()
harness.window.webContents.emit('unresponsive')
harness.window.webContents.emit('responsive')
vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS)
expect(harness.window.webContents.reload).not.toHaveBeenCalled()
expect(harness.writeSnapshot).toHaveBeenCalledWith('responsive')
harness.window.webContents.emit('unresponsive')
harness.window.emit('closed')
vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS)
expect(harness.window.webContents.reload).not.toHaveBeenCalled()
})
it('does not reload a destroyed window or web contents, or while the app is quitting', () => {
vi.useFakeTimers()
const destroyedWindowHarness = createHarness()
destroyedWindowHarness.window.destroyed = true
destroyedWindowHarness.window.webContents.emit('unresponsive')
vi.advanceTimersByTime(DEFAULT_RENDERER_UNRESPONSIVE_RECOVERY_DELAY_MS)
expect(destroyedWindowHarness.window.webContents.reload).not.toHaveBeenCalled()
const destroyedContentsHarness = createHarness()
destroyedContentsHarness.window.webContents.destroyed = true
destroyedContentsHarness.window.webContents.emit('render-process-gone', {}, {
reason: 'crashed',
exitCode: 1,
})
expect(destroyedContentsHarness.window.webContents.reload).not.toHaveBeenCalled()
const quittingHarness = createHarness({ quitting: true })
quittingHarness.window.webContents.emit('render-process-gone', {}, {
reason: 'clean-exit',
exitCode: 0,
})
expect(quittingHarness.window.webContents.reload).not.toHaveBeenCalled()
expect(quittingHarness.onRecoveryExhausted).not.toHaveBeenCalled()
})
it('rechecks destruction and quitting before a deferred reload and cancels it on close', () => {
vi.useFakeTimers()
const details = { reason: 'crashed', exitCode: 1 }
const destroyedWindowHarness = createHarness()
destroyedWindowHarness.window.webContents.emit('render-process-gone', {}, details)
destroyedWindowHarness.window.destroyed = true
vi.runOnlyPendingTimers()
expect(destroyedWindowHarness.window.webContents.reload).not.toHaveBeenCalled()
expect(destroyedWindowHarness.recordDiagnostic).toHaveBeenCalledWith(
'[recovery-skipped] trigger=process-gone:crashed quitting=false',
)
const destroyedContentsHarness = createHarness()
destroyedContentsHarness.window.webContents.emit('render-process-gone', {}, details)
destroyedContentsHarness.window.webContents.destroyed = true
vi.runOnlyPendingTimers()
expect(destroyedContentsHarness.window.webContents.reload).not.toHaveBeenCalled()
const quittingHarness = createHarness()
quittingHarness.window.webContents.emit('render-process-gone', {}, details)
quittingHarness.setQuitting(true)
vi.runOnlyPendingTimers()
expect(quittingHarness.window.webContents.reload).not.toHaveBeenCalled()
const closedHarness = createHarness()
closedHarness.window.webContents.emit('render-process-gone', {}, details)
expect(vi.getTimerCount()).toBe(1)
closedHarness.window.emit('closed')
expect(vi.getTimerCount()).toBe(0)
vi.runAllTimers()
expect(closedHarness.window.webContents.reload).not.toHaveBeenCalled()
})
it('reports reload and recovered main-frame load failures once while ignoring aborts', () => {
vi.useFakeTimers()
const reloadErrorHarness = createHarness()
reloadErrorHarness.window.webContents.reload.mockImplementation(() => {
throw new Error('reload failed')
})
reloadErrorHarness.window.webContents.emit('render-process-gone', {}, {
reason: 'crashed',
exitCode: 1,
})
vi.runOnlyPendingTimers()
expect(reloadErrorHarness.onRecoveryExhausted).toHaveBeenCalledWith(
'sanitized:[recovery-exhausted] trigger=process-gone:crashed reloadError=reload failed',
)
const loadFailureHarness = createHarness()
loadFailureHarness.window.webContents.emit('render-process-gone', {}, {
reason: 'crashed',
exitCode: 1,
})
vi.runOnlyPendingTimers()
loadFailureHarness.window.webContents.emit(
'did-fail-load', {}, -3, 'ERR_ABORTED', 'file:///app/index.html', true,
)
expect(loadFailureHarness.onRecoveryExhausted).not.toHaveBeenCalled()
loadFailureHarness.window.webContents.emit(
'did-fail-load', {}, -2, 'ERR_FAILED', 'file:///app/index.html', false,
)
expect(loadFailureHarness.onRecoveryExhausted).not.toHaveBeenCalled()
loadFailureHarness.window.webContents.emit(
'did-fail-load', {}, -2, 'ERR_FAILED', 'file:///app/index.html', true,
)
loadFailureHarness.window.webContents.emit(
'did-fail-load', {}, -2, 'ERR_FAILED', 'file:///app/index.html', true,
)
expect(loadFailureHarness.onRecoveryExhausted).toHaveBeenCalledTimes(1)
})
})