resolved-profile-boot.spec.ts 10 KB

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