Files
deepseek-harness/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts
imccyu 825a63ab01 feat(sdk): add dsh-plugin-fetch source + fetcher seam
Greenfield #2 "建插件" modules for the forthcoming `dsh-sdk create <source>`
command, in a new foundation-independent package so it touches none of the
dsh-scripts / dsh-helper / create-sdk hotspots the foundation refactor edits.

- `resolvePluginSource(spec)` parses `owner/repo[/subdir]#ref` (github) or
  `pkg@version` (npm) into a `PluginSource` discriminated union, failing loud on
  an ambiguous or malformed spec.
- `PluginFetcher<S>` seam + `fetchPlugin` tag dispatch returning a common
  `FetchedPlugin` (temp dir + immutable provenance).
- `GigetFetcher` (github) over @bluwy/giget-core: resolve `#ref` to a commit SHA
  first, download that SHA; provenance pins the SHA. Chosen over unjs/giget for
  its single runtime dep and absent install/action surface.
- `PacoteFetcher` (npm) over pacote: resolve the manifest, then extract the
  tarball verified against its registry integrity. Registry-only is enforced by
  the source resolver; extract runs no lifecycle scripts.
- Branded `CommitSha`/`Integrity`; network + temp-dir boundaries are injected so
  the logic is unit-tested at 100% per-file coverage without network.

Wiring (package.json pin, cordis.yml via ProjectEditSession with a confirmed
diff, install --ignore-scripts) and the launcher command registration land
later with the foundation.
2026-07-18 16:08:12 +08:00

146 lines
6.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { downloadTemplate } from '@bluwy/giget-core'
import {
createGigetFetcher,
defaultResolveRef,
GigetFetcher,
GITHUB_TEMP_PREFIX,
type GigetFetcherDeps,
} from '../src/giget-fetcher.ts'
import type { CommitSha } from '../src/ids.ts'
import type { GithubSource } from '../src/source.ts'
vi.mock('@bluwy/giget-core', () => ({ downloadTemplate: vi.fn(async (_input: string, options: { dir: string }) => ({ dir: options.dir, source: '', info: { name: '', tar: '' } })) }))
const SHA = 'a'.repeat(40)
/** A `fetch` mock typed with the call signature the assertions destructure. */
function fetchReturning(response: Response): Mock<(url: string, init?: RequestInit) => Promise<Response>> {
return vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(response))
}
function fakeDeps(overrides: Partial<GigetFetcherDeps> = {}): {
deps: GigetFetcherDeps
download: ReturnType<typeof vi.fn>
resolveRef: ReturnType<typeof vi.fn>
createTempDir: ReturnType<typeof vi.fn>
} {
const download = vi.fn(async () => ({ dir: '/tmp/x' }))
const resolveRef = vi.fn(async () => SHA as CommitSha)
const createTempDir = vi.fn(async () => '/tmp/dsh-plugin-github-abc')
return { deps: { download, resolveRef, createTempDir, ...overrides }, download, resolveRef, createTempDir }
}
describe('GigetFetcher.fetch', () => {
it('pins the ref to a SHA, downloads that SHA, and reports provenance', async () => {
const { deps, download, resolveRef, createTempDir } = fakeDeps()
const source: GithubSource = { kind: 'github', owner: 'unjs', repo: 'template', ref: 'main' }
const result = await new GigetFetcher(deps).fetch(source)
expect(resolveRef).toHaveBeenCalledWith(source)
expect(createTempDir).toHaveBeenCalledWith(GITHUB_TEMP_PREFIX)
expect(download).toHaveBeenCalledWith(`unjs/template#${SHA}`, { dir: '/tmp/dsh-plugin-github-abc', force: 'clean' })
expect(result).toEqual({
dir: '/tmp/dsh-plugin-github-abc',
source,
provenance: { kind: 'github', sha: SHA },
})
})
it('includes the subdir in the download input', async () => {
const { deps, download } = fakeDeps()
const source: GithubSource = { kind: 'github', owner: 'o', repo: 'r', subdir: 'packages/plugin' }
await new GigetFetcher(deps).fetch(source)
expect(download).toHaveBeenCalledWith(`o/r/packages/plugin#${SHA}`, expect.anything())
})
it('exposes its source kind', () => {
expect(new GigetFetcher(fakeDeps().deps).kind).toBe('github')
})
})
describe('defaultResolveRef', () => {
afterEach(() => vi.unstubAllGlobals())
it('resolves the default branch (HEAD) with no auth header', async () => {
const fetchMock = fetchReturning(new Response(`${SHA}\n`, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' })
expect(sha).toBe(SHA)
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('https://api.github.com/repos/o/r/commits/HEAD')
expect((init as RequestInit).headers).toEqual({ Accept: 'application/vnd.github.sha' })
})
it('resolves an explicit ref and sends a bearer token', async () => {
const fetchMock = fetchReturning(new Response(SHA, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'v1.2.3' }, 'secret')
expect(sha).toBe(SHA)
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('https://api.github.com/repos/o/r/commits/v1.2.3')
expect((init as RequestInit).headers).toEqual({
Accept: 'application/vnd.github.sha',
Authorization: 'Bearer secret',
})
})
it('throws with the HEAD label when the API rejects an unref-ed source', async () => {
vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 404 })))
await expect(defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' })).rejects.toThrow(
/cannot resolve github ref o\/r#HEAD: HTTP 404/,
)
})
it('throws with the explicit-ref label when the API rejects', async () => {
vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 403 })))
await expect(
defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' }),
).rejects.toThrow(/cannot resolve github ref o\/r#main: HTTP 403/)
})
})
describe('createGigetFetcher', () => {
const downloadMock = vi.mocked(downloadTemplate)
let savedToken: string | undefined
beforeEach(() => {
downloadMock.mockClear()
savedToken = process.env.GITHUB_TOKEN
delete process.env.GITHUB_TOKEN
})
afterEach(() => {
vi.unstubAllGlobals()
if (savedToken === undefined) delete process.env.GITHUB_TOKEN
else process.env.GITHUB_TOKEN = savedToken
})
it('wires the real download without provider auth when no token is present', async () => {
vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 })))
await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' })
const [input, options] = downloadMock.mock.calls[0]!
expect(input).toBe(`o/r#${SHA}`)
expect(options?.dir).toContain(GITHUB_TEMP_PREFIX)
expect(options?.force).toBe('clean')
expect(options?.providerOptions).toBeUndefined()
})
it('passes an explicit token to both ref resolution and provider auth', async () => {
const fetchMock = fetchReturning(new Response(SHA, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await createGigetFetcher({ token: 'tok' }).fetch({ kind: 'github', owner: 'o', repo: 'r' })
expect((fetchMock.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer tok' })
const [, options] = downloadMock.mock.calls[0]!
expect(options).toMatchObject({ providerOptions: { auth: 'tok' } })
})
it('reads GITHUB_TOKEN from the environment', async () => {
process.env.GITHUB_TOKEN = 'from-env'
vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 })))
await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r' })
const [, options] = downloadMock.mock.calls[0]!
expect(options).toMatchObject({ providerOptions: { auth: 'from-env' } })
})
})