operations.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. /** The CLI and manager share package reconciliation, path anchoring and diagnostics. */
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { join, resolve } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { PassThrough } from 'node:stream'
  6. import { expect, it, onTestFinished, vi } from 'vitest'
  7. import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
  8. import { anchorPathSpec, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts'
  9. const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType<typeof result>>() }))
  10. vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
  11. function fixture() {
  12. const home = mkdtempSync(join(tmpdir(), 'manager-pnpm-'))
  13. onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
  14. const dir = join(home, 'profiles', 'test')
  15. const installAnchor = join(home, 'package.json')
  16. writeFileSync(installAnchor, '{}\n')
  17. initProfile(dir, [])
  18. return { home, dir, context: { home, profile: 'test', installAnchor, cwd: home } }
  19. }
  20. function result(
  21. exitCode: number | undefined, output: string, mutate: () => void = () => {},
  22. details: { code?: string; shortMessage?: string } = {},
  23. ) {
  24. const stdout = new PassThrough()
  25. const stderr = new PassThrough()
  26. const done = Promise.resolve().then(() => {
  27. mutate()
  28. stdout.end(output)
  29. stderr.end()
  30. return { exitCode, failed: exitCode !== 0, ...details }
  31. })
  32. return Object.assign(done, { stdout, stderr })
  33. }
  34. function install(dir: string, name: string) {
  35. const path = join(dir, 'node_modules', name)
  36. mkdirSync(path, { recursive: true })
  37. writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
  38. writeFileSync(join(path, 'cordis.patch.yml'), '[]\n')
  39. const manifest = readProfileManifest('test', dir)
  40. manifest.dependencies = { ...manifest.dependencies, [name]: '1' }
  41. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  42. }
  43. it('anchors relative package specs without rewriting registry specs', () => {
  44. expect(anchorPathSpec('.', '/workspace')).toBe(resolve('/workspace'))
  45. expect(anchorPathSpec('file:../plugin', '/workspace/project')).toBe(`file:${resolve('/workspace/plugin')}`)
  46. expect(anchorPathSpec('package@1', '/workspace')).toBe('package@1')
  47. })
  48. it('activates newly installed bundles and leaves retained disabled dependencies disabled', async () => {
  49. const { dir, context } = fixture()
  50. install(dir, 'disabled')
  51. command.run.mockImplementationOnce(() => result(0, 'installed', () =>{ install(dir, 'new-bundle') }))
  52. expect(await runPluginCommand(context, ['add', 'new-bundle'], { execution: 'service', outputBytes: 100 })).toMatchObject({ exitCode: 0 })
  53. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new-bundle'])
  54. command.run.mockImplementationOnce(() => result(0, 'updated'))
  55. await runPluginCommand(context, ['update'], { execution: 'service', outputBytes: 100 })
  56. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new-bundle'])
  57. })
  58. it('can install without activation and bounds output while retaining the complete log', async () => {
  59. const { dir, context } = fixture()
  60. command.run.mockImplementationOnce(() => result(0, '0123456789', () =>{ install(dir, 'extra') }))
  61. const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 4, activateNewBundles: false })
  62. expect(outcome).toMatchObject({ exitCode: 0, output: '6789', truncated: true })
  63. expect(readFileSync(outcome.logPath, 'utf8')).toBe('0123456789')
  64. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
  65. expect(command.run.mock.calls[0]?.[1]).toEqual(['add', join(context.cwd, 'extra')])
  66. })
  67. it.each([runPluginCommand, runProfilePnpm])('installs into the supplied application profile directory with %s', async (run) => {
  68. const { home, dir: namedDir, context } = fixture()
  69. const dir = join(home, 'application', 'profile')
  70. initProfile(dir, [])
  71. command.run.mockImplementationOnce(() => result(0, 'installed', () => { install(dir, 'extra') }))
  72. const outcome = await run({ ...context, dir }, ['add', 'extra'], { execution: 'service', outputBytes: 100 })
  73. expect(outcome.exitCode).toBe(0)
  74. expect(command.run.mock.calls[0]?.[2]).toMatchObject({ cwd: dir })
  75. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['extra'])
  76. expect(readProfileManifest('test', namedDir).dependencies).not.toHaveProperty('extra')
  77. expect(readProfileManifest('test', namedDir).dsh?.profile?.bundles).toEqual([])
  78. })
  79. it('retains partial package-manager changes after failure without activating them', async () => {
  80. const { dir, context } = fixture()
  81. command.run.mockImplementationOnce(() => result(1, 'installation failed', () =>{ install(dir, 'partial') }))
  82. expect(await runProfilePnpm(context, ['add', 'partial'], { execution: 'service', outputBytes: 100 })).toMatchObject({ exitCode: 1 })
  83. expect(readProfileManifest('test', dir).dependencies).toEqual({ partial: '1' })
  84. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
  85. })
  86. it('initializes missing profiles under the same lock and reports initialization', async () => {
  87. const { home, context } = fixture()
  88. const messages: string[] = []
  89. command.run.mockImplementation(() => result(0, ''))
  90. for (const profile of ['custom', 'web']) {
  91. await runPluginCommand({ ...context, profile }, ['root'], {
  92. execution: 'service', outputBytes: 100, lockWaitMs: 1000, onOutput: (text) => { messages.push(text) },
  93. })
  94. expect(readProfileManifest('test', join(home, 'profiles', profile)).dsh?.profile?.bundles).toContain('@deepseek-ai/dsh-base')
  95. }
  96. expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2)
  97. })
  98. it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => {
  99. const { context, dir } = fixture()
  100. install(dir, 'removed')
  101. const manifest = readProfileManifest('test', dir)
  102. manifest.dsh = { profile: { bundles: ['builtin', 'removed'] } }
  103. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  104. const messages: string[] = []
  105. command.run.mockImplementationOnce(() => result(0, '', () => {
  106. install(dir, 'plain')
  107. writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
  108. const after = readProfileManifest('test', dir)
  109. delete after.dependencies?.removed
  110. writeFileSync(join(dir, 'package.json'), JSON.stringify(after))
  111. }))
  112. await runPluginCommand(context, ['remove', 'removed'], { execution: 'service', outputBytes: 100, onOutput: (text) => { messages.push(text) } })
  113. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['builtin'])
  114. expect(messages.join('')).toContain('plain dependency')
  115. })
  116. it('preserves a package-manager selected new bundle without adding it twice', async () => {
  117. const { context, dir } = fixture()
  118. writeFileSync(join(dir, 'package.json'), '{}')
  119. command.run.mockImplementationOnce(() => result(0, '', () => {
  120. install(dir, 'new')
  121. const manifest = readProfileManifest('test', dir)
  122. manifest.dsh = { profile: { bundles: ['new'] } }
  123. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  124. }))
  125. await runProfilePnpm(context, ['add', 'new'], { execution: 'service', outputBytes: 100 })
  126. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new'])
  127. })
  128. it.each([
  129. { code: 'ENOENT', shortMessage: 'pnpm not found', expected: 127 },
  130. { code: 'EACCES', shortMessage: undefined, expected: 1 },
  131. ])('reports launch failures with a complete log: $code', async ({ code, shortMessage, expected }) => {
  132. const { context } = fixture()
  133. command.run.mockImplementationOnce(() => result(undefined, '', () => {}, { code, ...shortMessage === undefined ? {} : { shortMessage } }))
  134. const outcome = await runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 4, signal: new AbortController().signal })
  135. expect(outcome.exitCode).toBe(expected)
  136. expect(outcome.truncated).toBe(true)
  137. expect(readFileSync(outcome.logPath, 'utf8')).toBe(shortMessage ?? 'pnpm failed')
  138. })
  139. it('cancels and settles package output when the output consumer fails', async () => {
  140. const { context } = fixture()
  141. let cancellation: AbortSignal | undefined
  142. command.run.mockImplementationOnce((_name, _args, options) => {
  143. cancellation = (options as { cancelSignal: AbortSignal }).cancelSignal
  144. const child = result(0, 'text')
  145. child.stdout.setEncoding('utf8')
  146. return child
  147. })
  148. await expect(runProfilePnpm(context, ['root'], {
  149. execution: 'service', outputBytes: 100, onOutput() { throw new Error('output destination closed') },
  150. })).rejects.toThrow('output destination closed')
  151. expect(cancellation?.aborted).toBe(true)
  152. })
  153. it('preserves an unexpected subprocess rejection after both streams settle', async () => {
  154. const { context } = fixture()
  155. const stdout = new PassThrough()
  156. const stderr = new PassThrough()
  157. stdout.end()
  158. stderr.end()
  159. command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { stdout, stderr }))
  160. await expect(runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 100 })).rejects.toThrow('subprocess failed')
  161. })
  162. it('handles manifests without dependency or bundle selections', async () => {
  163. const { context, dir } = fixture()
  164. command.run.mockImplementationOnce(() => result(0, '', () => { writeFileSync(join(dir, 'package.json'), '{}') }))
  165. expect(await runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 100 })).toMatchObject({ exitCode: 0 })
  166. })
  167. it.each(['cli', 'service'] as const)('uses the %s environment and interaction policy', async (execution) => {
  168. const { context } = fixture()
  169. const names = ['NPM_TOKEN', 'NODE_AUTH_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'DEEPSEEK_API_KEY']
  170. const originals = names.map(name => process.env[name])
  171. onTestFinished(() => {
  172. names.forEach((name, index) => {
  173. const original = originals[index]
  174. if (original === undefined) Reflect.deleteProperty(process.env, name)
  175. else process.env[name] = original
  176. })
  177. })
  178. for (const name of names) process.env[name] = 'fixture-credential'
  179. command.run.mockImplementationOnce(() => result(0, ''))
  180. await runPluginCommand(context, ['approve-builds'], { execution, outputBytes: 100 })
  181. const options = command.run.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv; stdin: string; stdout: string; stderr: string }
  182. for (const name of names) expect(options.env[name]).toBe(execution === 'cli' ? 'fixture-credential' : undefined)
  183. expect(options.stdin).toBe(execution === 'cli' ? 'inherit' : 'ignore')
  184. expect(options.stdout).toBe(execution === 'cli' ? 'inherit' : 'pipe')
  185. expect(options.stderr).toBe(execution === 'cli' ? 'inherit' : 'pipe')
  186. })
  187. it('settles inherited CLI descriptors without requiring captured streams', async () => {
  188. const { context } = fixture()
  189. command.run.mockImplementationOnce(() => Object.assign(
  190. Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null },
  191. ) as unknown as ReturnType<typeof result>)
  192. expect(await runPluginCommand(context, ['approve-builds'], { execution: 'cli', outputBytes: 100 })).toMatchObject({ exitCode: 0, output: '' })
  193. })
  194. it('asks the registry through pnpm view in the profile directory and reports how the lookup ended', async () => {
  195. const { dir } = fixture()
  196. const answer = (value: object) => command.run.mockResolvedValueOnce(value as never)
  197. answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false })
  198. expect(await viewProfilePackage(dir, 'x@^1', { timeoutMs: 5 })).toEqual({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false })
  199. expect(command.run).toHaveBeenLastCalledWith('pnpm', ['view', 'x@^1', 'name', 'version', 'description', 'dsh', '--json'], expect.objectContaining({
  200. cwd: dir, timeout: 5, reject: false, stdin: 'ignore',
  201. }))
  202. expect((command.run.mock.lastCall as unknown[])[2]).not.toHaveProperty('cancelSignal')
  203. const signal = AbortSignal.abort()
  204. answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: true, isCanceled: false })
  205. expect(await viewProfilePackage(dir, 'x', { timeoutMs: 5, signal })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: true })
  206. expect((command.run.mock.lastCall as unknown[])[2]).toMatchObject({ cancelSignal: signal })
  207. answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: true })
  208. expect(await viewProfilePackage(dir, 'x', { command: 'node', timeoutMs: 5 })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: false })
  209. expect((command.run.mock.lastCall as unknown[])[0]).toBe('node')
  210. answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: false, code: 'ENOENT', shortMessage: 'spawn pnpm ENOENT' })
  211. const missing = await viewProfilePackage(dir, 'x', { timeoutMs: 5 })
  212. expect(missing).toMatchObject({ exitCode: null, timedOut: false })
  213. expect(missing.cause).toMatchObject({ message: 'spawn pnpm ENOENT', code: 'ENOENT' })
  214. })
  215. it('uses application-owned executable arguments and environment for package operations and inspection', async () => {
  216. const { dir, context } = fixture()
  217. const runtime = { command: '/app/electron', args: ['--expose-internals', '/app/pnpm.mjs'], env: { ELECTRON_RUN_AS_NODE: '1', PATH: '/app/bin' } }
  218. command.run.mockImplementationOnce(() => result(0, ''))
  219. await runProfilePnpm(context, ['add', './extra'], { ...runtime, execution: 'service', outputBytes: 100, activateNewBundles: false })
  220. expect(command.run).toHaveBeenLastCalledWith(runtime.command, [...runtime.args, 'add', resolve(context.cwd, 'extra')],
  221. expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown }))
  222. command.run.mockResolvedValueOnce(Object.assign({ exitCode: 0, failed: false }, { stdout: '{}', stderr: '', timedOut: false }))
  223. await viewProfilePackage(dir, 'example', { ...runtime, timeoutMs: 1000 })
  224. expect(command.run).toHaveBeenLastCalledWith(runtime.command,
  225. [...runtime.args, 'view', 'example', 'name', 'version', 'description', 'dsh', '--json'],
  226. expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown }))
  227. })