1
0

project-manager.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { resolveDesktopPaths } from '../src/paths.ts'
  7. import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts'
  8. import { runtimeFixture } from './runtime-fixture.ts'
  9. const roots: string[] = []
  10. const releaseWorkers: Array<() => Promise<void>> = []
  11. function temporaryRoot(): string {
  12. const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
  13. roots.push(root)
  14. return root
  15. }
  16. function writeFakePnpm(root: string): string {
  17. const path = join(root, 'pnpm.mjs')
  18. writeFileSync(path, `
  19. import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  20. import { join } from 'node:path'
  21. const args = process.argv.slice(2)
  22. const project = process.cwd()
  23. const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value))
  24. appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY}) + '\\n')
  25. if (command !== 'rebuild') {
  26. const manifestPath = join(project, 'package.json')
  27. const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
  28. if (command === 'add') {
  29. const spec = args[args.indexOf(command) + 1]
  30. const index = spec.lastIndexOf('@')
  31. const name = index > 0 ? spec.slice(0, index) : spec
  32. manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0'
  33. }
  34. if (command === 'remove') delete manifest.dependencies[args[args.indexOf(command) + 1]]
  35. writeFileSync(manifestPath, JSON.stringify(manifest))
  36. rmSync(join(project, 'node_modules'), { recursive: true, force: true })
  37. for (const [name, version] of Object.entries(manifest.dependencies)) {
  38. const packageRoot = join(project, 'node_modules', name)
  39. mkdirSync(packageRoot, { recursive: true })
  40. writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version,
  41. peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}}))
  42. writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n')
  43. }
  44. writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies))
  45. }
  46. `)
  47. return path
  48. }
  49. function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
  50. return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides }
  51. }
  52. function setup(): { root: string; manager: DesktopProjectManager } {
  53. const root = temporaryRoot()
  54. const dsh = join(root, 'resources', 'dsh')
  55. runtimeFixture(dsh)
  56. return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) }
  57. }
  58. function calls(root: string): { args: string[]; registry: string }[] {
  59. const path = join(root, 'pnpm-log.jsonl')
  60. return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : []
  61. }
  62. afterEach(async () => {
  63. const cleanups = releaseWorkers.splice(0)
  64. const directories = roots.splice(0)
  65. const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()))
  66. for (const root of directories) rmSync(root, { recursive: true, force: true })
  67. const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : [])
  68. if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed')
  69. })
  70. describe('desktop external plugin profile', () => {
  71. it('reuses plugin files without scanning manifests and can disable or reset them', async () => {
  72. const { manager } = setup()
  73. await manager.applyRelease()
  74. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  75. const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
  76. writeFileSync(manifest, '{broken')
  77. await expect(manager.applyRelease()).resolves.toBe(false)
  78. await manager.mutate({ type: 'plugins-disable-all' }, hooks())
  79. await expect(manager.applyRelease()).resolves.toBe(false)
  80. expect(readFileSync(manifest, 'utf8')).toBe('{broken')
  81. await manager.resetConfiguration(hooks())
  82. expect(existsSync(manifest)).toBe(false)
  83. await expect(manager.applyRelease()).resolves.toBe(false)
  84. })
  85. it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
  86. const { root, manager } = setup()
  87. await manager.applyRelease()
  88. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  89. const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
  90. unlinkSync(patch)
  91. await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => {
  92. expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
  93. dsh: { profile: { bundles: string[] } }
  94. }).dsh.profile.bundles).not.toContain('plugin')
  95. } }))
  96. expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
  97. dsh: { profile: { bundles: string[] } }
  98. }).dsh.profile.bundles).not.toContain('plugin')
  99. expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
  100. expect(calls(root)).toHaveLength(2)
  101. await expect(manager.applyRelease()).resolves.toBe(false)
  102. })
  103. it('resets the entire profile without backups while retaining its lock and shared data', async () => {
  104. const { root, manager } = setup()
  105. await manager.applyRelease()
  106. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  107. const profile = manager.paths.profile
  108. expect(manager.paths.lock).toBe(join(profile, 'lock'))
  109. const task = join(root, '.dsh', 'task-sentinel')
  110. const homeEnvironment = join(root, '.dsh', '.env')
  111. writeFileSync(homeEnvironment, 'HOME_SETTING=retained')
  112. writeFileSync(task, 'retained task')
  113. writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken')
  114. writeFileSync(join(profile, 'cordis.patch.yml'), ': broken')
  115. writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad')
  116. mkdirSync(join(profile, '.extra'))
  117. writeFileSync(join(profile, '.extra', 'custom-file'), 'remove')
  118. const shared = join(root, 'shared-data')
  119. mkdirSync(shared)
  120. writeFileSync(join(shared, 'sentinel'), 'preserve')
  121. symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir')
  122. await expect(manager.applyRelease()).rejects.toThrow()
  123. await manager.resetConfiguration(hooks({
  124. beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') },
  125. afterChange: async () => {
  126. manager.assertProfileRuntime(profile)
  127. expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid))
  128. await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active')
  129. },
  130. }))
  131. expect(manager.listPlugins()).toEqual([])
  132. expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false)
  133. expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false)
  134. expect(existsSync(join(profile, '.env'))).toBe(false)
  135. expect(existsSync(join(profile, '.extra'))).toBe(false)
  136. expect(existsSync(join(profile, 'external-link'))).toBe(false)
  137. expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve')
  138. expect(readFileSync(task, 'utf8')).toBe('retained task')
  139. expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained')
  140. expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false)
  141. expect(calls(root)).toHaveLength(2)
  142. await expect(manager.applyRelease()).resolves.toBe(false)
  143. expect(existsSync(homeEnvironment)).toBe(true)
  144. })
  145. it('reports damaged application metadata as a reinstall failure', async () => {
  146. const { manager } = setup()
  147. writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
  148. await expect(manager.applyRelease()).rejects.toThrow()
  149. expect(manager.canRecoverProfile()).toBe(false)
  150. })
  151. it('accepts registry names and tags but rejects alternate sources and flags', () => {
  152. expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin')
  153. expect(packageNameFromSpec('plugin@next')).toBe('plugin')
  154. for (const spec of ['file:../plugin', '--registry=evil', 'https://example.test/plugin.tgz']) {
  155. expect(() => packageNameFromSpec(spec)).toThrow(/unsupported npm package spec/u)
  156. }
  157. })
  158. it('retries installation after an interrupted runtime rebuild removed plugin files', async () => {
  159. const { root, manager } = setup()
  160. await manager.applyRelease()
  161. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  162. const dsh = join(root, 'new-node')
  163. runtimeFixture(dsh, '1.1.0', '24.18.0')
  164. const failing = join(root, 'fail-install.mjs')
  165. writeFileSync(failing, 'process.exitCode = 1')
  166. const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing })
  167. await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1')
  168. expect(existsSync(join(manager.paths.profile, 'node_modules/plugin'))).toBe(false)
  169. const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
  170. await expect(retry.applyRelease()).resolves.toBe(true)
  171. expect(retry.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
  172. await expect(retry.applyRelease()).resolves.toBe(false)
  173. })
  174. it('preserves unknown files when initializing a profile', async () => {
  175. const { manager } = setup()
  176. mkdirSync(manager.paths.profile, { recursive: true })
  177. writeFileSync(join(manager.paths.profile, '.DS_Store'), 'metadata')
  178. writeFileSync(join(manager.paths.profile, 'user-file'), 'retain')
  179. await expect(manager.applyRelease()).resolves.toBe(true)
  180. expect(readFileSync(join(manager.paths.profile, '.DS_Store'), 'utf8')).toBe('metadata')
  181. expect(readFileSync(join(manager.paths.profile, 'user-file'), 'utf8')).toBe('retain')
  182. })
  183. it.each(['plugin-add', 'runtime-change'] as const)('retries failed rebuild after %s across manager instances', async (operation) => {
  184. const { root, manager } = setup()
  185. await manager.applyRelease()
  186. let dsh = manager.runtime.dsh
  187. if (operation === 'runtime-change') {
  188. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  189. dsh = join(root, 'new-node')
  190. runtimeFixture(dsh, '1.1.0', '24.18.0')
  191. }
  192. const failing = join(root, 'fail-rebuild.mjs')
  193. writeFileSync(failing, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); if (process.argv.includes('rebuild')) process.exitCode = 1`)
  194. const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing })
  195. if (operation === 'plugin-add') {
  196. await worker.applyRelease()
  197. await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())).rejects.toThrow('pnpm exited with 1')
  198. } else await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1')
  199. expect(() => { worker.assertProfileRuntime(worker.paths.profile) }).toThrow('package preparation is incomplete')
  200. const count = calls(root).length
  201. const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
  202. await expect(retry.applyRelease()).resolves.toBe(true)
  203. expect(calls(root).slice(count).map(call => call.args.find(arg => !arg.startsWith('--config.')))).toEqual(['install', 'rebuild'])
  204. await expect(retry.applyRelease()).resolves.toBe(false)
  205. expect(calls(root)).toHaveLength(count + 2)
  206. })
  207. it('initializes and restarts offline without executing pnpm', async () => {
  208. const { root, manager } = setup()
  209. await expect(manager.applyRelease()).resolves.toBe(true)
  210. await expect(manager.applyRelease()).resolves.toBe(false)
  211. expect(manager.listPlugins()).toEqual([])
  212. expect(calls(root)).toEqual([])
  213. expect(existsSync(manager.paths.pnpm.store)).toBe(false)
  214. expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
  215. expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
  216. })
  217. it('repairs a removed managed link without running pnpm', async () => {
  218. const { root, manager } = setup()
  219. await manager.applyRelease()
  220. unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))
  221. await expect(manager.applyRelease()).resolves.toBe(true)
  222. expect(calls(root)).toEqual([])
  223. })
  224. it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => {
  225. const { manager } = setup()
  226. await manager.applyRelease()
  227. const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() })
  228. await expect(relaunched.applyRelease()).resolves.toBe(false)
  229. })
  230. it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
  231. const { root, manager } = setup()
  232. if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}')
  233. if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
  234. if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
  235. if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json'))
  236. await expect(manager.applyRelease()).resolves.toBe(true)
  237. const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
  238. await expect(relaunched.applyRelease()).resolves.toBe(false)
  239. expect(existsSync(manager.paths.profile)).toBe(true)
  240. expect(calls(root)).toEqual([])
  241. })
  242. it('installs only plugins and checks the graph before running lifecycle scripts', async () => {
  243. const { root, manager } = setup()
  244. await manager.applyRelease()
  245. await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
  246. expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }])
  247. expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
  248. ['add', '@scope/plugin@2.0.0', '--save-exact', '--ignore-scripts'], ['rebuild', '--pending'],
  249. ])
  250. expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true)
  251. expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
  252. await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u)
  253. await expect(manager.applyRelease()).resolves.toBe(false)
  254. expect(calls(root)).toHaveLength(2)
  255. })
  256. it('retains disabled plugin versions through updates and enables them explicitly', async () => {
  257. const { root, manager } = setup()
  258. await manager.applyRelease()
  259. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  260. await manager.mutate({ type: 'plugins-disable-all' }, hooks())
  261. expect(calls(root)).toHaveLength(2)
  262. expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
  263. await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks())
  264. expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }])
  265. await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks())
  266. expect(manager.listPlugins()[0]?.enabled).toBe(true)
  267. await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
  268. expect(manager.listPlugins()).toEqual([])
  269. })
  270. it('keeps plugin files and patches through a compatible release and application relocation', async () => {
  271. const { root, manager } = setup()
  272. await manager.applyRelease()
  273. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  274. writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n')
  275. const nextRoot = join(root, 'relocated', 'dsh')
  276. runtimeFixture(nextRoot, '1.1.0')
  277. const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot })
  278. await expect(next.applyRelease()).resolves.toBe(true)
  279. expect(next.listPlugins()).toEqual(manager.listPlugins())
  280. expect(next.releaseVersion()).toBe('1.1.0')
  281. expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
  282. expect(calls(root)).toHaveLength(2)
  283. expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis')))
  284. expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
  285. })
  286. it('reinstalls the locked plugin graph when bundled Node changes', async () => {
  287. const { root, manager } = setup()
  288. await manager.applyRelease()
  289. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  290. const dsh = join(root, 'new-node')
  291. runtimeFixture(dsh, '1.1.0', '24.18.0')
  292. const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
  293. await next.applyRelease()
  294. expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
  295. ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'],
  296. ])
  297. expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
  298. })
  299. it('allows incompatible plugins to be disabled in recovery without deleting them', async () => {
  300. const { root, manager } = setup()
  301. await manager.applyRelease()
  302. await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  303. const dsh = join(root, 'next-major')
  304. runtimeFixture(dsh, '2.0.0')
  305. const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
  306. await expect(next.applyRelease()).rejects.toThrow(/requires @deepseek-ai\/cordis/u)
  307. expect(next.releaseVersion()).toBe('2.0.0')
  308. await next.mutate({ type: 'plugins-disable-all' }, hooks())
  309. expect(next.releaseVersion()).toBe('2.0.0')
  310. expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
  311. })
  312. it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => {
  313. const { manager } = setup()
  314. await manager.applyRelease()
  315. let starts = 0
  316. await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
  317. beforeChange: async () => {
  318. expect(manager.listPlugins()).toEqual([])
  319. if (phase === 'before') throw new Error('before failed')
  320. },
  321. afterChange: async () => { starts++; throw new Error('after failed') },
  322. }))).rejects.toThrow(`${phase} failed`)
  323. expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }])
  324. expect(starts).toBe(phase === 'before' ? 0 : 1)
  325. expect(existsSync(join(manager.paths.root, 'staging'))).toBe(false)
  326. expect(existsSync(join(manager.paths.root, 'rollback'))).toBe(false)
  327. expect(existsSync(join(manager.paths.root, 'pending.json'))).toBe(false)
  328. })
  329. it('keeps partial package changes and restores host links after pnpm fails', async () => {
  330. const { root, manager } = setup()
  331. await manager.applyRelease()
  332. const failingPnpm = join(root, 'failing.mjs')
  333. writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`)
  334. const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
  335. await worker.applyRelease()
  336. let starts = 0
  337. await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
  338. afterChange: async () => { starts++ },
  339. }))).rejects.toThrow(/pnpm exited with 1/u)
  340. expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
  341. expect(starts).toBe(0)
  342. expect(existsSync(manager.paths.lock)).toBe(false)
  343. expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')))
  344. .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
  345. await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
  346. expect(manager.listPlugins()).toEqual([])
  347. })
  348. it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
  349. const { root, manager } = setup()
  350. await manager.applyRelease()
  351. const ready = join(root, 'ready')
  352. const release = join(root, 'release')
  353. const blocker = join(root, 'blocking.mjs')
  354. writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`)
  355. const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker })
  356. await worker.applyRelease()
  357. const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
  358. // Teardown observes failures even if the runner has abandoned the test body.
  359. const completed = pending.then(value => ({ value }), (error: unknown) => ({ error }))
  360. releaseWorkers.push(async () => {
  361. writeFileSync(release, 'continue')
  362. const outcome = await completed
  363. if ('error' in outcome) throw outcome.error
  364. })
  365. try {
  366. // Child startup shares the test budget; an aborted poll must not resume ownership assertions.
  367. await expect.poll(() => {
  368. signal.throwIfAborted()
  369. return existsSync(ready)
  370. }, { timeout: task.timeout }).toBe(true)
  371. signal.throwIfAborted()
  372. expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
  373. await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u)
  374. } finally {
  375. writeFileSync(release, 'continue')
  376. await pending
  377. }
  378. expect(existsSync(manager.paths.lock)).toBe(false)
  379. })
  380. })