operations.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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 } 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 } 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('/workspace')
  45. expect(anchorPathSpec('file:../plugin', '/workspace/project')).toBe('file:/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'], { 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'], { 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'], { 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('retains partial package-manager changes after failure without activating them', async () => {
  68. const { dir, context } = fixture()
  69. command.run.mockImplementationOnce(() => result(1, 'installation failed', () =>{ install(dir, 'partial') }))
  70. expect(await runProfilePnpm(context, ['add', 'partial'], { outputBytes: 100 })).toMatchObject({ exitCode: 1 })
  71. expect(readProfileManifest('test', dir).dependencies).toEqual({ partial: '1' })
  72. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
  73. })
  74. it('initializes missing profiles under the same lock and reports initialization', async () => {
  75. const { home, context } = fixture()
  76. const messages: string[] = []
  77. command.run.mockImplementation(() => result(0, ''))
  78. for (const profile of ['custom', 'web']) {
  79. await runPluginCommand({ ...context, profile }, ['root'], {
  80. outputBytes: 100, lockWaitMs: 1000, onOutput: (text) => { messages.push(text) },
  81. })
  82. expect(readProfileManifest('test', join(home, 'profiles', profile)).dsh?.profile?.bundles).toContain('@deepseek-ai/dsh-base')
  83. }
  84. expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2)
  85. })
  86. it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => {
  87. const { context, dir } = fixture()
  88. install(dir, 'removed')
  89. const manifest = readProfileManifest('test', dir)
  90. manifest.dsh = { profile: { bundles: ['builtin', 'removed'] } }
  91. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  92. const messages: string[] = []
  93. command.run.mockImplementationOnce(() => result(0, '', () => {
  94. install(dir, 'plain')
  95. writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
  96. const after = readProfileManifest('test', dir)
  97. delete after.dependencies?.removed
  98. writeFileSync(join(dir, 'package.json'), JSON.stringify(after))
  99. }))
  100. await runPluginCommand(context, ['remove', 'removed'], { outputBytes: 100, onOutput: (text) => { messages.push(text) } })
  101. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['builtin'])
  102. expect(messages.join('')).toContain('plain dependency')
  103. })
  104. it('preserves a package-manager selected new bundle without adding it twice', async () => {
  105. const { context, dir } = fixture()
  106. writeFileSync(join(dir, 'package.json'), '{}')
  107. command.run.mockImplementationOnce(() => result(0, '', () => {
  108. install(dir, 'new')
  109. const manifest = readProfileManifest('test', dir)
  110. manifest.dsh = { profile: { bundles: ['new'] } }
  111. writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
  112. }))
  113. await runProfilePnpm(context, ['add', 'new'], { outputBytes: 100 })
  114. expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['new'])
  115. })
  116. it.each([
  117. { code: 'ENOENT', shortMessage: 'pnpm not found', expected: 127 },
  118. { code: 'EACCES', shortMessage: undefined, expected: 1 },
  119. ])('reports launch failures with a complete log: $code', async ({ code, shortMessage, expected }) => {
  120. const { context } = fixture()
  121. command.run.mockImplementationOnce(() => result(undefined, '', () => {}, { code, ...shortMessage === undefined ? {} : { shortMessage } }))
  122. const outcome = await runProfilePnpm(context, ['root'], { outputBytes: 4, signal: new AbortController().signal })
  123. expect(outcome.exitCode).toBe(expected)
  124. expect(outcome.truncated).toBe(true)
  125. expect(readFileSync(outcome.logPath, 'utf8')).toBe(shortMessage ?? 'pnpm failed')
  126. })
  127. it('cancels and settles package output when the output consumer fails', async () => {
  128. const { context } = fixture()
  129. let cancellation: AbortSignal | undefined
  130. command.run.mockImplementationOnce((_name, _args, options) => {
  131. cancellation = (options as { cancelSignal: AbortSignal }).cancelSignal
  132. const child = result(0, 'text')
  133. child.stdout.setEncoding('utf8')
  134. return child
  135. })
  136. await expect(runProfilePnpm(context, ['root'], {
  137. outputBytes: 100, onOutput() { throw new Error('output destination closed') },
  138. })).rejects.toThrow('output destination closed')
  139. expect(cancellation?.aborted).toBe(true)
  140. })
  141. it('preserves an unexpected subprocess rejection after both streams settle', async () => {
  142. const { context } = fixture()
  143. const stdout = new PassThrough()
  144. const stderr = new PassThrough()
  145. stdout.end()
  146. stderr.end()
  147. command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { stdout, stderr }))
  148. await expect(runProfilePnpm(context, ['root'], { outputBytes: 100 })).rejects.toThrow('subprocess failed')
  149. })
  150. it('handles manifests without dependency or bundle selections', async () => {
  151. const { context, dir } = fixture()
  152. command.run.mockImplementationOnce(() => result(0, '', () => { writeFileSync(join(dir, 'package.json'), '{}') }))
  153. expect(await runProfilePnpm(context, ['root'], { outputBytes: 100 })).toMatchObject({ exitCode: 0 })
  154. })