mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-22 14:30:53 +08:00
233 lines
8.6 KiB
TypeScript
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)
|
|
})
|
|
})
|