resolved-profile-boot.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /** Application-owned profiles share the named profile launch lifecycle. */
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
  7. import { boot, composeEntries, healIsolatedProfileModuleFallback, watchUserPatches, type Profile } from '@deepseek-ai/dsh-app-boot'
  8. import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
  9. import { afterEach, describe, expect, it, vi } from 'vitest'
  10. import { runProfile } from '../src/profile-boot.ts'
  11. vi.mock('@deepseek-ai/dsh-app-boot', async importOriginal => ({
  12. ...await importOriginal<typeof import('@deepseek-ai/dsh-app-boot')>(),
  13. boot: vi.fn(),
  14. healIsolatedProfileModuleFallback: vi.fn(),
  15. installFailLoud: vi.fn(),
  16. watchUserPatches: vi.fn(),
  17. }))
  18. vi.mock('@deepseek-ai/dsh-http-proxy', () => ({ installProxyFromEnvironment: vi.fn() }))
  19. const homes: string[] = []
  20. afterEach(() => {
  21. vi.restoreAllMocks()
  22. vi.unstubAllEnvs()
  23. vi.clearAllMocks()
  24. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  25. })
  26. describe('runProfile with an application-owned profile', () => {
  27. it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => {
  28. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
  29. homes.push(home)
  30. mkdirSync(join(home, 'runtime'))
  31. writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
  32. writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
  33. vi.stubEnv('DSH_HOME', home)
  34. vi.spyOn(process, 'on').mockReturnValue(process)
  35. const ctx = new Context()
  36. ctx.provide('loader', { create: vi.fn() })
  37. ctx.provide('hmr', {})
  38. const dispose = vi.spyOn(ctx.fiber, 'dispose')
  39. const failure = new Error('startup failed')
  40. const cleanupFailure = new Error('proxy cleanup failed')
  41. const treeCleanupFailure = new Error('tree cleanup failed')
  42. if (stage === 'tree-cleanup' || stage === 'both-cleanups') dispose.mockRejectedValueOnce(treeCleanupFailure)
  43. const disposeProxy = vi.fn().mockImplementation(() => stage === 'cleanup' || stage === 'both-cleanups'
  44. ? Promise.reject(cleanupFailure)
  45. : Promise.resolve())
  46. vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
  47. vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
  48. await setup?.(ctx)
  49. if (stage === 'boot') throw failure
  50. return ctx
  51. })
  52. if (stage === 'composition') vi.mocked(healIsolatedProfileModuleFallback).mockImplementationOnce(() => { throw failure })
  53. if (stage !== 'composition' && stage !== 'boot') vi.mocked(watchUserPatches).mockImplementationOnce(() => { throw failure })
  54. const profile: Profile = {
  55. name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
  56. patchReload: 'live', patches: [], layers: [],
  57. }
  58. try {
  59. const application = runProfile({
  60. environment: createLaunchEnvironmentSnapshot([]), profile: 'desktop', patchFiles: [], args: ['--no-open'],
  61. resolvedProfile: { profile, installAnchor: join(home, 'runtime/package.json') },
  62. })
  63. if (stage === 'both-cleanups') {
  64. await expect(application).rejects.toMatchObject({ errors: [failure, { errors: [treeCleanupFailure, cleanupFailure] }] })
  65. } else if (stage === 'tree-cleanup') {
  66. await expect(application).rejects.toMatchObject({ errors: [failure, treeCleanupFailure] })
  67. } else if (stage === 'cleanup') {
  68. await expect(application).rejects.toMatchObject({ errors: [failure, cleanupFailure] })
  69. } else {
  70. await expect(application).rejects.toBe(failure)
  71. }
  72. expect(disposeProxy).toHaveBeenCalledOnce()
  73. expect(dispose).toHaveBeenCalledTimes(stage === 'composition' ? 0 : 1)
  74. } finally {
  75. await ctx.fiber.dispose()
  76. }
  77. })
  78. it.each([['live', 'link'], ['startup', 'link'], ['startup', 'runtime']] as const)('uses shared layers, %s reload, %s resolution, and shutdown', async (patchReload, resolutionMode) => {
  79. const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
  80. homes.push(home)
  81. mkdirSync(join(home, 'runtime'))
  82. writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
  83. writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
  84. vi.stubEnv('DSH_HOME', home)
  85. vi.stubEnv('DSH_TELEMETRY_DISABLED', '1')
  86. vi.spyOn(process, 'on').mockReturnValue(process)
  87. const oldExitCode = process.exitCode
  88. const ctx = new Context()
  89. // The real context supplies services; this test substitutes tree mounting and filesystem watchers.
  90. ctx.provide('loader', { create: vi.fn() })
  91. ctx.provide('hmr', {})
  92. const dispose = vi.spyOn(ctx.fiber, 'dispose')
  93. const disposeProxy = vi.fn().mockResolvedValue(undefined)
  94. vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
  95. vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
  96. await setup?.(ctx)
  97. return ctx
  98. })
  99. const homePatch = join(home, 'cordis.patch.yml')
  100. const profilePatch = join(home, 'profile.patch.yml')
  101. const overlay = join(home, 'desktop.patch.yml')
  102. writeFileSync(homePatch, '- id: target\n config: { home: true, priority: home }\n')
  103. writeFileSync(profilePatch, '- id: target\n config: { profile: true, priority: profile }\n')
  104. writeFileSync(overlay, '- id: target\n config: { overlay: true, priority: overlay }\n')
  105. writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
  106. const profile: Profile = {
  107. name: 'desktop', dir: home, patchPath: profilePatch, patchReload,
  108. patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
  109. layers: [{
  110. packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'),
  111. patches: [{ insert: [
  112. { id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } },
  113. { id: 'session-telemetry-otel', name: 'telemetry' },
  114. ] }],
  115. }],
  116. }
  117. const environment = createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTPS_PROXY: 'http://localhost:8080' } }])
  118. const runtime = { profile, installAnchor: join(home, 'runtime/package.json') }
  119. try {
  120. const { shutdown } = await runProfile({
  121. environment, profile: 'desktop', resolvedProfile: runtime, resolutionMode,
  122. patchFiles: [overlay], args: ['--port', '0', '--no-open'],
  123. })
  124. expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function))
  125. if (resolutionMode === 'link') {
  126. expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor })
  127. } else {
  128. expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled()
  129. }
  130. expect(readFileSync(join(home, 'cordis.yml'), 'utf8')).not.toContain('stale')
  131. expect(ctx.cmdlineArgs!.get()).toEqual(['--port', '0', '--no-open'])
  132. const ready = vi.fn()
  133. ctx.appReady!.onReady(ready)
  134. expect(ready).toHaveBeenCalledOnce()
  135. const patches = vi.mocked(boot).mock.calls[0]![2]!
  136. const rows = composeEntries([patches])
  137. expect(patches.slice(1, 4)).toEqual([
  138. { id: 'target', config: { profile: true, priority: 'profile' } },
  139. { id: 'target', config: { home: true, priority: 'home' } },
  140. { id: 'target', config: { overlay: true, priority: 'overlay' } },
  141. ])
  142. expect(rows.find(row => row.id === 'target')?.config).toEqual({ overlay: true, priority: 'overlay' })
  143. expect(rows.find(row => row.id === 'session-telemetry-otel')?.disabled).toBe(true)
  144. if (patchReload === 'live') {
  145. expect(vi.mocked(watchUserPatches).mock.calls.map(call => call[1].filename)).toEqual([profilePatch, homePatch])
  146. writeFileSync(homePatch, '- id: target\n config: { home: updated }\n')
  147. const live = vi.mocked(watchUserPatches).mock.calls[0]![1].compose!
  148. expect(live([])[2]).toMatchObject({ id: 'target', config: { home: 'updated' } })
  149. } else {
  150. expect(watchUserPatches).not.toHaveBeenCalled()
  151. }
  152. await shutdown.shutdown(0)
  153. expect(dispose).toHaveBeenCalledOnce()
  154. expect(disposeProxy).toHaveBeenCalledOnce()
  155. } finally {
  156. await ctx.fiber.dispose()
  157. process.exitCode = oldExitCode
  158. }
  159. })
  160. })