desktop-host.e2e.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. /** Built Desktop Host lifecycle with Electron disconnecting before profile startup settles. */
  2. import { fork } from 'node:child_process'
  3. import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { finished } from 'node:stream/promises'
  8. import { expect, it, onTestFinished } from 'vitest'
  9. it.each([false, true])('settles startup after parent IPC disconnect (boot failure: %s)', async (fail) => {
  10. const root = mkdtempSync(join(tmpdir(), 'desktop-disconnect-'))
  11. const modules = join(root, 'node_modules', '@deepseek-ai')
  12. for (const name of ['dsh-app-boot', 'dsh', 'dsh-home-paths', 'dsh-tools']) mkdirSync(join(modules, name), { recursive: true })
  13. for (const [name, source] of [
  14. ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`],
  15. ['dsh-tools', 'export const defineTool = value => value'],
  16. ] as const) {
  17. writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}')
  18. writeFileSync(join(modules, name, 'index.js'), source)
  19. }
  20. writeFileSync(join(root, 'package.json'), '{"type":"module"}')
  21. writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
  22. writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({}); export const loadLayeredEnv = () => ({})')
  23. writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
  24. writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
  25. import { writeFileSync } from 'node:fs';
  26. export function runProfile() {
  27. process.send({ type: 'booting' });
  28. return new Promise((resolve, reject) => process.once('disconnect', () => {
  29. if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
  30. resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
  31. shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } });
  32. }));
  33. }
  34. `)
  35. const entry = join(root, 'index.js')
  36. copyFileSync(fileURLToPath(new URL('../../desktop-host/lib/index.js', import.meta.url)), entry)
  37. const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
  38. let stderr = ''
  39. child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
  40. const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
  41. const drained = finished(child.stderr!, { cleanup: true })
  42. onTestFinished(async () => {
  43. if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
  44. await Promise.all([exited, drained])
  45. rmSync(root, { recursive: true, force: true })
  46. })
  47. try {
  48. await new Promise<void>((resolve, reject) => {
  49. child.once('message', () => { resolve() })
  50. child.once('error', reject)
  51. child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) })
  52. })
  53. child.disconnect()
  54. expect(await exited).toBe(fail ? 1 : 0)
  55. await drained
  56. expect(stderr).not.toContain('ERR_IPC_CHANNEL_CLOSED')
  57. expect(stderr).not.toContain('Unhandled')
  58. if (fail) expect(stderr).toContain('fixture boot failure')
  59. else expect(readFileSync(join(root, 'stopped'), 'utf8')).toBe('stopped')
  60. } finally {
  61. if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
  62. await Promise.all([exited, drained])
  63. }
  64. })