project-manager.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, 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 { resolveDesktopPaths } from '../src/paths.ts'
  6. import { DesktopProjectManager } from '../src/project-manager.ts'
  7. import { readProfilePlugins } from '@deepseek-ai/dsh-app-boot'
  8. import { runtimeFixture } from './runtime-fixture.ts'
  9. const roots: string[] = []
  10. function temporaryRoot(): string {
  11. const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
  12. roots.push(root)
  13. return root
  14. }
  15. function seedPlugin(manager: DesktopProjectManager): void {
  16. const path = join(manager.paths.profile, 'package.json')
  17. const manifest = JSON.parse(readFileSync(path, 'utf8')) as {
  18. dependencies: Record<string, string>
  19. dsh: { profile: { bundles: string[] } }
  20. }
  21. manifest.dependencies.plugin = '1.0.0'
  22. manifest.dsh.profile.bundles.push('plugin')
  23. writeFileSync(path, JSON.stringify(manifest))
  24. const directory = join(manager.paths.profile, 'node_modules/plugin')
  25. mkdirSync(directory, { recursive: true })
  26. writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'plugin', version: '1.0.0', dsh: { bundle: { patch: 'bundle.yml' } } }))
  27. writeFileSync(join(directory, 'bundle.yml'), '[]\n')
  28. }
  29. function plugins(manager: DesktopProjectManager) {
  30. return readProfilePlugins({ binName: 'dsh', profileDir: manager.paths.profile,
  31. installAnchor: join(manager.runtime.dsh, 'node_modules/@deepseek-ai/dsh/package.json') }).dependencies
  32. .map(({ name, version, enabled }) => ({ name, version, enabled }))
  33. }
  34. function setup(): { root: string; manager: DesktopProjectManager } {
  35. const root = temporaryRoot()
  36. const dsh = join(root, 'resources', 'dsh')
  37. runtimeFixture(dsh)
  38. return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { dsh }) }
  39. }
  40. afterEach(() => {
  41. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  42. })
  43. describe('desktop external plugin profile', () => {
  44. it('cleans application packages only when preparing a production launch', async () => {
  45. const { manager } = setup()
  46. await manager.applyRelease()
  47. const name = '@deepseek-ai/dsh-web-app'
  48. const path = join(manager.paths.profile, 'node_modules', name)
  49. mkdirSync(path, { recursive: true })
  50. await manager.applyRelease()
  51. expect(existsSync(path)).toBe(true)
  52. await manager.applyRelease(true)
  53. expect(existsSync(path)).toBe(false)
  54. })
  55. it('reuses plugin files without scanning manifests and can disable them', async () => {
  56. const { manager } = setup()
  57. await manager.applyRelease()
  58. seedPlugin(manager)
  59. const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
  60. writeFileSync(manifest, '{broken')
  61. await expect(manager.applyRelease()).resolves.toBeUndefined()
  62. await manager.disableAllPlugins()
  63. await expect(manager.applyRelease()).resolves.toBeUndefined()
  64. expect(readFileSync(manifest, 'utf8')).toBe('{broken')
  65. })
  66. it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
  67. const { manager } = setup()
  68. await manager.applyRelease()
  69. seedPlugin(manager)
  70. const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
  71. unlinkSync(patch)
  72. await manager.disableAllPlugins()
  73. expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
  74. dsh: { profile: { bundles: string[] } }
  75. }).dsh.profile.bundles).not.toContain('plugin')
  76. expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
  77. await expect(manager.applyRelease()).resolves.toBeUndefined()
  78. })
  79. it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
  80. const { manager } = setup()
  81. await manager.applyRelease()
  82. seedPlugin(manager)
  83. const patch = join(manager.paths.profile, 'cordis.patch.yml')
  84. writeFileSync(patch, ': broken')
  85. const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
  86. const backupPath = await uninitialized.disableAllPlugins()
  87. expect(existsSync(patch)).toBe(false)
  88. const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-'))
  89. expect(backups).toHaveLength(1)
  90. expect(backupPath).toBe(join(manager.paths.profile, backups[0]!))
  91. expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken')
  92. expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
  93. const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
  94. dependencies: Record<string, string>
  95. dsh: { profile: { bundles: string[] } }
  96. }
  97. expect(manifest.dependencies.plugin).toBe('1.0.0')
  98. expect(manifest.dsh.profile.bundles).not.toContain('plugin')
  99. expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
  100. await manager.applyRelease()
  101. expect(readFileSync(patch, 'utf8')).toContain('[]')
  102. })
  103. it('needs no runtime or package manifest when no plugins have been installed', async () => {
  104. const { manager } = setup()
  105. await expect(manager.disableAllPlugins()).resolves.toBeUndefined()
  106. expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
  107. expect(existsSync(manager.paths.lock)).toBe(false)
  108. })
  109. it('reports invalid profile JSON without replacing it', async () => {
  110. const { manager } = setup()
  111. await manager.applyRelease()
  112. const path = join(manager.paths.profile, 'package.json')
  113. writeFileSync(path, '{broken')
  114. await expect(manager.disableAllPlugins()).rejects.toThrow()
  115. expect(readFileSync(path, 'utf8')).toBe('{broken')
  116. expect(existsSync(manager.paths.lock)).toBe(false)
  117. })
  118. it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => {
  119. const { manager } = setup()
  120. await manager.applyRelease()
  121. expect(readFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), 'utf8'))
  122. .toBe('packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n')
  123. })
  124. it('opens a linked profile and migrates only the generated pnpm defaults', async () => {
  125. const { root, manager } = setup()
  126. const target = join(root, 'linked-profile')
  127. mkdirSync(target)
  128. mkdirSync(join(manager.paths.profile, '..'), { recursive: true })
  129. symlinkSync(target, manager.paths.profile, process.platform === 'win32' ? 'junction' : 'dir')
  130. await manager.applyRelease()
  131. const path = join(target, 'pnpm-workspace.yaml')
  132. const defaults = readFileSync(path, 'utf8')
  133. writeFileSync(path, `${defaults}strictDepBuilds: true\nallowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n "@deepseek-ai/dsh-subprocess-local": true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n`)
  134. await manager.applyRelease()
  135. expect(readFileSync(path, 'utf8')).toBe(defaults)
  136. const custom = `${defaults}allowBuilds:\n my-plugin: true\n`
  137. writeFileSync(path, custom)
  138. await manager.applyRelease()
  139. expect(readFileSync(path, 'utf8')).toBe(custom)
  140. })
  141. it('reports damaged application metadata as a reinstall failure', async () => {
  142. const { manager } = setup()
  143. writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
  144. await expect(manager.applyRelease()).rejects.toThrow()
  145. })
  146. it('preserves unknown files when initializing a profile', async () => {
  147. const { manager } = setup()
  148. mkdirSync(manager.paths.profile, { recursive: true })
  149. writeFileSync(join(manager.paths.profile, '.DS_Store'), 'metadata')
  150. writeFileSync(join(manager.paths.profile, 'user-file'), 'retain')
  151. await expect(manager.applyRelease()).resolves.toBeUndefined()
  152. expect(readFileSync(join(manager.paths.profile, '.DS_Store'), 'utf8')).toBe('metadata')
  153. expect(readFileSync(join(manager.paths.profile, 'user-file'), 'utf8')).toBe('retain')
  154. expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toContain('[]')
  155. expect(existsSync(join(manager.paths.profile, 'desktop-runtime-state.json'))).toBe(false)
  156. })
  157. it('initializes and restarts offline without executing pnpm', async () => {
  158. const { manager } = setup()
  159. await expect(manager.applyRelease()).resolves.toBeUndefined()
  160. await expect(manager.applyRelease()).resolves.toBeUndefined()
  161. expect(plugins(manager)).toEqual([])
  162. expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
  163. })
  164. it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => {
  165. const { manager } = setup()
  166. await manager.applyRelease()
  167. const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() })
  168. await expect(relaunched.applyRelease()).resolves.toBeUndefined()
  169. })
  170. it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
  171. const { manager } = setup()
  172. if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}')
  173. if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
  174. if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
  175. if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json'))
  176. await expect(manager.applyRelease()).resolves.toBeUndefined()
  177. const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
  178. await expect(relaunched.applyRelease()).resolves.toBeUndefined()
  179. expect(existsSync(manager.paths.profile)).toBe(true)
  180. })
  181. it('keeps plugin files and patches through a compatible release and application relocation', async () => {
  182. const { root, manager } = setup()
  183. await manager.applyRelease()
  184. seedPlugin(manager)
  185. writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n')
  186. const nextRoot = join(root, 'relocated', 'dsh')
  187. runtimeFixture(nextRoot, '1.1.0')
  188. const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot })
  189. await expect(next.applyRelease()).resolves.toBeUndefined()
  190. expect(plugins(next)).toEqual(plugins(manager))
  191. expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
  192. expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
  193. })
  194. it('preserves plugin files without running pnpm when bundled Node changes', async () => {
  195. const { root, manager } = setup()
  196. await manager.applyRelease()
  197. seedPlugin(manager)
  198. const dsh = join(root, 'new-node')
  199. runtimeFixture(dsh, '1.1.0', '24.18.0')
  200. const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
  201. await next.applyRelease()
  202. expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
  203. })
  204. it('allows peer version mismatches to reach Host startup and remain available for recovery', async () => {
  205. const { root, manager } = setup()
  206. await manager.applyRelease()
  207. seedPlugin(manager)
  208. const dsh = join(root, 'next-major')
  209. runtimeFixture(dsh, '2.0.0')
  210. const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
  211. await expect(next.applyRelease()).resolves.toBeUndefined()
  212. await next.disableAllPlugins()
  213. expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
  214. })
  215. })
  216. describe.each(['applyRelease', 'disableAllPlugins'] as const)('desktop profile lock during %s', (operation) => {
  217. it('preserves a live owner lock and leaves the profile untouched', async () => {
  218. const { manager } = setup()
  219. mkdirSync(manager.paths.profile, { recursive: true })
  220. const owner = `${String(process.pid)}\n`
  221. writeFileSync(manager.paths.lock, owner)
  222. await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
  223. expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
  224. expect(readdirSync(manager.paths.profile)).toEqual(['lock'])
  225. })
  226. it('reclaims a stale owner lock and releases it after the operation', async () => {
  227. const { manager } = setup()
  228. mkdirSync(manager.paths.profile, { recursive: true })
  229. writeFileSync(manager.paths.lock, `${String(process.pid)}\n`)
  230. const probe = vi.spyOn(process, 'kill').mockImplementation(() => {
  231. throw Object.assign(new Error('process absent'), { code: 'ESRCH' })
  232. })
  233. try {
  234. await expect(manager[operation]()).resolves.toBeUndefined()
  235. expect(probe).toHaveBeenCalledExactlyOnceWith(process.pid, 0)
  236. expect(existsSync(manager.paths.lock)).toBe(false)
  237. } finally {
  238. probe.mockRestore()
  239. }
  240. })
  241. it.each(['invalid', '0', '-1', '9007199254740992'])('preserves a lock with invalid owner %s', async (owner) => {
  242. const { manager } = setup()
  243. mkdirSync(manager.paths.profile, { recursive: true })
  244. writeFileSync(manager.paths.lock, owner)
  245. await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
  246. expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
  247. })
  248. it('rejects a directory at the lock path', async () => {
  249. const { manager } = setup()
  250. mkdirSync(manager.paths.lock, { recursive: true })
  251. await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
  252. expect(lstatSync(manager.paths.lock).isDirectory()).toBe(true)
  253. })
  254. it('rejects a linked lock without touching its target', async () => {
  255. const { root, manager } = setup()
  256. mkdirSync(manager.paths.profile, { recursive: true })
  257. const target = join(root, 'lock-target')
  258. mkdirSync(target)
  259. writeFileSync(join(target, 'sentinel'), 'retain')
  260. symlinkSync(target, manager.paths.lock, process.platform === 'win32' ? 'junction' : 'dir')
  261. try {
  262. await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
  263. expect(lstatSync(manager.paths.lock).isSymbolicLink()).toBe(true)
  264. expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('retain')
  265. } finally {
  266. unlinkSync(manager.paths.lock)
  267. }
  268. })
  269. })