|
@@ -1,5 +1,6 @@
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { join } from 'node:path'
|
|
import { join } from 'node:path'
|
|
|
|
|
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
|
|
|
import { DESKTOP_IPC } from '../src/ipc.ts'
|
|
import { DESKTOP_IPC } from '../src/ipc.ts'
|
|
|
|
|
|
|
|
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
|
|
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
|
|
@@ -13,6 +14,7 @@ const harness = await vi.hoisted(async () => {
|
|
|
return { promise, resolve, reject }
|
|
return { promise, resolve, reject }
|
|
|
}
|
|
}
|
|
|
const windows: FakeWindow[] = []
|
|
const windows: FakeWindow[] = []
|
|
|
|
|
+ let windowFailure: Error | undefined
|
|
|
const hosts: FakeHost[] = []
|
|
const hosts: FakeHost[] = []
|
|
|
const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
|
|
const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
|
|
|
let pluginsEnabled = false
|
|
let pluginsEnabled = false
|
|
@@ -37,7 +39,7 @@ const harness = await vi.hoisted(async () => {
|
|
|
readonly show = vi.fn()
|
|
readonly show = vi.fn()
|
|
|
readonly focus = vi.fn()
|
|
readonly focus = vi.fn()
|
|
|
readonly restore = vi.fn()
|
|
readonly restore = vi.fn()
|
|
|
- constructor(readonly options: { show: boolean }) { super(); windows.push(this) }
|
|
|
|
|
|
|
+ constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) }
|
|
|
isDestroyed() { return this.destroyed }
|
|
isDestroyed() { return this.destroyed }
|
|
|
isMinimized() { return false }
|
|
isMinimized() { return false }
|
|
|
async loadURL(url: string) {
|
|
async loadURL(url: string) {
|
|
@@ -83,13 +85,15 @@ const harness = await vi.hoisted(async () => {
|
|
|
const popup = vi.fn()
|
|
const popup = vi.fn()
|
|
|
return {
|
|
return {
|
|
|
windows, hosts, handlers, app, FakeWindow, FakeHost,
|
|
windows, hosts, handlers, app, FakeWindow, FakeHost,
|
|
|
|
|
+ failWindow(error: Error) { windowFailure = error },
|
|
|
popup,
|
|
popup,
|
|
|
socketHeaders: vi.fn(),
|
|
socketHeaders: vi.fn(),
|
|
|
menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
|
|
menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
|
|
|
- dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
|
|
|
|
|
|
|
+ dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>() },
|
|
|
openExternal: vi.fn(),
|
|
openExternal: vi.fn(),
|
|
|
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
|
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
|
|
- canRecoverProfile: vi.fn(() => true),
|
|
|
|
|
|
|
+ mutateFailure: vi.fn<() => void>(),
|
|
|
|
|
+ disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
|
|
|
get preparing() { return preparing }, get prepared() { return prepared },
|
|
get preparing() { return preparing }, get prepared() { return prepared },
|
|
|
get hostStarted() { return hostStarted }, get navigated() { return navigated },
|
|
get hostStarted() { return hostStarted }, get navigated() { return navigated },
|
|
|
get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
|
|
get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
|
|
@@ -100,6 +104,7 @@ const harness = await vi.hoisted(async () => {
|
|
|
reset() {
|
|
reset() {
|
|
|
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
|
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
|
|
app.isPackaged = true
|
|
app.isPackaged = true
|
|
|
|
|
+ windowFailure = undefined
|
|
|
pluginsEnabled = false
|
|
pluginsEnabled = false
|
|
|
preparing = deferred(); prepared = deferred(); hostStarted = deferred()
|
|
preparing = deferred(); prepared = deferred(); hostStarted = deferred()
|
|
|
navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
|
|
navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
|
|
@@ -125,15 +130,14 @@ vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desk
|
|
|
vi.mock('../src/project-manager.ts', () => ({
|
|
vi.mock('../src/project-manager.ts', () => ({
|
|
|
DesktopProjectManager: class {
|
|
DesktopProjectManager: class {
|
|
|
readonly applyRelease = harness.applyRelease
|
|
readonly applyRelease = harness.applyRelease
|
|
|
- canRecoverProfile = harness.canRecoverProfile
|
|
|
|
|
|
|
+ disableAllPlugins = harness.disableAllPlugins
|
|
|
async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
|
|
async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
|
|
|
await hooks.beforeChange()
|
|
await hooks.beforeChange()
|
|
|
|
|
+ harness.mutateFailure()
|
|
|
harness.pluginsEnabled = false
|
|
harness.pluginsEnabled = false
|
|
|
await hooks.afterChange()
|
|
await hooks.afterChange()
|
|
|
}
|
|
}
|
|
|
- async resetConfiguration(hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
|
|
|
|
|
- await this.mutate(undefined, hooks)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+
|
|
|
},
|
|
},
|
|
|
}))
|
|
}))
|
|
|
vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost }))
|
|
vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost }))
|
|
@@ -142,7 +146,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn
|
|
|
function invoke(channel: string): unknown {
|
|
function invoke(channel: string): unknown {
|
|
|
const handler = harness.handlers.get(channel)
|
|
const handler = harness.handlers.get(channel)
|
|
|
if (handler === undefined) throw new Error(`missing handler ${channel}`)
|
|
if (handler === undefined) throw new Error(`missing handler ${channel}`)
|
|
|
- return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } })
|
|
|
|
|
|
|
+ return handler({ senderFrame: { url: 'dsh-app://shell/plugin-manager.html' } })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
beforeEach(() => {
|
|
@@ -150,6 +154,8 @@ beforeEach(() => {
|
|
|
vi.clearAllMocks()
|
|
vi.clearAllMocks()
|
|
|
vi.useFakeTimers()
|
|
vi.useFakeTimers()
|
|
|
harness.reset()
|
|
harness.reset()
|
|
|
|
|
+ harness.dialog.showMessageBox.mockImplementation(() => new Promise(() => {}))
|
|
|
|
|
+ vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
|
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
|
|
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
|
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
|
|
vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
|
|
vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
|
|
@@ -162,7 +168,6 @@ afterEach(async () => {
|
|
|
harness.app.quit()
|
|
harness.app.quit()
|
|
|
await harness.quitCompleted.promise
|
|
await harness.quitCompleted.promise
|
|
|
vi.restoreAllMocks()
|
|
vi.restoreAllMocks()
|
|
|
- harness.canRecoverProfile.mockReturnValue(true)
|
|
|
|
|
vi.clearAllTimers()
|
|
vi.clearAllTimers()
|
|
|
vi.useRealTimers()
|
|
vi.useRealTimers()
|
|
|
vi.unstubAllEnvs()
|
|
vi.unstubAllEnvs()
|
|
@@ -278,7 +283,7 @@ describe('desktop main startup', () => {
|
|
|
await retry
|
|
await retry
|
|
|
expect(harness.windows).toHaveLength(1)
|
|
expect(harness.windows).toHaveLength(1)
|
|
|
expect(window.urls).toEqual([
|
|
expect(window.urls).toEqual([
|
|
|
- 'dsh-app://app/', 'dsh-app://shell/startup.html', 'dsh-app://app/',
|
|
|
|
|
|
|
+ 'dsh-app://app/', 'dsh-app://app/',
|
|
|
])
|
|
])
|
|
|
expect(harness.openExternal).not.toHaveBeenCalled()
|
|
expect(harness.openExternal).not.toHaveBeenCalled()
|
|
|
})
|
|
})
|
|
@@ -308,131 +313,123 @@ describe('desktop main startup', () => {
|
|
|
expect(harness.openExternal).not.toHaveBeenCalled()
|
|
expect(harness.openExternal).not.toHaveBeenCalled()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('exits with a diagnostic when both initialization and emergency navigation fail', async () => {
|
|
|
|
|
- const exited = Promise.withResolvers<undefined>()
|
|
|
|
|
- vi.spyOn(harness.app, 'getLocale').mockImplementationOnce(() => { throw new Error('locale unavailable') })
|
|
|
|
|
- vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('emergency navigation failed'))
|
|
|
|
|
- vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
|
- harness.app.exit.mockImplementationOnce(() => { exited.resolve(undefined) })
|
|
|
|
|
|
|
+ it('reports a window construction failure without requiring a window', async () => {
|
|
|
|
|
+ const shown = Promise.withResolvers<undefined>()
|
|
|
|
|
+ harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
|
|
|
|
|
+ harness.failWindow(new Error('window creation failed'))
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
- await exited.promise
|
|
|
|
|
- expect(harness.app.exit).toHaveBeenCalledWith(1)
|
|
|
|
|
- expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' }))
|
|
|
|
|
|
|
+ await shown.promise
|
|
|
|
|
+ expect(harness.windows).toHaveLength(0)
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('window creation failed')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('withholds profile recovery after application resources fail to load', async () => {
|
|
|
|
|
- harness.canRecoverProfile.mockReturnValue(false)
|
|
|
|
|
|
|
+ it('accepts Web fatal reports only from the primary application frame', async () => {
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
- harness.prepared.reject(new Error('runtime resources missing'))
|
|
|
|
|
- await harness.errorPublished.promise
|
|
|
|
|
- expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: false })
|
|
|
|
|
const window = harness.windows[0]!
|
|
const window = harness.windows[0]!
|
|
|
- window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
|
|
|
|
|
- const html = decodeURIComponent(window.urls.at(-1)!)
|
|
|
|
|
- expect(html).toContain('dsh-recovery://restart')
|
|
|
|
|
- expect(html).not.toContain('dsh-recovery://reset')
|
|
|
|
|
- expect(html).not.toContain('dsh-recovery://plugins')
|
|
|
|
|
|
|
+ const frame = { url: 'dsh-app://app/' }
|
|
|
|
|
+ Object.assign(window.webContents, { mainFrame: frame })
|
|
|
|
|
+ const handler = harness.handlers.get(DESKTOP_IPC.bootFailed)! as unknown as (event: unknown, message: unknown) => void
|
|
|
|
|
+ const event = { sender: window.webContents, senderFrame: frame }
|
|
|
|
|
+ expect(() => { handler({ ...event, senderFrame: { url: 'https://other.example/' } }, 'untrusted') }).toThrow('unowned renderer')
|
|
|
|
|
+ expect(() => { handler({ ...event, senderFrame: { ...frame } }, 'subframe') }).toThrow('non-primary frame')
|
|
|
|
|
+ expect(() => { handler(event, {}) }).toThrow('must be text')
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
|
|
|
+ handler(event, 'client mount failed')
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('client mount failed')
|
|
|
|
|
+ expect(window.urls).toEqual(['dsh-app://app/'])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('reloads a crashed startup renderer in the same window', async () => {
|
|
|
|
|
|
|
+ it('ignores subresource failures and navigation cancellation but reports a failed main document', async () => {
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
const window = harness.windows[0]!
|
|
const window = harness.windows[0]!
|
|
|
- window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
|
|
|
|
|
- await harness.errorPublished.promise
|
|
|
|
|
- expect(window.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
|
|
|
|
|
- expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' })
|
|
|
|
|
|
|
+ window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/image.png', false)
|
|
|
|
|
+ window.webContents.emit('did-fail-load', {}, -3, 'aborted', 'dsh-app://app/', true)
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
|
|
|
+ window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true)
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('Desktop page failed to load')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => {
|
|
|
|
|
|
|
+ it('returns package-operation failures locally without opening fatal recovery', async () => {
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
- const window = harness.windows[0]!
|
|
|
|
|
- window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
|
|
|
|
|
harness.prepared.resolve()
|
|
harness.prepared.resolve()
|
|
|
await harness.hostStarted.promise
|
|
await harness.hostStarted.promise
|
|
|
- harness.hosts[0]!.ready.resolve()
|
|
|
|
|
|
|
+ const host = harness.hosts[0]!
|
|
|
|
|
+ host.ready.resolve()
|
|
|
await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
|
|
await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
|
|
|
- const started = harness.nextHostStart()
|
|
|
|
|
- const navigated = harness.nextNavigation()
|
|
|
|
|
- const event = { preventDefault: vi.fn() }
|
|
|
|
|
- window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`)
|
|
|
|
|
- await harness.hosts[0]!.stopping.promise
|
|
|
|
|
- harness.hosts[0]!.exited.resolve()
|
|
|
|
|
- await started
|
|
|
|
|
- harness.hosts[1]!.ready.resolve()
|
|
|
|
|
- await navigated
|
|
|
|
|
- expect(event.preventDefault).toHaveBeenCalled()
|
|
|
|
|
- expect(window.urls.at(-1)).toBe('dsh-app://app/')
|
|
|
|
|
|
|
+ harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') })
|
|
|
|
|
+ host.exited.resolve()
|
|
|
|
|
+ await expect(invoke(DESKTOP_IPC.pluginsDisableAll)).rejects.toThrow('package write failed')
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
|
|
|
+ expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('allows a full profile reset for an unclassified startup failure', async () => {
|
|
|
|
|
|
|
+ it('offers all recovery choices when resources fail before the Host starts', async () => {
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
- harness.prepared.resolve()
|
|
|
|
|
- await harness.hostStarted.promise
|
|
|
|
|
- harness.hosts[0]!.exited.resolve()
|
|
|
|
|
- harness.hosts[0]!.ready.reject(new Error('Unknown startup failure'))
|
|
|
|
|
|
|
+ harness.prepared.reject(new Error('runtime resources missing'))
|
|
|
await harness.errorPublished.promise
|
|
await harness.errorPublished.promise
|
|
|
- const started = harness.nextHostStart()
|
|
|
|
|
- const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset))
|
|
|
|
|
- await started
|
|
|
|
|
- harness.hosts[1]!.ready.resolve()
|
|
|
|
|
- await reset
|
|
|
|
|
- expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
|
|
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
|
|
|
|
|
+ expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('keeps a self-contained reinstall document in the main window after preload failure', async () => {
|
|
|
|
|
|
|
+ it.each(['preload', 'renderer'])('retains the document after a fatal %s failure and reports only the first error', async (kind) => {
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
const window = harness.windows[0]!
|
|
const window = harness.windows[0]!
|
|
|
- window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
|
|
|
|
|
- expect(window.urls.at(-1)).toContain('data:text/html')
|
|
|
|
|
- expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable')
|
|
|
|
|
- harness.prepared.resolve()
|
|
|
|
|
- await harness.hostStarted.promise
|
|
|
|
|
- harness.hosts[0]!.ready.resolve()
|
|
|
|
|
- await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
|
|
|
|
|
- expect(harness.windows).toHaveLength(1)
|
|
|
|
|
- expect(window.urls.at(-1)).toContain('data:text/html')
|
|
|
|
|
- expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
|
|
|
|
|
|
|
+ if (kind === 'preload') window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
|
|
|
|
|
+ else window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
|
|
|
|
|
+ window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('secondary failure'))
|
|
|
|
|
+ harness.prepared.reject(new Error('backend also failed'))
|
|
|
|
|
+ await harness.errorPublished.promise
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).not.toContain('secondary failure')
|
|
|
|
|
+ expect(window.urls).toEqual(['dsh-app://app/'])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it.each([true, false])('offers plugin recovery and disables plugins in packaged=%s mode', async (packaged) => {
|
|
|
|
|
- harness.app.isPackaged = packaged
|
|
|
|
|
- harness.pluginsEnabled = true
|
|
|
|
|
|
|
+ it('ignores clean renderer exits and exits of a closed window', async () => {
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
- harness.prepared.resolve()
|
|
|
|
|
- await harness.hostStarted.promise
|
|
|
|
|
- harness.hosts[0]!.exited.resolve()
|
|
|
|
|
- harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed'))
|
|
|
|
|
- await harness.errorPublished.promise
|
|
|
|
|
- expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true })
|
|
|
|
|
- const nextStarted = harness.nextHostStart()
|
|
|
|
|
- const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll))
|
|
|
|
|
- await nextStarted
|
|
|
|
|
- expect(harness.pluginsEnabled).toBe(false)
|
|
|
|
|
- harness.hosts[1]!.ready.resolve()
|
|
|
|
|
- await recovery
|
|
|
|
|
- expect(harness.windows).toHaveLength(1)
|
|
|
|
|
- expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
|
|
|
|
|
|
|
+ const window = harness.windows[0]!
|
|
|
|
|
+ window.webContents.emit('render-process-gone', {}, { reason: 'clean-exit' })
|
|
|
|
|
+ window.close()
|
|
|
|
|
+ window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('reports a rejected document load without navigating to a recovery page', async () => {
|
|
|
|
|
+ const shown = Promise.withResolvers<undefined>()
|
|
|
|
|
+ harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
|
|
|
|
|
+ vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('document missing'))
|
|
|
|
|
+ await import('../src/main.ts')
|
|
|
|
|
+ await shown.promise
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
|
|
|
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('document missing')
|
|
|
|
|
+ expect(harness.hosts).toHaveLength(0)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('waits for Host exit before relaunching the application', async () => {
|
|
|
|
|
|
|
+ it.each([0, 1, 2])('waits for Host exit before recovery action %s', async (response) => {
|
|
|
|
|
+ harness.dialog.showMessageBox.mockResolvedValue({ response, checkboxChecked: false })
|
|
|
await import('../src/main.ts')
|
|
await import('../src/main.ts')
|
|
|
await harness.preparing.promise
|
|
await harness.preparing.promise
|
|
|
harness.prepared.resolve()
|
|
harness.prepared.resolve()
|
|
|
await harness.hostStarted.promise
|
|
await harness.hostStarted.promise
|
|
|
- harness.hosts[0]!.ready.resolve()
|
|
|
|
|
- await harness.navigated.promise
|
|
|
|
|
- const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart))
|
|
|
|
|
- await harness.hosts[0]!.stopping.promise
|
|
|
|
|
|
|
+ const host = harness.hosts[0]!
|
|
|
|
|
+ host.ready.resolve()
|
|
|
|
|
+ await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
|
|
|
|
|
+ host.onFailure!(new Error('backend exited'))
|
|
|
|
|
+ await host.stopping.promise
|
|
|
expect(harness.app.relaunch).not.toHaveBeenCalled()
|
|
expect(harness.app.relaunch).not.toHaveBeenCalled()
|
|
|
- harness.hosts[0]!.exited.resolve()
|
|
|
|
|
- await restart
|
|
|
|
|
- expect(harness.app.relaunch).toHaveBeenCalledOnce()
|
|
|
|
|
|
|
+ expect(harness.disableAllPlugins).not.toHaveBeenCalled()
|
|
|
|
|
+ host.exited.resolve()
|
|
|
|
|
+ await harness.quitCompleted.promise
|
|
|
|
|
+ expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
|
|
|
|
|
+ expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
|
|
|
|
|
+ expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
|
|
|
|
+ expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('shows the loading window before profile preparation and starts one actual Host', async () => {
|
|
it('shows the loading window before profile preparation and starts one actual Host', async () => {
|
|
@@ -491,8 +488,8 @@ describe('desktop main startup', () => {
|
|
|
first.ready.reject(new Error('plugin composition failed'))
|
|
first.ready.reject(new Error('plugin composition failed'))
|
|
|
await harness.errorPublished.promise
|
|
await harness.errorPublished.promise
|
|
|
await failedRetry
|
|
await failedRetry
|
|
|
- expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', profileRecovery: true })
|
|
|
|
|
- expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
|
|
|
|
|
|
|
+ expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' })
|
|
|
|
|
+ expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
|
|
const nextStarted = harness.nextHostStart()
|
|
const nextStarted = harness.nextHostStart()
|
|
|
const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
|
|
const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
|
|
|
await nextStarted
|
|
await nextStarted
|