resolved-profile-boot.spec.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  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, 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. }))
  17. vi.mock('@deepseek-ai/dsh-http-proxy', () => ({ installProxyFromEnvironment: vi.fn() }))
  18. const homes: string[] = []
  19. afterEach(() => {
  20. vi.restoreAllMocks()
  21. vi.unstubAllEnvs()
  22. vi.clearAllMocks()
  23. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  24. })
  25. describe('runProfile with an application-owned profile', () => {
  26. it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => {
  27. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
  28. homes.push(home)
  29. mkdirSync(join(home, 'runtime'))
  30. writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
  31. writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
  32. vi.stubEnv('DSH_HOME', home)
  33. vi.spyOn(process, 'on').mockReturnValue(process)
  34. const ctx = new Context()
  35. ctx.provide('loader', { create: vi.fn() })
  36. ctx.provide('hmr', {})
  37. const dispose = vi.spyOn(ctx.fiber, 'dispose')
  38. const failure = new Error('startup failed')
  39. const cleanupFailure = new Error('proxy cleanup failed')
  40. const treeCleanupFailure = new Error('tree cleanup failed')
  41. if (stage === 'tree-cleanup' || stage === 'both-cleanups') dispose.mockRejectedValueOnce(treeCleanupFailure)
  42. const disposeProxy = vi.fn().mockImplementation(() => stage === 'cleanup' || stage === 'both-cleanups'
  43. ? Promise.reject(cleanupFailure)
  44. : Promise.resolve())
  45. vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
  46. vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
  47. await setup?.(ctx)
  48. throw failure
  49. })
  50. if (stage === 'composition') vi.mocked(healIsolatedProfileModuleFallback).mockImplementationOnce(() => { throw failure })
  51. const profile: Profile = {
  52. name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
  53. 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(['link', 'runtime'] as const)('uses shared layers, %s resolution, and shutdown', async (resolutionMode) => {
  76. const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
  77. homes.push(home)
  78. mkdirSync(join(home, 'runtime'))
  79. writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
  80. writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
  81. vi.stubEnv('DSH_HOME', home)
  82. vi.stubEnv('DSH_TELEMETRY_DISABLED', '1')
  83. vi.spyOn(process, 'on').mockReturnValue(process)
  84. const oldExitCode = process.exitCode
  85. const ctx = new Context()
  86. // The real context supplies services; this test substitutes tree mounting and filesystem watchers.
  87. ctx.provide('loader', { create: vi.fn() })
  88. ctx.provide('hmr', {})
  89. const dispose = vi.spyOn(ctx.fiber, 'dispose')
  90. const disposeProxy = vi.fn().mockResolvedValue(undefined)
  91. vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
  92. vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
  93. await setup?.(ctx)
  94. return ctx
  95. })
  96. const homePatch = join(home, 'cordis.patch.yml')
  97. const profilePatch = join(home, 'profile.patch.yml')
  98. const overlay = join(home, 'desktop.patch.yml')
  99. writeFileSync(homePatch, '- id: target\n config: { home: true, priority: home }\n')
  100. writeFileSync(profilePatch, '- id: target\n config: { profile: true, priority: profile }\n')
  101. writeFileSync(overlay, '- id: target\n config: { overlay: true, priority: overlay }\n')
  102. writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
  103. const profile: Profile = {
  104. name: 'desktop', dir: home, patchPath: profilePatch,
  105. patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
  106. layers: [{
  107. packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'),
  108. patches: [{ insert: [
  109. { id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } },
  110. { id: 'session-telemetry-otel', name: 'telemetry' },
  111. ] }],
  112. }],
  113. }
  114. const environment = createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTPS_PROXY: 'http://localhost:8080' } }])
  115. const runtime = { profile, installAnchor: join(home, 'runtime/package.json') }
  116. try {
  117. const { shutdown } = await runProfile({
  118. environment, profile: 'desktop', resolvedProfile: runtime, resolutionMode,
  119. patchFiles: [overlay], args: ['--port', '0', '--no-open'],
  120. })
  121. expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function))
  122. if (resolutionMode === 'link') {
  123. expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor })
  124. } else {
  125. expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled()
  126. }
  127. expect(readFileSync(join(home, 'cordis.yml'), 'utf8')).not.toContain('stale')
  128. expect(ctx.cmdlineArgs!.get()).toEqual(['--port', '0', '--no-open'])
  129. const ready = vi.fn()
  130. ctx.appReady!.onReady(ready)
  131. expect(ready).toHaveBeenCalledOnce()
  132. const patches = vi.mocked(boot).mock.calls[0]![2]!
  133. const rows = composeEntries([patches])
  134. expect(patches.slice(1, 4)).toEqual([
  135. { id: 'target', config: { profile: true, priority: 'profile' } },
  136. { id: 'target', config: { home: true, priority: 'home' } },
  137. { id: 'target', config: { overlay: true, priority: 'overlay' } },
  138. ])
  139. expect(rows.find(row => row.id === 'target')?.config).toEqual({ overlay: true, priority: 'overlay' })
  140. expect(rows.find(row => row.id === 'session-telemetry-otel')?.disabled).toBe(true)
  141. expect(ctx.profileContext).toMatchObject({ dir: home, patchPath: profilePatch, installAnchor: runtime.installAnchor })
  142. await shutdown.shutdown(0)
  143. expect(dispose).toHaveBeenCalledOnce()
  144. expect(disposeProxy).toHaveBeenCalledOnce()
  145. } finally {
  146. await ctx.fiber.dispose()
  147. process.exitCode = oldExitCode
  148. }
  149. })
  150. })