githubReleases.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. // @vitest-environment node
  2. import { describe, expect, it, vi, afterEach } from 'vitest'
  3. import { inspectGitHubRelease, githubRepository } from '../../src/server/githubReleases.js'
  4. import { inspectGitRemote } from '../../src/server/updateManager.js'
  5. import { normalizeReleaseSource, DEFAULT_RELEASE_SOURCE } from '../../bin/lib/release-source.mjs'
  6. import { loadServerConfig } from '../../src/server/config.js'
  7. import { launchAgentPlist } from '../../bin/hermes-yaoyao.mjs'
  8. const commit = 'a'.repeat(40), tagObject = 'b'.repeat(40)
  9. const gitCalls = vi.hoisted(() => ({ execFile: vi.fn() }))
  10. vi.mock('node:child_process', async original => ({ ...await original<typeof import('node:child_process')>(), execFile: gitCalls.execFile }))
  11. const manifest = { schemaVersion: 1, releaseVersion: '0.4.2', webVersion: '0.4.2', gitTag: 'v0.4.2' }
  12. const api = 'https://api.github.com/repos/Lsamien/hermes-yaoyao'
  13. function fixture(overrides: Record<string, unknown> = {}) {
  14. const documents: Record<string, unknown> = {
  15. [`${api}/releases/latest`]: { draft: false, prerelease: false, tag_name: 'v0.4.2', body: 'Release notes', assets: [
  16. { name: 'Yaoyao-0.4.2-arm64.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://github.com/Lsamien/hermes-yaoyao/releases/download/v0.4.2/Yaoyao-0.4.2-arm64.dmg' },
  17. { name: 'bad.dmg', size: 10, state: 'uploaded', browser_download_url: 'https://evil.test/bad.dmg' },
  18. ] },
  19. [`${api}/git/ref/tags/v0.4.2`]: { object: { type: 'tag', sha: tagObject } },
  20. [`${api}/git/tags/${tagObject}`]: { object: { type: 'commit', sha: commit } },
  21. [`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`]: manifest,
  22. ...overrides,
  23. }
  24. return vi.fn(async (url: string | URL | Request) => {
  25. const value = documents[String(url)]
  26. return value instanceof Response ? value : Response.json(value ?? {}, { status: value ? 200 : 404 })
  27. }) as unknown as typeof fetch
  28. }
  29. afterEach(() => { vi.unstubAllGlobals(); gitCalls.execFile.mockReset() })
  30. describe('GitHub release source', () => {
  31. it('migrates only known official sources and leaves user forks unchanged', () => {
  32. for (const source of [undefined, '', 'https://git.samien.cn/samien/hermes-yaoyao.git', 'http://192.168.153.8:3000/samien/hermes-yaoyao/', 'git@git.samien.cn:samien/hermes-yaoyao.git']) expect(normalizeReleaseSource(source)).toBe(DEFAULT_RELEASE_SOURCE)
  33. for (const source of ['https://git.samien.cn/other/fork.git', 'https://private.example/repo.git', 'git@private.example:fork.git', 'https://github.com/other/fork.git']) expect(normalizeReleaseSource(source)).toBe(source)
  34. expect(normalizeReleaseSource('https://github.com/lsamien/Hermes-Yaoyao')).toBe(DEFAULT_RELEASE_SOURCE)
  35. expect(githubRepository('https://github.com/Lsamien/hermes-yaoyao/')).toBe('Lsamien/hermes-yaoyao')
  36. expect(loadServerConfig({ HERMES_YAOYAO_RELEASE_SOURCE: 'https://git.samien.cn/samien/hermes-yaoyao.git' }).releaseSource).toBe(DEFAULT_RELEASE_SOURCE)
  37. const plist = launchAgentPlist({ environment: { HERMES_YAOYAO_RELEASE_SOURCE: 'https://git.samien.cn/samien/hermes-yaoyao.git' } })
  38. expect(plist).toContain(DEFAULT_RELEASE_SOURCE)
  39. expect(launchAgentPlist({ environment: { HERMES_YAOYAO_RELEASE_SOURCE: 'https://private.example/fork.git' } })).toContain('https://private.example/fork.git')
  40. })
  41. it('selects a published stable release, peels annotated tags and reads an immutable manifest', async () => {
  42. const fetchImpl = fixture()
  43. const result = await inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl)
  44. expect(result).toMatchObject({ manifest, commit, notes: 'Release notes', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.2' })
  45. expect(result.assets).toHaveLength(1)
  46. expect(fetchImpl).toHaveBeenCalledWith(`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`, expect.anything())
  47. })
  48. it('uses the GitHub adapter instead of cloning or contacting 9119', async () => {
  49. const fetchImpl = fixture(); vi.stubGlobal('fetch', fetchImpl)
  50. expect(await inspectGitRemote(DEFAULT_RELEASE_SOURCE, manifest as any)).toMatchObject({ manifest, commit })
  51. expect(fetchImpl).toHaveBeenCalledTimes(4)
  52. expect(gitCalls.execFile).not.toHaveBeenCalled()
  53. })
  54. it('keeps a custom GitHub fork on the original Git-tag discovery path', async () => {
  55. gitCalls.execFile.mockImplementation((_command, _args, _options, callback) => callback(null, '', ''))
  56. const fetchImpl = vi.fn(); vi.stubGlobal('fetch', fetchImpl)
  57. const source = 'https://github.com/other/fork.git'
  58. expect(await inspectGitRemote(source, manifest as any)).toBeUndefined()
  59. expect(gitCalls.execFile).toHaveBeenCalledWith('git', ['ls-remote', '--tags', source, 'refs/tags/v*'], expect.anything(), expect.any(Function))
  60. expect(fetchImpl).not.toHaveBeenCalled()
  61. })
  62. it.each([403, 429, 404, 500])('surfaces HTTP %s rather than returning up to date', async status => {
  63. await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/releases/latest`]: new Response('', { status }) }))).rejects.toThrow(/GitHub/)
  64. })
  65. it.each([{ draft: true }, { prerelease: true }, { tag_name: 'v0.4.2-beta.1' }])('rejects non-stable release metadata %s', async patch => {
  66. await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/releases/latest`]: { draft: false, prerelease: false, tag_name: 'v0.4.2', ...patch } }))).rejects.toThrow('稳定')
  67. })
  68. it('rejects mismatching manifests and invalid tags', async () => {
  69. await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`https://raw.githubusercontent.com/Lsamien/hermes-yaoyao/${commit}/release.json`]: { ...manifest, gitTag: 'v0.4.1' } }))).rejects.toThrow()
  70. await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fixture({ [`${api}/git/ref/tags/v0.4.2`]: { object: { type: 'tree', sha: commit } } }))).rejects.toThrow('标签提交')
  71. })
  72. it('reports network failure and cancellation explicitly', async () => {
  73. const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'))
  74. await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl)).rejects.toThrow('网络')
  75. await expect(inspectGitHubRelease(DEFAULT_RELEASE_SOURCE, fetchImpl, AbortSignal.abort())).rejects.toThrow('取消')
  76. })
  77. })