1
0

repository-cache.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { execFile } from 'node:child_process'
  2. import { createHash } from 'node:crypto'
  3. import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { promisify } from 'node:util'
  8. import { afterEach, describe, expect, it, vi } from 'vitest'
  9. import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'
  10. const execFileAsync = promisify(execFile)
  11. const roots: string[] = []
  12. async function temporaryRoot(name: string): Promise<string> {
  13. const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
  14. roots.push(root)
  15. return root
  16. }
  17. async function fakePackage(directory: string): Promise<void> {
  18. const target = join(directory, 'node_modules', 'repository')
  19. await mkdir(target, { recursive: true })
  20. await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
  21. }
  22. afterEach(async () => {
  23. vi.unstubAllEnvs()
  24. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  25. })
  26. describe('RepositoryCache', () => {
  27. it('single-flights and permanently reuses an exact specifier', async () => {
  28. const root = await temporaryRoot('repository-cache')
  29. const calls: string[] = []
  30. const install: RepositoryInstall = async (directory) => {
  31. calls.push(directory)
  32. await fakePackage(directory)
  33. }
  34. const cache = new RepositoryCache(root, install)
  35. const specifier = 'github:owner/repository#0123456789abcdef'
  36. const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
  37. expect(concurrent).toBe(first)
  38. expect(calls).toHaveLength(1)
  39. const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
  40. expect(await reopened.resolve(specifier)).toBe(first)
  41. expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
  42. packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
  43. dependencies: { repository: specifier },
  44. })
  45. const second = await cache.resolve('github:owner/repository#fedcba9876543210')
  46. expect(second).not.toBe(first)
  47. expect(calls).toHaveLength(2)
  48. })
  49. it('accepts the valid winner when independent cache instances race', async () => {
  50. const root = await temporaryRoot('repository-race')
  51. const bothStarted = Promise.withResolvers<undefined>()
  52. let starts = 0
  53. const install: RepositoryInstall = async (directory) => {
  54. await fakePackage(directory)
  55. starts += 1
  56. if (starts === 2) bothStarted.resolve(undefined)
  57. await bothStarted.promise
  58. }
  59. const specifier = 'github:owner/repository#race'
  60. const [first, second] = await Promise.all([
  61. new RepositoryCache(root, install).resolve(specifier),
  62. new RepositoryCache(root, install).resolve(specifier),
  63. ])
  64. expect(second).toBe(first)
  65. expect(starts).toBe(2)
  66. expect(await readdir(root)).toHaveLength(1)
  67. })
  68. it('removes a failed staging tree and permits an exact retry', async () => {
  69. const root = await temporaryRoot('repository-retry')
  70. let attempts = 0
  71. const cache = new RepositoryCache(root, async (directory) => {
  72. attempts += 1
  73. if (attempts === 1) throw new Error('install failed')
  74. await fakePackage(directory)
  75. })
  76. await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
  77. expect(await readdir(root)).toEqual([])
  78. await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
  79. expect(attempts).toBe(2)
  80. })
  81. it('rejects empty or padded specifiers before touching the cache', async () => {
  82. const root = await temporaryRoot('repository-input')
  83. const cache = new RepositoryCache(root, fakePackage)
  84. expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
  85. expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
  86. await expect(readdir(root)).resolves.toEqual([])
  87. })
  88. it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
  89. const root = await temporaryRoot('repository-corrupt')
  90. const specifier = 'github:owner/repository#corrupt'
  91. const key = createHash('sha256').update(specifier).digest('hex')
  92. const entry = join(root, key)
  93. await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
  94. await writeFile(join(entry, '.repository-cache.json'), '{}\n')
  95. const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
  96. await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
  97. })
  98. it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
  99. const root = await temporaryRoot('repository-pnpm')
  100. const repository = join(root, 'source')
  101. await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
  102. await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
  103. await writeFile(join(repository, 'package.json'), `${JSON.stringify({
  104. name: 'repository-fixture',
  105. version: '1.0.0',
  106. })}\n`)
  107. await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
  108. await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
  109. name: 'repository-plugin-fixture',
  110. version: '1.0.0',
  111. scripts: { prepare: 'node prepare.mjs' },
  112. dsh: { skills: ['../skills'] },
  113. })}\n`)
  114. await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
  115. "import { cp, mkdir, writeFile } from 'node:fs/promises'",
  116. "await mkdir('dsh-plugin-assets/skills', { recursive: true })",
  117. "await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
  118. "await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
  119. "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
  120. '',
  121. ].join('\n'))
  122. await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
  123. await execFileAsync('git', ['add', '.'], { cwd: repository })
  124. await execFileAsync('git', [
  125. '-c', 'user.name=Repository Fixture',
  126. '-c', 'user.email=repository@example.invalid',
  127. 'commit', '--quiet', '-m', 'fixture',
  128. ], { cwd: repository })
  129. const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
  130. const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
  131. vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
  132. vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
  133. const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
  134. await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
  135. await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
  136. await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
  137. .resolves.toBe('repository skill source\n')
  138. await expect(readFile(join(installed, 'package.json'), 'utf8'))
  139. .resolves.toContain('repository-plugin-fixture')
  140. })
  141. })