plugin.spec.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  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 static metadata reader. Nothing boots.
  5. */
  6. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { PassThrough } from 'node:stream'
  10. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  11. import { readProfileManifest, resolveProfileDir, type readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
  12. import type { SpawnLike } from '@deepseek-ai/dsh-plugin-manager'
  13. import { runPlugin } from '../src/plugin.ts'
  14. let home: string
  15. let previousHome: string | undefined
  16. let stderr: string
  17. let stdout: string
  18. /** The FORCE_COLOR each fake pnpm run was spawned with. */
  19. let spawnEnvs: (string | undefined)[] = []
  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. spawnEnvs = []
  27. vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderr += String(chunk); return true })
  28. vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); return true })
  29. })
  30. afterEach(() => {
  31. vi.restoreAllMocks()
  32. if (previousHome === undefined) delete process.env.DSH_HOME
  33. else process.env.DSH_HOME = previousHome
  34. rmSync(home, { recursive: true, force: true })
  35. })
  36. /** Stage one package under the profile's node_modules and record it as a dependency, as `pnpm add` does. */
  37. function install(profileDir: string, name: string, bundle: boolean): void {
  38. const dir = join(profileDir, 'node_modules', name)
  39. mkdirSync(dir, { recursive: true })
  40. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  41. name, version: '1.0.0', type: 'module',
  42. ...bundle ? { dsh: { bundle: { patch: './cordis.patch.yml' } } } : {},
  43. }))
  44. if (bundle) writeFileSync(join(dir, 'cordis.patch.yml'), '- insert:\n - id: hello\n name: cordis:good\n')
  45. const path = join(profileDir, 'package.json')
  46. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
  47. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, [name]: '1.0.0' } }, null, 2))
  48. }
  49. /** Forget one package, as `pnpm remove` does. */
  50. function uninstall(profileDir: string, name: string): void {
  51. const path = join(profileDir, 'package.json')
  52. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies?: Record<string, string> }
  53. const { [name]: _gone, ...remaining } = manifest.dependencies ?? {}
  54. writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
  55. rmSync(join(profileDir, 'node_modules', name), { recursive: true, force: true })
  56. }
  57. /** A pnpm that installs `ext-bundle` as a bundle and everything else as a plain library, or fails as told. */
  58. function fakePnpm(calls: string[][], failWith?: { code: number } | { error: NodeJS.ErrnoException }): SpawnLike {
  59. return (spec) => {
  60. const [, ...args] = spec.argv
  61. calls.push([...args])
  62. spawnEnvs.push(spec.env?.FORCE_COLOR)
  63. const stdout = new PassThrough()
  64. const stderr = new PassThrough()
  65. const done = Promise.resolve().then(() => {
  66. try {
  67. if (failWith !== undefined && 'error' in failWith) throw failWith.error
  68. if (failWith !== undefined) {
  69. stderr.write('ERR_PNPM_FETCH\n')
  70. return { exitCode: failWith.code, signal: null }
  71. }
  72. const [verb, target] = args
  73. if (verb === 'add' && target !== undefined) install(spec.cwd, target, target === 'ext-bundle')
  74. if (verb === 'remove' && target !== undefined) uninstall(spec.cwd, target)
  75. stdout.write(`\u001b[32m${verb === 'add' ? '+' : '-'}\u001b[39m ${String(target)}\n`)
  76. return { exitCode: 0, signal: null }
  77. } finally { stdout.end(); stderr.end() }
  78. })
  79. return {
  80. stdin: undefined, stdout, stderr, control: undefined, collected: {}, done, terminate() {},
  81. waitForExit: () => done.then(() => true, () => true),
  82. }
  83. }
  84. }
  85. /** Static fixture declarations: a bundle or an unknown package. */
  86. const fakeMetadata: typeof readPackageMetadata = (options) => {
  87. const manifest = JSON.parse(readFileSync(join(options.profileDir, 'node_modules', options.packageName, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
  88. return {
  89. packageName: options.packageName,
  90. kind: manifest.dsh?.bundle === undefined ? 'unknown' : 'bundle',
  91. cordisSameCopy: null,
  92. rows: [],
  93. overrides: [],
  94. addable: [],
  95. }
  96. }
  97. describe('dsh plugin', () => {
  98. it.each(['SIGINT', 'SIGTERM'] as const)('waits for an install interrupted by %s and removes its listener', async (signal) => {
  99. const before = process.listeners(signal)
  100. const done = Promise.withResolvers<{ exitCode: null; signal: 'SIGTERM' }>()
  101. const started = Promise.withResolvers<undefined>()
  102. const stdout = new PassThrough()
  103. const stderr = new PassThrough()
  104. const run = runPlugin('web', ['add', 'slow'], { spawn: (spec) => {
  105. spec.signal?.addEventListener('abort', () => { stdout.end(); stderr.end(); done.resolve({ exitCode: null, signal: 'SIGTERM' }) }, { once: true })
  106. started.resolve(undefined)
  107. return {
  108. stdin: undefined, stdout, stderr, control: undefined, collected: {}, done: done.promise,
  109. terminate() {}, waitForExit: () => done.promise.then(() => true),
  110. }
  111. } })
  112. await started.promise
  113. const owned = process.listeners(signal).find(listener => !before.includes(listener))
  114. expect(owned).toBeDefined()
  115. // Invoke only this command's listener; do not signal the test runner or other listeners.
  116. owned?.(signal)
  117. expect(await run).toBe(signal === 'SIGINT' ? 130 : 143)
  118. expect(process.listeners(signal)).toEqual(before)
  119. })
  120. it('lets pnpm colour its output when stdout is a terminal', async () => {
  121. const calls: string[][] = []
  122. const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
  123. Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
  124. try {
  125. expect(await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })).toBe(0)
  126. } finally {
  127. if (descriptor === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY
  128. else Object.defineProperty(process.stdout, 'isTTY', descriptor)
  129. }
  130. expect(spawnEnvs).toEqual(['1'])
  131. expect(stdout).toContain('\u001b[32m+\u001b[39m ext-bundle')
  132. })
  133. it('add enables the new bundle and retains an undeclared dependency', async () => {
  134. const calls: string[][] = []
  135. const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
  136. expect(code).toBe(0)
  137. const profileDir = resolveProfileDir('web', home)
  138. expect(stderr).toContain(`dsh: initialized profile web at ${profileDir}`)
  139. expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib']])
  140. const manifest = readProfileManifest('dsh', profileDir)
  141. expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
  142. expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-lib')
  143. expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
  144. // The log reaches its readers plain: colours are off for the child and stripped from what it still prints.
  145. expect(spawnEnvs).toEqual(['0', '0'])
  146. expect(stdout).toContain('+ ext-bundle')
  147. expect(stdout).not.toContain('\u001b[')
  148. expect(stderr).toContain('ext-lib declares no dsh.bundle')
  149. expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
  150. })
  151. it('remove goes through the installer and forgets the probe record', async () => {
  152. const calls: string[][] = []
  153. await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
  154. const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
  155. expect(code).toBe(0)
  156. expect(calls).toEqual([['add', 'ext-bundle'], ['remove', 'ext-bundle']])
  157. const profileDir = resolveProfileDir('web', home)
  158. const manifest = readProfileManifest('dsh', profileDir)
  159. expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-bundle')
  160. expect(manifest.dsh?.profile?.bundles).not.toContain('ext-bundle')
  161. expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
  162. })
  163. it('reports a failed pnpm run with its exit code, the git hint, and a missing pnpm as 127', async () => {
  164. const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), metadata: fakeMetadata })
  165. expect(failed).toBe(1)
  166. expect(stderr).toContain('dsh: pnpm failed in profile directory')
  167. expect(stderr).toContain('git-hosted plugins build on install via their prepare script')
  168. const error = Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' })
  169. const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), metadata: fakeMetadata })
  170. expect(missing).toBe(127)
  171. expect(stderr).toContain('dsh: pnpm not found on PATH')
  172. })
  173. })