github-repository-plugin.built.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { createHash } from 'node:crypto'
  2. import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
  3. import { createServer } from 'node:http'
  4. import { createRequire } from 'node:module'
  5. import { tmpdir } from 'node:os'
  6. import { delimiter, join } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  9. import { execa } from 'execa'
  10. import { describe, expect, it } from 'vitest'
  11. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  12. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  13. const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin')
  14. const releasePackageNames = new Set(globSync([
  15. 'vendor/*/package.json',
  16. 'packages/*/*/package.json',
  17. 'apps/*/package.json',
  18. ], { cwd: repoRoot }).map((filename) => {
  19. const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record<string, unknown>
  20. if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`)
  21. return manifest.name
  22. }))
  23. const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE
  24. const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1'
  25. const enabled = required || source !== undefined
  26. interface PublishedPackageRegistry {
  27. url: string
  28. requests: string[]
  29. close(): Promise<void>
  30. }
  31. function publishedManifest(): Record<string, unknown> {
  32. const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record<string, unknown>
  33. const version = manifest.version
  34. if (typeof version !== 'string') throw new Error('repository Plugin package version is missing')
  35. Reflect.deleteProperty(manifest, 'private')
  36. for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
  37. const dependencies = manifest[field]
  38. if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue
  39. const entries = dependencies as Record<string, unknown>
  40. for (const name of Object.keys(entries)) {
  41. if (releasePackageNames.has(name)) {
  42. entries[name] = version
  43. }
  44. }
  45. }
  46. return manifest
  47. }
  48. async function startPublishedPackageRegistry(root: string): Promise<PublishedPackageRegistry> {
  49. const staging = join(root, 'published-repository-plugin')
  50. const artifacts = join(root, 'npm-registry-artifacts')
  51. mkdirSync(staging)
  52. mkdirSync(artifacts)
  53. cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true })
  54. for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) {
  55. cpSync(join(repositoryPluginPackage, filename), join(staging, filename))
  56. }
  57. cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE'))
  58. const manifest = publishedManifest()
  59. writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
  60. const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], {
  61. cwd: staging,
  62. reject: false,
  63. })
  64. if (packed.exitCode !== 0) {
  65. throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`)
  66. }
  67. const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz'))
  68. if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`)
  69. const tarball = readFileSync(join(artifacts, tarballs[0]!))
  70. const name = manifest.name as string
  71. const version = manifest.version as string
  72. const requests: string[] = []
  73. let registryUrl = ''
  74. const server = createServer((request, response) => {
  75. const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname)
  76. requests.push(`${request.method ?? 'GET'} ${path}`)
  77. if (path === `/${name}`) {
  78. const metadata = {
  79. name,
  80. 'dist-tags': { latest: version },
  81. versions: {
  82. [version]: {
  83. ...manifest,
  84. dist: {
  85. tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`,
  86. shasum: createHash('sha1').update(tarball).digest('hex'),
  87. integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`,
  88. },
  89. },
  90. },
  91. }
  92. response.writeHead(200, { 'content-type': 'application/json' })
  93. response.end(JSON.stringify(metadata))
  94. return
  95. }
  96. if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) {
  97. response.writeHead(200, {
  98. 'content-type': 'application/octet-stream',
  99. 'content-length': String(tarball.length),
  100. })
  101. response.end(tarball)
  102. return
  103. }
  104. response.writeHead(404, { 'content-type': 'application/json' })
  105. response.end(JSON.stringify({ error: 'not found' }))
  106. })
  107. await new Promise<void>((resolve, reject) => {
  108. server.once('error', reject)
  109. server.listen(0, '127.0.0.1', resolve)
  110. })
  111. const address = server.address()
  112. if (address === null || typeof address === 'string') throw new Error('simulated npm registry did not expose a TCP address')
  113. registryUrl = `http://127.0.0.1:${address.port}/`
  114. return {
  115. url: registryUrl,
  116. requests,
  117. close: () => new Promise<void>((resolve, reject) => {
  118. server.close((error) => { if (error === undefined) resolve(); else reject(error) })
  119. }),
  120. }
  121. }
  122. describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => {
  123. it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => {
  124. expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true)
  125. expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch(
  126. /^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u,
  127. )
  128. const apiKey = 'github-repository-plugin-e2e-key'
  129. const server = await startMockLlmServer({
  130. sequence: ['tool_call_success', 'success'],
  131. apiKey,
  132. toolName: 'mcp__github_repository__proof',
  133. toolArguments: '{}',
  134. successText: 'trusted GitHub repository package reached dsh run',
  135. })
  136. const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-'))
  137. const registry = await startPublishedPackageRegistry(home)
  138. const npmrc = join(home, 'npmrc')
  139. writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`)
  140. const hostBin = join(home, 'host-bin')
  141. mkdirSync(hostBin)
  142. writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [
  143. '#!/bin/sh',
  144. 'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2',
  145. 'exit 91',
  146. '',
  147. ].join('\n'), { mode: 0o700 })
  148. const patch = join(home, 'github-repository-plugin.cordis.patch.yml')
  149. writeFileSync(patch, [
  150. '- id: repository-plugins',
  151. ' config:',
  152. ' repositories:',
  153. ` - ${JSON.stringify(source)}`,
  154. '- id: session-title-llm',
  155. ' disabled: true',
  156. '',
  157. ].join('\n'))
  158. try {
  159. const result = await execa(process.execPath, [
  160. dshBin,
  161. 'run',
  162. '--patch',
  163. patch,
  164. 'prove the private GitHub repository Plugin is active',
  165. ], {
  166. cwd: repoRoot,
  167. input: '',
  168. timeout: 180_000,
  169. killSignal: 'SIGKILL',
  170. reject: false,
  171. env: {
  172. ...process.env,
  173. DSH_HOME: home,
  174. DSH_TELEMETRY_DISABLED: '1',
  175. DEEPSEEK_API_KEY: apiKey,
  176. DEEPSEEK_BASE_URL: server.baseURL,
  177. NPM_CONFIG_USERCONFIG: npmrc,
  178. // A warm runner cache could satisfy the exact tarball without
  179. // contacting this test's registry, which would stop proving the
  180. // unpublished package was installed through the simulated release.
  181. PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'),
  182. PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'),
  183. PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`,
  184. },
  185. })
  186. if (result.timedOut) {
  187. throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  188. }
  189. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0)
  190. expect(result.stdout).toBe('trusted GitHub repository package reached dsh run')
  191. expect(server.requests).toHaveLength(2)
  192. const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}`
  193. expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin')
  194. expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz')
  195. const firstRequest = JSON.stringify(server.requests[0]!.body)
  196. const secondRequest = JSON.stringify(server.requests[1]!.body)
  197. expect(firstRequest, runtimeDiagnostic).toContain(
  198. 'Proves that dsh installed a private repository Plugin from an exact GitHub source.',
  199. )
  200. expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof')
  201. expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.')
  202. expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY')
  203. expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
  204. const cacheRoot = join(home, 'cache', 'repository-plugins')
  205. const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory())
  206. expect(generations).toHaveLength(1)
  207. const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository')
  208. const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record<string, unknown>
  209. expect(manifest).toMatchObject({
  210. name: 'dsh-github-repository-plugin-e2e-fixture',
  211. private: true,
  212. scripts: {
  213. prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare',
  214. },
  215. dsh: {
  216. skills: ['../skills'],
  217. mcpServers: './.mcp.json',
  218. entry: './lib/plugin.mjs',
  219. },
  220. dependencies: {
  221. '@modelcontextprotocol/sdk': '1.29.0',
  222. },
  223. devDependencies: {
  224. '@deepseek-ai/dsh-repository-plugin': '0.0.1',
  225. cordis: '4.0.0-rc.7',
  226. tsdown: '0.22.2',
  227. typescript: '6.0.3',
  228. },
  229. })
  230. expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8'))
  231. .toContain('This skill exists only in the GitHub repository source fixture.')
  232. expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs')
  233. expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
  234. expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY')
  235. expect(existsSync(join(installed, 'src'))).toBe(false)
  236. const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs'))
  237. expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true)
  238. const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')
  239. expect(wrapper).toContain('dsh-repository-plugin')
  240. expect(wrapper).toContain('await import(manifest.entry)')
  241. expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
  242. } finally {
  243. await server.close()
  244. await registry.close()
  245. rmSync(home, { recursive: true, force: true })
  246. }
  247. }, 190_000)
  248. })