main-startup.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { join } from 'node:path'
  3. import { DESKTOP_IPC } from '../src/ipc.ts'
  4. const harness = await vi.hoisted(async () => {
  5. const { EventEmitter } = await import('node:events')
  6. function deferred() {
  7. let resolve!: () => void
  8. let reject!: (error: Error) => void
  9. const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
  10. return { promise, resolve, reject }
  11. }
  12. const windows: FakeWindow[] = []
  13. const hosts: FakeHost[] = []
  14. const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
  15. let pluginsEnabled = false
  16. let preparing = deferred()
  17. let prepared = deferred()
  18. let hostStarted = deferred()
  19. let navigated = deferred()
  20. let errorPublished = deferred()
  21. let quitCompleted = deferred()
  22. class FakeWindow extends EventEmitter {
  23. destroyed = false
  24. readonly urls: string[] = []
  25. readonly webContents = Object.assign(new EventEmitter(), {
  26. setWindowOpenHandler: vi.fn(),
  27. openDevTools: vi.fn(),
  28. getURL: () => this.urls.at(-1) ?? '',
  29. send: vi.fn((channel: string, state: { phase?: string }) => {
  30. if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve()
  31. }),
  32. })
  33. readonly show = vi.fn()
  34. readonly focus = vi.fn()
  35. readonly restore = vi.fn()
  36. constructor(readonly options: { show: boolean }) { super(); windows.push(this) }
  37. isDestroyed() { return this.destroyed }
  38. isMinimized() { return false }
  39. async loadURL(url: string) {
  40. this.urls.push(url)
  41. if (url === 'dsh-app://app/index.html') navigated.resolve()
  42. }
  43. static getAllWindows() { return windows.filter(window => !window.destroyed) }
  44. close() { this.destroyed = true; this.emit('closed') }
  45. }
  46. class FakeHost {
  47. readonly ready = deferred()
  48. readonly exited = deferred()
  49. readonly stopping = deferred()
  50. readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise })
  51. readonly stop = vi.fn(() => {
  52. this.stopping.resolve()
  53. this.ready.reject(new Error('child stopped'))
  54. return this.exited.promise
  55. })
  56. constructor(readonly node: string, readonly runtime: string, readonly profile: string) { hosts.push(this) }
  57. }
  58. const app = Object.assign(new EventEmitter(), {
  59. isPackaged: true,
  60. name: 'Desktop test',
  61. whenReady: () => Promise.resolve(),
  62. getLocale: () => 'en-US',
  63. getVersion: () => '1.0.0',
  64. getAppPath: () => 'desktop-test-app',
  65. requestSingleInstanceLock: () => true,
  66. exit: vi.fn(),
  67. relaunch: vi.fn(),
  68. quit: vi.fn(() => {
  69. const event = { preventDefault: vi.fn() }
  70. app.emit('before-quit', event)
  71. if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve()
  72. }),
  73. })
  74. return {
  75. windows, hosts, handlers, app, FakeWindow, FakeHost,
  76. dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
  77. applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
  78. assertProfileRuntime: vi.fn(),
  79. canRecoverProfile: vi.fn(() => true),
  80. get preparing() { return preparing }, get prepared() { return prepared },
  81. get hostStarted() { return hostStarted }, get navigated() { return navigated },
  82. get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
  83. nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
  84. get pluginsEnabled() { return pluginsEnabled },
  85. set pluginsEnabled(value: boolean) { pluginsEnabled = value },
  86. reset() {
  87. windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
  88. app.isPackaged = true
  89. pluginsEnabled = false
  90. preparing = deferred(); prepared = deferred(); hostStarted = deferred()
  91. navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
  92. },
  93. }
  94. })
  95. vi.mock('electron', () => ({
  96. app: harness.app,
  97. BrowserWindow: harness.FakeWindow,
  98. dialog: harness.dialog,
  99. ipcMain: {
  100. handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) },
  101. },
  102. Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() },
  103. protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
  104. }))
  105. vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
  106. vi.mock('../src/project-manager.ts', () => ({
  107. DesktopProjectManager: class {
  108. readonly applyRelease = harness.applyRelease
  109. readonly assertProfileRuntime = harness.assertProfileRuntime
  110. canRecoverProfile = harness.canRecoverProfile
  111. async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
  112. await hooks.beforeChange()
  113. harness.pluginsEnabled = false
  114. await hooks.afterChange()
  115. }
  116. async resetConfiguration(hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
  117. await this.mutate(undefined, hooks)
  118. }
  119. },
  120. }))
  121. vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost }))
  122. vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() }))
  123. function invoke(channel: string): unknown {
  124. const handler = harness.handlers.get(channel)
  125. if (handler === undefined) throw new Error(`missing handler ${channel}`)
  126. return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } })
  127. }
  128. beforeEach(() => {
  129. vi.resetModules()
  130. vi.clearAllMocks()
  131. vi.useFakeTimers()
  132. harness.reset()
  133. vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node')
  134. vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
  135. vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
  136. vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
  137. vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
  138. })
  139. afterEach(async () => {
  140. harness.prepared.resolve()
  141. for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() }
  142. harness.app.quit()
  143. await harness.quitCompleted.promise
  144. vi.restoreAllMocks()
  145. harness.canRecoverProfile.mockReturnValue(true)
  146. vi.clearAllTimers()
  147. vi.useRealTimers()
  148. vi.unstubAllEnvs()
  149. vi.unstubAllGlobals()
  150. })
  151. describe('desktop main startup', () => {
  152. it('exits with a diagnostic when both initialization and emergency navigation fail', async () => {
  153. const exited = Promise.withResolvers<undefined>()
  154. vi.spyOn(harness.app, 'getLocale').mockImplementationOnce(() => { throw new Error('locale unavailable') })
  155. vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('emergency navigation failed'))
  156. vi.spyOn(console, 'error').mockImplementation(() => {})
  157. harness.app.exit.mockImplementationOnce(() => { exited.resolve(undefined) })
  158. await import('../src/main.ts')
  159. await exited.promise
  160. expect(harness.app.exit).toHaveBeenCalledWith(1)
  161. expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' }))
  162. })
  163. it('withholds profile recovery after application resources fail to load', async () => {
  164. harness.canRecoverProfile.mockReturnValue(false)
  165. await import('../src/main.ts')
  166. await harness.preparing.promise
  167. harness.prepared.reject(new Error('runtime resources missing'))
  168. await harness.errorPublished.promise
  169. expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: false })
  170. const window = harness.windows[0]!
  171. window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
  172. const html = decodeURIComponent(window.urls.at(-1)!)
  173. expect(html).toContain('dsh-recovery://restart')
  174. expect(html).not.toContain('dsh-recovery://reset')
  175. expect(html).not.toContain('dsh-recovery://plugins')
  176. })
  177. it('reloads a crashed startup renderer in the same window', async () => {
  178. await import('../src/main.ts')
  179. await harness.preparing.promise
  180. const window = harness.windows[0]!
  181. window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
  182. await harness.errorPublished.promise
  183. expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://shell/startup.html'])
  184. expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' })
  185. })
  186. it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => {
  187. await import('../src/main.ts')
  188. await harness.preparing.promise
  189. const window = harness.windows[0]!
  190. window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
  191. harness.prepared.resolve()
  192. await harness.hostStarted.promise
  193. harness.hosts[0]!.ready.resolve()
  194. await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
  195. const started = harness.nextHostStart()
  196. const event = { preventDefault: vi.fn() }
  197. window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`)
  198. await harness.hosts[0]!.stopping.promise
  199. harness.hosts[0]!.exited.resolve()
  200. await started
  201. harness.hosts[1]!.ready.resolve()
  202. await harness.navigated.promise
  203. expect(event.preventDefault).toHaveBeenCalled()
  204. expect(window.urls.at(-1)).toBe('dsh-app://app/index.html')
  205. })
  206. it('allows a full profile reset for an unclassified startup failure', async () => {
  207. await import('../src/main.ts')
  208. await harness.preparing.promise
  209. harness.prepared.resolve()
  210. await harness.hostStarted.promise
  211. harness.hosts[0]!.exited.resolve()
  212. harness.hosts[0]!.ready.reject(new Error('Unknown startup failure'))
  213. await harness.errorPublished.promise
  214. const started = harness.nextHostStart()
  215. const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset))
  216. await started
  217. harness.hosts[1]!.ready.resolve()
  218. await reset
  219. expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
  220. })
  221. it('keeps a self-contained reinstall document in the main window after preload failure', async () => {
  222. await import('../src/main.ts')
  223. await harness.preparing.promise
  224. const window = harness.windows[0]!
  225. window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
  226. expect(window.urls.at(-1)).toContain('data:text/html')
  227. expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable')
  228. harness.prepared.resolve()
  229. await harness.hostStarted.promise
  230. harness.hosts[0]!.ready.resolve()
  231. await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
  232. expect(harness.windows).toHaveLength(1)
  233. expect(window.urls.at(-1)).toContain('data:text/html')
  234. expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
  235. })
  236. it('offers plugin recovery and disables plugins before restarting in the same window', async () => {
  237. harness.pluginsEnabled = true
  238. await import('../src/main.ts')
  239. await harness.preparing.promise
  240. harness.prepared.resolve()
  241. await harness.hostStarted.promise
  242. harness.hosts[0]!.exited.resolve()
  243. harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed'))
  244. await harness.errorPublished.promise
  245. expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true })
  246. const nextStarted = harness.nextHostStart()
  247. const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll))
  248. await nextStarted
  249. expect(harness.pluginsEnabled).toBe(false)
  250. harness.hosts[1]!.ready.resolve()
  251. await recovery
  252. expect(harness.windows).toHaveLength(1)
  253. expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
  254. })
  255. it('waits for Host exit before relaunching the application', async () => {
  256. await import('../src/main.ts')
  257. await harness.preparing.promise
  258. harness.prepared.resolve()
  259. await harness.hostStarted.promise
  260. harness.hosts[0]!.ready.resolve()
  261. await harness.navigated.promise
  262. const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart))
  263. await harness.hosts[0]!.stopping.promise
  264. expect(harness.app.relaunch).not.toHaveBeenCalled()
  265. harness.hosts[0]!.exited.resolve()
  266. await restart
  267. expect(harness.app.relaunch).toHaveBeenCalledOnce()
  268. })
  269. it('shows the loading window before profile preparation and starts one actual Host', async () => {
  270. await import('../src/main.ts')
  271. await harness.preparing.promise
  272. expect(harness.windows).toHaveLength(1)
  273. const window = harness.windows[0]!
  274. expect(window.options.show).toBe(true)
  275. expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
  276. expect(harness.hosts).toHaveLength(0)
  277. const retry = invoke(DESKTOP_IPC.backendRetry)
  278. const secondRetry = invoke(DESKTOP_IPC.backendRetry)
  279. harness.prepared.resolve()
  280. await harness.hostStarted.promise
  281. expect(harness.hosts).toHaveLength(1)
  282. expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
  283. harness.hosts[0]!.ready.resolve()
  284. await Promise.all([retry, secondRetry, harness.navigated.promise])
  285. expect(harness.applyRelease).toHaveBeenCalledTimes(1)
  286. expect(harness.assertProfileRuntime).toHaveBeenCalledWith('desktop-test-profile')
  287. expect(harness.hosts[0]).toMatchObject({
  288. node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'),
  289. runtime: join('desktop-test-resources', 'dsh'),
  290. profile: 'desktop-test-profile',
  291. })
  292. expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
  293. expect(harness.windows).toHaveLength(1)
  294. expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html'])
  295. expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
  296. })
  297. it('starts the unpackaged Host from the application development directory', async () => {
  298. harness.app.isPackaged = false
  299. await import('../src/main.ts')
  300. await harness.hostStarted.promise
  301. const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
  302. expect(harness.hosts[0]).toMatchObject({ node: 'test-node', runtime: project, profile: project })
  303. expect(harness.applyRelease).not.toHaveBeenCalled()
  304. expect(harness.assertProfileRuntime).not.toHaveBeenCalled()
  305. harness.hosts[0]!.ready.resolve()
  306. await harness.navigated.promise
  307. expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
  308. })
  309. it('keeps startup errors and a successful retry in the same window', async () => {
  310. await import('../src/main.ts')
  311. await harness.preparing.promise
  312. harness.prepared.resolve()
  313. await harness.hostStarted.promise
  314. const first = harness.hosts[0]!
  315. const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed')
  316. first.exited.resolve()
  317. first.ready.reject(new Error('plugin composition failed'))
  318. await harness.errorPublished.promise
  319. await failedRetry
  320. expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', profileRecovery: true })
  321. expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html'])
  322. const nextStarted = harness.nextHostStart()
  323. const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
  324. await nextStarted
  325. expect(harness.hosts).toHaveLength(2)
  326. harness.hosts[1]!.ready.resolve()
  327. await retry
  328. expect(harness.windows).toHaveLength(1)
  329. expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/index.html')
  330. expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
  331. })
  332. it('waits for a pending child to exit on quit without late window navigation', async () => {
  333. await import('../src/main.ts')
  334. await harness.preparing.promise
  335. harness.prepared.resolve()
  336. await harness.hostStarted.promise
  337. const window = harness.windows[0]!
  338. const host = harness.hosts[0]!
  339. host.stop.mockImplementation(() => { host.stopping.resolve(); return host.exited.promise })
  340. window.close()
  341. harness.app.quit()
  342. await host.stopping.promise
  343. expect(harness.app.quit).toHaveBeenCalledTimes(1)
  344. host.ready.resolve()
  345. host.exited.resolve()
  346. await harness.quitCompleted.promise
  347. expect(host.stop).toHaveBeenCalledTimes(1)
  348. expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
  349. expect(harness.windows).toHaveLength(1)
  350. })
  351. })