plugin.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * `dsh plugin add` and `remove` through the shared installer: a temporary
  3. * harness home, a fake pnpm that stages packages and edits the manifest the
  4. * way the real one does, and a fake probe. Nothing boots.
  5. */
  6. import { EventEmitter } from 'node:events'
  7. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { PassThrough } from 'node:stream'
  11. import type { ChildProcess } from 'node:child_process'
  12. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  13. import { readProfileManifest, resolveProfileDir, type probePackage } from '@deepseek-ai/dsh-app-boot'
  14. import type { SpawnLike } from '@deepseek-ai/dsh-plugin-manager'
  15. import { runPlugin } from '../src/plugin.ts'
  16. let home: string
  17. let previousHome: string | undefined
  18. let stderr: string
  19. let stdout: string
  20. beforeEach(() => {
  21. home = mkdtempSync(join(tmpdir(), 'dsh-plugin-command-'))
  22. previousHome = process.env.DSH_HOME
  23. process.env.DSH_HOME = home
  24. stderr = ''
  25. stdout = ''
  26. vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += String(chunk); return true })
  27. vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); return true })
  28. })
  29. afterEach(() => {
  30. vi.restoreAllMocks()
  31. if (previousHome === undefined) delete process.env.DSH_HOME
  32. else process.env.DSH_HOME = previousHome
  33. rmSync(home, { recursive: true, force: true })
  34. })
  35. /** Stage one package under the profile's node_modules and record it as a dependency, as `pnpm add` does. */
  36. function install(profileDir: string, name: string, bundle: boolean): void {
  37. const dir = join(profileDir, 'node_modules', name)
  38. mkdirSync(dir, { recursive: true })
  39. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  40. name, version: '1.0.0', type: 'module',
  41. ...bundle ? { dsh: { bundle: { patch: './cordis.patch.yml' } } } : {},
  42. }))
  43. if (bundle) writeFileSync(join(dir, 'cordis.patch.yml'), '- insert:\n - id: hello\n name: cordis:good\n')
  44. const path = join(profileDir, 'package.json')
  45. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
  46. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, [name]: '1.0.0' } }, null, 2))
  47. }
  48. /** Forget one package, as `pnpm remove` does. */
  49. function uninstall(profileDir: string, name: string): void {
  50. const path = join(profileDir, 'package.json')
  51. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
  52. const { [name]: _gone, ...remaining } = manifest.dependencies ?? {}
  53. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
  54. rmSync(join(profileDir, 'node_modules', name), { recursive: true, force: true })
  55. }
  56. /** A pnpm that installs `ext-bundle` as a bundle and everything else as a plain library, or fails as told. */
  57. function fakePnpm(calls: string[][], failWith?: { code: number } | { error: NodeJS.ErrnoException }): SpawnLike {
  58. return (_command, args, options) => {
  59. calls.push([...args])
  60. const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough }
  61. child.stdout = new PassThrough()
  62. child.stderr = new PassThrough()
  63. setTimeout(() => {
  64. if (failWith !== undefined && 'error' in failWith) {
  65. child.emit('error', failWith.error)
  66. return
  67. }
  68. if (failWith !== undefined) {
  69. child.stderr.write('ERR_PNPM_FETCH\n')
  70. child.emit('close', failWith.code)
  71. return
  72. }
  73. const [verb, target] = args
  74. const profileDir = options.cwd as string
  75. if (verb === 'add' && target !== undefined) install(profileDir, target, target === 'ext-bundle')
  76. if (verb === 'remove' && target !== undefined) uninstall(profileDir, target)
  77. child.stdout.write(`${verb === 'add' ? '+' : '-'} ${String(target)}\n`)
  78. child.emit('close', 0)
  79. }, 5)
  80. return child as unknown as ChildProcess
  81. }
  82. }
  83. /** A probe that reads the staged manifest: a package with `dsh.bundle` is a bundle, anything else a library. */
  84. const fakeProbe: typeof probePackage = (options) => {
  85. const manifest = JSON.parse(readFileSync(join(options.profileDir, 'node_modules', options.packageName, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
  86. return Promise.resolve({
  87. packageName: options.packageName,
  88. kind: manifest.dsh?.bundle === undefined ? 'library' : 'bundle',
  89. ok: true,
  90. cordisSameCopy: null,
  91. rows: [],
  92. overrides: [],
  93. addable: [],
  94. checkedAt: new Date().toISOString(),
  95. })
  96. }
  97. describe('dsh plugin', () => {
  98. it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
  99. const calls: string[][] = []
  100. const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), probe: fakeProbe })
  101. expect(code).toBe(0)
  102. const profileDir = resolveProfileDir('web', home)
  103. expect(stderr).toContain(`dsh: initialized profile web at ${profileDir}`)
  104. expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib'], ['remove', 'ext-lib']])
  105. const manifest = readProfileManifest('dsh', profileDir)
  106. expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
  107. expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
  108. expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
  109. expect(stdout).toContain('+ ext-bundle')
  110. expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
  111. expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
  112. })
  113. it('remove goes through the installer and forgets the probe record', async () => {
  114. const calls: string[][] = []
  115. await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
  116. const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
  117. expect(code).toBe(0)
  118. expect(calls).toEqual([['add', 'ext-bundle'], ['remove', 'ext-bundle']])
  119. const profileDir = resolveProfileDir('web', home)
  120. const manifest = readProfileManifest('dsh', profileDir)
  121. expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-bundle')
  122. expect(manifest.dsh?.profile?.bundles).not.toContain('ext-bundle')
  123. expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
  124. })
  125. it('reports a failed pnpm run with its exit code, the git hint, and a missing pnpm as 127', async () => {
  126. const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), probe: fakeProbe })
  127. expect(failed).toBe(1)
  128. expect(stderr).toContain('dsh: pnpm failed in profile directory')
  129. expect(stderr).toContain('git-hosted plugins build on install via their prepare script')
  130. const error = Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' })
  131. const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), probe: fakeProbe })
  132. expect(missing).toBe(127)
  133. expect(stderr).toContain('dsh: pnpm not found on PATH')
  134. })
  135. })