project-manager.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import { createHash } from 'node:crypto'
  2. import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join, relative, sep } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { resolveDesktopPaths } from '../src/paths.ts'
  8. import {
  9. createSeedMetadata,
  10. DesktopProjectManager,
  11. packageNameFromSpec,
  12. verifySeedIntegrity,
  13. type DesktopProjectHooks,
  14. } from '../src/project-manager.ts'
  15. import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
  16. import { DESKTOP_PACKAGES_DIR, DESKTOP_PACKAGE_SET_FILE } from '../src/core-package-set.ts'
  17. import type { DesktopRelease } from '../src/release.ts'
  18. import { archivePnpmStore } from '../src/seed-store.ts'
  19. const roots: string[] = []
  20. const releaseWorkers: Array<() => Promise<void>> = []
  21. function temporaryRoot(): string {
  22. const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
  23. roots.push(root)
  24. return root
  25. }
  26. function writeIntegrity(seed: string): void {
  27. const paths: string[] = []
  28. const visit = (directory: string): void => {
  29. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  30. const path = join(directory, entry.name)
  31. if (entry.isDirectory()) visit(path)
  32. else if (entry.name !== 'integrity.json') paths.push(path)
  33. }
  34. }
  35. visit(seed)
  36. const files = paths.sort().map((path) => {
  37. const body = readFileSync(path)
  38. return {
  39. path: relative(seed, path).split(sep).join('/'),
  40. bytes: statSync(path).size,
  41. sha256: createHash('sha256').update(body).digest('hex'),
  42. }
  43. })
  44. writeFileSync(join(seed, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files })}\n`)
  45. }
  46. function archiveStore(seed: string): void {
  47. const store = join(seed, 'store')
  48. mkdirSync(store, { recursive: true })
  49. if (readdirSync(store).length === 0) writeFileSync(join(store, 'test-entry'), 'content')
  50. archivePnpmStore(seed, store)
  51. }
  52. function writeCorePackageSet(seed: string, version: string): void {
  53. const packages = [
  54. { name: '@deepseek-ai/dsh', file: `deepseek-ai-dsh-${version}.tgz`, body: Buffer.from(`dsh-${version}`) },
  55. {
  56. name: '@deepseek-ai/dsh-desktop-host',
  57. file: `deepseek-ai-dsh-desktop-host-${version}.tgz`,
  58. body: Buffer.from(`desktop-host-${version}`),
  59. },
  60. ]
  61. mkdirSync(join(seed, DESKTOP_PACKAGES_DIR), { recursive: true })
  62. for (const entry of packages) writeFileSync(join(seed, DESKTOP_PACKAGES_DIR, entry.file), entry.body)
  63. writeFileSync(join(seed, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({
  64. schemaVersion: 1,
  65. packages: packages.map(({ name, file, body }) => ({
  66. name,
  67. version,
  68. file,
  69. bytes: body.byteLength,
  70. integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`,
  71. })),
  72. })}\n`)
  73. }
  74. function createTestSeedMetadata(seed: string, desktopRelease: DesktopRelease): void {
  75. writeCorePackageSet(seed, desktopRelease.version)
  76. createSeedMetadata(seed, desktopRelease)
  77. }
  78. function writeFakePnpm(root: string): string {
  79. const path = join(root, 'pnpm.mjs')
  80. writeFileSync(path, String.raw`
  81. import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  82. import { dirname, join } from 'node:path'
  83. const args = process.argv.slice(2)
  84. const project = process.cwd()
  85. const command = args.find(value => value === 'install' || value === 'add' || value === 'remove')
  86. const manifestPath = join(project, 'package.json')
  87. const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
  88. const packageName = spec => spec.startsWith('@')
  89. ? spec.slice(0, spec.indexOf('@', spec.indexOf('/') + 1) === -1 ? undefined : spec.indexOf('@', spec.indexOf('/') + 1))
  90. : spec.split('@')[0]
  91. const packageVersion = spec => {
  92. const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@')
  93. return index === -1 ? '1.0.0' : spec.slice(index + 1)
  94. }
  95. if (command === 'add') {
  96. const spec = args[args.indexOf('add') + 1]
  97. manifest.dependencies[packageName(spec)] = packageVersion(spec)
  98. }
  99. if (command === 'remove') delete manifest.dependencies[args[args.indexOf('remove') + 1]]
  100. writeFileSync(manifestPath, JSON.stringify(manifest))
  101. rmSync(join(project, 'node_modules'), { recursive: true, force: true })
  102. for (const [name, version] of Object.entries(manifest.dependencies)) {
  103. const packageRoot = join(project, 'node_modules', ...name.split('/'))
  104. mkdirSync(packageRoot, { recursive: true })
  105. const core = name === '@deepseek-ai/dsh' || name === '@deepseek-ai/dsh-desktop-host'
  106. const plugin = !core
  107. const installedVersion = plugin
  108. ? version
  109. : JSON.parse(readFileSync(join(project, 'desktop-release.json'), 'utf8')).version
  110. writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
  111. name, version: installedVersion,
  112. ...(plugin ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}),
  113. }))
  114. if (plugin) writeFileSync(join(packageRoot, 'bundle.yml'), '[]\n')
  115. else if (name === '@deepseek-ai/dsh-desktop-host') {
  116. mkdirSync(join(packageRoot, 'lib'), { recursive: true })
  117. writeFileSync(join(packageRoot, 'lib', 'index.js'), '')
  118. }
  119. }
  120. writeFileSync(join(project, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  121. if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.stringify({ args, env: process.env }))
  122. `)
  123. return path
  124. }
  125. function writeBlockingFakePnpm(root: string, ready: string, release: string): string {
  126. const path = join(root, 'blocking-pnpm.mjs')
  127. const delegate = writeFakePnpm(root)
  128. writeFileSync(path, `
  129. import { existsSync, writeFileSync } from 'node:fs'
  130. import { setTimeout as sleep } from 'node:timers/promises'
  131. writeFileSync(${JSON.stringify(ready)}, String(process.pid))
  132. while (!existsSync(${JSON.stringify(release)})) await sleep(10)
  133. await import(${JSON.stringify(pathToFileURL(delegate).href)})
  134. `)
  135. return path
  136. }
  137. function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
  138. return {
  139. healthCheck: async () => {},
  140. beforeActivate: async () => {},
  141. afterActivate: async () => {},
  142. ...overrides,
  143. }
  144. }
  145. function release(version = '1.0.0'): DesktopRelease {
  146. return {
  147. schemaVersion: 1,
  148. version,
  149. hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
  150. nodeVersion: '24.17.0',
  151. pnpmVersion: '11.7.0',
  152. }
  153. }
  154. afterEach(async () => {
  155. const cleanups = releaseWorkers.splice(0)
  156. const directories = roots.splice(0)
  157. const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()))
  158. for (const root of directories) rmSync(root, { recursive: true, force: true })
  159. const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : [])
  160. if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed')
  161. })
  162. describe('desktop package policy', () => {
  163. it('accepts registry package specs but rejects alternate sources and flags', () => {
  164. expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin')
  165. expect(packageNameFromSpec('plugin@next')).toBe('plugin')
  166. expect(() => packageNameFromSpec('file:../plugin')).toThrow(/unsupported npm package spec/u)
  167. expect(() => packageNameFromSpec('--registry=evil')).toThrow(/unsupported npm package spec/u)
  168. expect(() => packageNameFromSpec('https://example.test/plugin.tgz')).toThrow(/unsupported npm package spec/u)
  169. })
  170. it('rejects any seed content changed after release inventory generation', () => {
  171. const seed = join(temporaryRoot(), 'seed')
  172. createTestSeedMetadata(seed, release())
  173. writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  174. writeIntegrity(seed)
  175. expect(() => { verifySeedIntegrity(seed) }).not.toThrow()
  176. writeFileSync(join(seed, 'package.json'), '{}\n')
  177. expect(() => { verifySeedIntegrity(seed) }).toThrow(/integrity verification failed/u)
  178. })
  179. })
  180. describe('desktop project transactions', () => {
  181. it('installs the offline seed and reconciles a mismatched private Host', async () => {
  182. const root = temporaryRoot()
  183. const seed = join(root, 'seed')
  184. const log = join(root, 'pnpm-log.json')
  185. createTestSeedMetadata(seed, release())
  186. writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  187. mkdirSync(join(seed, 'store'), { recursive: true })
  188. writeFileSync(join(seed, 'store', 'seed-entry'), 'content')
  189. archiveStore(seed)
  190. writeIntegrity(seed)
  191. const paths = resolveDesktopPaths(join(root, '.dsh'))
  192. const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
  193. const previousLog = process.env.TEST_PNPM_LOG
  194. const previousRegistry = process.env.npm_config_registry
  195. process.env.TEST_PNPM_LOG = log
  196. process.env.npm_config_registry = 'https://user-registry.invalid'
  197. try {
  198. await expect(manager.applyRelease(seed, '2.0.0', hooks())).rejects.toThrow(/does not match Electron/u)
  199. await manager.applyRelease(seed, '1.0.0', hooks())
  200. writeFileSync(
  201. join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'),
  202. '{"name":"@deepseek-ai/dsh-desktop-host","version":"0.9.0"}\n',
  203. )
  204. await expect(manager.applyRelease(seed, '1.0.0', hooks())).resolves.toBe(true)
  205. } finally {
  206. if (previousLog === undefined) delete process.env.TEST_PNPM_LOG
  207. else process.env.TEST_PNPM_LOG = previousLog
  208. if (previousRegistry === undefined) delete process.env.npm_config_registry
  209. else process.env.npm_config_registry = previousRegistry
  210. }
  211. expect(manager.dshVersion()).toBe('1.0.0')
  212. expect(manager.releaseVersion()).toBe('1.0.0')
  213. expect(paths.profile).toBe(join(root, '.dsh', 'profiles', 'desktop'))
  214. expect(existsSync(join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(true)
  215. const installedHost = JSON.parse(readFileSync(
  216. join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'),
  217. 'utf8',
  218. )) as { version: string }
  219. expect(installedHost.version).toBe('1.0.0')
  220. expect(existsSync(join(paths.profile, 'desktop-plugins.json'))).toBe(false)
  221. expect(readFileSync(join(paths.pnpm.store, 'seed-entry'), 'utf8')).toBe('content')
  222. const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record<string, string> }
  223. expect(invocation.args).toContain('--offline')
  224. expect(invocation.args).toContain('--trust-lockfile')
  225. expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`)
  226. expect(invocation.args).toContain('--config.enable-global-virtual-store=false')
  227. expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/')
  228. expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/')
  229. expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store)
  230. expect(invocation.env.NPM_CONFIG_USERCONFIG).toBe(join(paths.pnpm.config, 'npmrc'))
  231. expect(invocation.env.npm_config_registry).toBeUndefined()
  232. })
  233. it('restores the active project when the replacement backend cannot start', async () => {
  234. const root = temporaryRoot()
  235. const seed = join(root, 'seed')
  236. createTestSeedMetadata(seed, release())
  237. writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  238. archiveStore(seed)
  239. writeIntegrity(seed)
  240. const paths = resolveDesktopPaths(join(root, '.dsh'))
  241. const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
  242. await manager.applyRelease(seed, '1.0.0', hooks())
  243. let starts = 0
  244. await expect(manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks({
  245. afterActivate: async () => {
  246. starts += 1
  247. if (starts === 1) throw new Error('backend rejected staged graph')
  248. },
  249. }))).rejects.toThrow(/backend rejected staged graph/u)
  250. expect(manager.listPlugins()).toEqual([])
  251. expect(manager.dshVersion()).toBe('1.0.0')
  252. expect(starts).toBe(2)
  253. })
  254. it('restores rollback when the active move completed before its journal update', async () => {
  255. const root = temporaryRoot()
  256. const seed = join(root, 'seed')
  257. createTestSeedMetadata(seed, release())
  258. writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  259. archiveStore(seed)
  260. writeIntegrity(seed)
  261. const paths = resolveDesktopPaths(join(root, '.dsh'))
  262. const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
  263. await manager.applyRelease(seed, '1.0.0', hooks())
  264. await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
  265. const stagingProfile = join(paths.staging, 'interrupted', 'profile')
  266. mkdirSync(stagingProfile, { recursive: true })
  267. writeFileSync(join(stagingProfile, 'marker'), 'staging')
  268. rmSync(paths.rollback, { recursive: true, force: true })
  269. mkdirSync(dirname(paths.rollback), { recursive: true })
  270. renameSync(paths.profile, paths.rollback)
  271. writeFileSync(paths.pending, `${JSON.stringify({
  272. schemaVersion: 1,
  273. id: 'interrupted',
  274. stagingProfile,
  275. step: 'prepared',
  276. })}\n`)
  277. manager.recover()
  278. expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }])
  279. expect(existsSync(stagingProfile)).toBe(false)
  280. expect(existsSync(paths.pending)).toBe(false)
  281. })
  282. it('records the live pnpm worker as transaction owner until it exits', async ({ task, signal }) => {
  283. const root = temporaryRoot()
  284. const seed = join(root, 'seed')
  285. const ready = join(root, 'pnpm-ready')
  286. const releaseWorker = join(root, 'pnpm-release')
  287. createTestSeedMetadata(seed, release())
  288. writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  289. archiveStore(seed)
  290. writeIntegrity(seed)
  291. const paths = resolveDesktopPaths(join(root, '.dsh'))
  292. const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) }
  293. const manager = new DesktopProjectManager(paths, runtime)
  294. const installing = manager.applyRelease(seed, '1.0.0', hooks())
  295. // Teardown observes failures even if the runner has abandoned the test body.
  296. const completed = installing.then(value => ({ value }), (error: unknown) => ({ error }))
  297. releaseWorkers.push(async () => {
  298. writeFileSync(releaseWorker, 'continue')
  299. const outcome = await completed
  300. if ('error' in outcome) throw outcome.error
  301. })
  302. // Child startup shares the test budget; an aborted poll must not resume ownership assertions.
  303. await expect.poll(() => {
  304. signal.throwIfAborted()
  305. return existsSync(ready)
  306. }, { timeout: task.timeout }).toBe(true)
  307. signal.throwIfAborted()
  308. const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10)
  309. expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`)
  310. const competing = new DesktopProjectManager(paths, runtime)
  311. await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u)
  312. writeFileSync(releaseWorker, 'continue')
  313. await expect(installing).resolves.toBe(true)
  314. expect(existsSync(paths.lock)).toBe(false)
  315. })
  316. it('keeps core packages local while installing plugins from the desktop registry', async () => {
  317. const root = temporaryRoot()
  318. const seed = join(root, 'seed')
  319. const log = join(root, 'pnpm-log.json')
  320. createTestSeedMetadata(seed, release())
  321. writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  322. archiveStore(seed)
  323. writeIntegrity(seed)
  324. const paths = resolveDesktopPaths(join(root, '.dsh'))
  325. const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
  326. await manager.applyRelease(seed, '1.0.0', hooks())
  327. const previousLog = process.env.TEST_PNPM_LOG
  328. process.env.TEST_PNPM_LOG = log
  329. try {
  330. await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
  331. } finally {
  332. if (previousLog === undefined) delete process.env.TEST_PNPM_LOG
  333. else process.env.TEST_PNPM_LOG = previousLog
  334. }
  335. const manifest = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as {
  336. dependencies: Record<string, string>
  337. }
  338. const coreSpec = manifest.dependencies['@deepseek-ai/dsh']
  339. expect(coreSpec).toMatch(/^file:\.\/desktop-packages\//u)
  340. expect(readFileSync(join(paths.profile, 'pnpm-workspace.yaml'), 'utf8'))
  341. .toContain(`${JSON.stringify('@deepseek-ai/dsh')}: ${JSON.stringify(coreSpec)}`)
  342. expect(manifest.dependencies['@scope/plugin']).toBe('2.0.0')
  343. const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record<string, string> }
  344. expect(invocation.args).toContain('add')
  345. expect(invocation.args).toContain('@scope/plugin@2.0.0')
  346. expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/')
  347. expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/')
  348. })
  349. it('reconciles dsh to the packaged release without removing desktop plugins', async () => {
  350. const root = temporaryRoot()
  351. const paths = resolveDesktopPaths(join(root, '.dsh'))
  352. const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
  353. const firstSeed = join(root, 'seed-1')
  354. createTestSeedMetadata(firstSeed, release('1.0.0'))
  355. writeFileSync(join(firstSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  356. mkdirSync(join(firstSeed, 'store'), { recursive: true })
  357. writeFileSync(join(firstSeed, 'store', 'release-1'), 'one')
  358. archiveStore(firstSeed)
  359. writeIntegrity(firstSeed)
  360. await manager.applyRelease(firstSeed, '1.0.0', hooks())
  361. await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
  362. const nextSeed = join(root, 'seed-2')
  363. createTestSeedMetadata(nextSeed, release('1.1.0'))
  364. writeFileSync(join(nextSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
  365. mkdirSync(join(nextSeed, 'store'), { recursive: true })
  366. writeFileSync(join(nextSeed, 'store', 'release-2'), 'two')
  367. archiveStore(nextSeed)
  368. writeIntegrity(nextSeed)
  369. await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(true)
  370. expect(manager.releaseVersion()).toBe('1.1.0')
  371. expect(manager.dshVersion()).toBe('1.1.0')
  372. expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }])
  373. const profile = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as {
  374. dsh: { profile: { bundles: string[] } }
  375. }
  376. expect(profile.dsh.profile.bundles).toEqual([
  377. '@deepseek-ai/dsh-base',
  378. '@deepseek-ai/dsh-web-app',
  379. '@scope/plugin',
  380. ])
  381. expect(readFileSync(join(paths.pnpm.store, 'release-1'), 'utf8')).toBe('one')
  382. expect(readFileSync(join(paths.pnpm.store, 'release-2'), 'utf8')).toBe('two')
  383. await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(false)
  384. })
  385. })