host-process.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import { DesktopHostProcess, DesktopHostUncleanExitError } from '../src/host-process.ts'
  6. const roots: string[] = []
  7. const hosts: DesktopHostProcess[] = []
  8. const HTTP_HOST = `
  9. import { createServer } from 'node:http'
  10. import { writeFileSync } from 'node:fs'
  11. import { join } from 'node:path'
  12. const server = createServer((request, response) => {
  13. if (request.url === '/fatal') {
  14. process.send({ type: 'fatal', message: 'plugin unavailable' })
  15. response.end('reported')
  16. return
  17. }
  18. if (request.url === '/crash') {
  19. response.end('exiting', () => {
  20. process.stderr.write('plugin crashed', () => process.exit(7))
  21. })
  22. return
  23. }
  24. response.setHeader('content-type', 'application/json')
  25. response.end(JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH, registry: process.env.NPM_CONFIG_REGISTRY, nodeOptions: process.env.NODE_OPTIONS, runAsNode: process.env.ELECTRON_RUN_AS_NODE, internals: process.execArgv.includes('--expose-internals')}))
  26. })
  27. server.listen(0, '127.0.0.1', () => {
  28. process.send({ type: 'ready', url: 'http://127.0.0.1:' + server.address().port + '/?token=fixture' })
  29. })
  30. process.on('message', message => {
  31. if (message.type === 'update-tasks') {
  32. process.send({ type: 'update-tasks', requestId: message.requestId, active: message.action === 'lock' })
  33. return
  34. }
  35. if (message.type !== 'shutdown') return
  36. server.close(() => {
  37. writeFileSync(join(process.argv[3], 'stopped'), '')
  38. process.send({ type: 'shutdown-complete' }, () => process.disconnect())
  39. })
  40. server.closeAllConnections()
  41. })
  42. `
  43. function projectWithHost(source = HTTP_HOST): string {
  44. const project = mkdtempSync(join(tmpdir(), 'dsh-desktop-host-test-'))
  45. roots.push(project)
  46. const packageRoot = join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host')
  47. mkdirSync(join(packageRoot, 'lib'), { recursive: true })
  48. writeFileSync(join(packageRoot, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","type":"module"}\n')
  49. writeFileSync(join(packageRoot, 'lib', 'index.js'), source)
  50. return project
  51. }
  52. function hostProcess(
  53. runtime: string, profile = runtime, onFailure?: (error: Error) => void, environment = process.env,
  54. ): DesktopHostProcess {
  55. const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, environment, onFailure)
  56. hosts.push(host)
  57. return host
  58. }
  59. afterEach(async () => {
  60. await Promise.all(hosts.splice(0).map(host => host.stop()))
  61. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  62. })
  63. describe('desktop host process', () => {
  64. it('correlates task inspections and admission changes over private IPC', async () => {
  65. const host = hostProcess(projectWithHost())
  66. await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
  67. await host.start()
  68. expect(await Promise.all([host.updateTasks('inspect'), host.updateTasks('lock'), host.updateTasks('unlock')]))
  69. .toEqual([false, true, false])
  70. await host.stop(true)
  71. await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
  72. })
  73. it.each([
  74. 'process.exit(17)',
  75. 'process.exit(0)',
  76. ])('refuses installation when exit lacks successful teardown acknowledgement: %s', async (exit) => {
  77. const host = hostProcess(projectWithHost(`
  78. process.send({ type: 'ready', url: 'http://127.0.0.1:3080/' })
  79. process.on('message', message => {
  80. if (message.type === 'shutdown') process.stderr.write('token=fixture-secret', () => { ${exit} })
  81. })
  82. `))
  83. await host.start()
  84. const error = await host.stop(true).then(() => undefined, (error: unknown) => error)
  85. expect(error).toBeInstanceOf(DesktopHostUncleanExitError)
  86. expect(String(error)).toContain('shutdown acknowledged false')
  87. expect(String(error)).toContain('graceful deadline exceeded false')
  88. expect(String(error)).not.toContain('fixture-secret')
  89. await expect(host.stop()).resolves.toBeUndefined()
  90. })
  91. it('returns the Web authentication URL and waits for graceful shutdown', async () => {
  92. const runtime = projectWithHost()
  93. const failure = vi.fn()
  94. const host = hostProcess(runtime, runtime, failure)
  95. const ready = await host.start()
  96. expect(new URL(ready.url).searchParams.get('token')).toBe('fixture')
  97. expect(await host.start()).toEqual(ready)
  98. expect((await fetch(ready.url)).status).toBe(200)
  99. await host.stop()
  100. expect(existsSync(join(runtime, 'stopped'))).toBe(true)
  101. await expect(fetch(ready.url)).rejects.toThrow()
  102. expect(failure).not.toHaveBeenCalled()
  103. })
  104. it('passes external dependencies and runtime profile resolution to the Host', async () => {
  105. const runtime = projectWithHost(HTTP_HOST.replace('runtime: process.argv[2]',
  106. 'pnpm: process.argv[6], nodeBin: process.argv[7], primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
  107. const primaryRuntime = join(runtime, 'external-primary-runtime')
  108. const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
  109. undefined, primaryRuntime, 'runtime', { pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
  110. hosts.push(host)
  111. const { url } = await host.start()
  112. expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime', pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
  113. })
  114. it('reports a fatal event after readiness once', async () => {
  115. const runtime = projectWithHost()
  116. const failure = vi.fn()
  117. const host = hostProcess(runtime, runtime, failure)
  118. const { url } = await host.start()
  119. await fetch(new URL('/fatal', url))
  120. await expect.poll(() => failure.mock.calls.length).toBe(1)
  121. await host.stop()
  122. expect(failure).toHaveBeenCalledTimes(1)
  123. expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable'))
  124. })
  125. it('reports a child crash after readiness with its stderr diagnostic', async () => {
  126. const runtime = projectWithHost()
  127. const failure = vi.fn()
  128. const host = hostProcess(runtime, runtime, failure)
  129. const { url } = await host.start()
  130. await fetch(new URL('/crash', url))
  131. await expect.poll(() => failure.mock.calls.length).toBe(1)
  132. expect(failure).toHaveBeenCalledWith(new Error('dsh desktop host exited with 7: plugin crashed'))
  133. })
  134. it('retains only recent diagnostics from a noisy child', async () => {
  135. const runtime = projectWithHost('process.stderr.write(\'discarded-prefix\' + \'x\'.repeat(70_000) + \'recent-failure\', () => { process.exitCode = 7; process.disconnect() })')
  136. const failure = await hostProcess(runtime).start().catch((error: unknown) => error)
  137. expect(failure).toBeInstanceOf(Error)
  138. const message = (failure as Error).message
  139. expect(message).not.toContain('discarded-prefix')
  140. expect(message.endsWith('recent-failure')).toBe(true)
  141. expect(message.length).toBeLessThan(66_000)
  142. })
  143. it('settles teardown when the executable cannot be spawned', async () => {
  144. const runtime = projectWithHost()
  145. const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime)
  146. hosts.push(host)
  147. await expect(host.start()).rejects.toThrow()
  148. await host.stop()
  149. })
  150. it('loads the resource entry with a separate profile and inherits runtime and package-manager configuration', async () => {
  151. const runtime = projectWithHost()
  152. const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-'))
  153. roots.push(profile)
  154. const host = hostProcess(runtime, profile, undefined, {
  155. ...process.env, NODE_OPTIONS: '--no-warnings', NODE_PATH: '/custom', NPM_CONFIG_REGISTRY: 'https://registry.example.test/',
  156. })
  157. const { url } = await host.start()
  158. const response = await fetch(url)
  159. expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile), nodePath: '/custom', registry: 'https://registry.example.test/', nodeOptions: '--no-warnings', runAsNode: '1', internals: true })
  160. })
  161. it.each([
  162. ["process.send({ type: 'fatal', message: 'startup failed' }); process.disconnect()", 'startup failed'],
  163. ["process.send({ type: 'ready', url: 4 })", 'invalid IPC event'],
  164. ['process.exit(0)', 'host stopped'],
  165. ])('rejects startup when the child fails before readiness: %s', async (source, message) => {
  166. const host = hostProcess(projectWithHost(source))
  167. await expect(host.start()).rejects.toThrow(message)
  168. })
  169. })