resolved-profile-boot.spec.ts 7.8 KB

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