Skip to content

Commit 0c8b837

Browse files
committed
test(coverage): cover network-mock paths in releases + dlx/binary (P2)
Push #2 P2 — network-mock based tests for paths that were previously "untestable" because they hit real network. New test files (+13 tests): - test/unit/releases-github-downloads-extras.test.mts (7 tests) Mocks getLatestRelease + getReleaseAssetUrl + httpDownload to cover: - toolPrefix tag resolution (success path) - toolPrefix throw when no matching release - "Either toolPrefix or tag must be provided" guard - explicit tag overrides toolPrefix lookup - cached-binary fast-path (httpDownload skipped) - downloadReleaseAsset throws on missing asset (string + object pattern) releases/github-downloads.ts: 80.0% → 100.0%. - test/unit/dlx/binary-download.test.mts (6 tests) Mocks httpDownload to cover downloadBinaryFile: - SRI integrity hash on success - existing-file fast-path skips download - matching integrity accepted - integrity mismatch throws + cleans up bad file - download failure wrapped with URL + dest context - sha256 option forwarded to httpDownload Project: 90.29% → 90.44% lines, 80.35% → 80.66% branches across 6888 passing tests (+13).
1 parent ab60abf commit 0c8b837

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @fileoverview Tests for downloadBinaryFile in src/dlx/binary.ts:
3+
* integrity verification, retry-on-existing-file fast path, download
4+
* failure wrapping, and chmod-on-Unix.
5+
*
6+
* Mocks httpDownload so the test runs hermetically without making real
7+
* network calls.
8+
*/
9+
10+
import { createHash } from 'node:crypto'
11+
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
12+
import { tmpdir } from 'node:os'
13+
import path from 'node:path'
14+
15+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16+
17+
import { downloadBinaryFile } from '../../../src/dlx/binary'
18+
import { httpDownload } from '../../../src/http-request'
19+
20+
vi.mock('../../../src/http-request', async importOriginal => {
21+
const original =
22+
await importOriginal<typeof import('../../../src/http-request')>()
23+
return {
24+
...original,
25+
httpDownload: vi.fn(
26+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27+
async (_url: string, destPath: string, _opts?: any) => {
28+
// Default behavior: write a known payload.
29+
writeFileSync(destPath, Buffer.from('default-payload'))
30+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31+
return { ok: true, status: 200, path: destPath } as any
32+
},
33+
),
34+
}
35+
})
36+
37+
function sha512OfBuffer(buf: Buffer): string {
38+
const h = createHash('sha512').update(buf).digest('base64')
39+
return `sha512-${h}`
40+
}
41+
42+
describe.sequential('dlx/binary — downloadBinaryFile', () => {
43+
let testDir: string
44+
45+
beforeEach(() => {
46+
testDir = mkdtempSync(path.join(tmpdir(), 'dlx-bin-dl-'))
47+
vi.mocked(httpDownload).mockClear()
48+
})
49+
50+
afterEach(() => {
51+
vi.restoreAllMocks()
52+
})
53+
54+
it('downloads and returns the SRI integrity hash', async () => {
55+
const destPath = path.join(testDir, 'tool')
56+
const result = await downloadBinaryFile(
57+
'https://example.com/tool',
58+
destPath,
59+
)
60+
expect(result.startsWith('sha512-')).toBe(true)
61+
expect(existsSync(destPath)).toBe(true)
62+
})
63+
64+
it('returns existing-file integrity when destPath already has content', async () => {
65+
const destPath = path.join(testDir, 'pre-existing')
66+
const payload = Buffer.from('already-here')
67+
writeFileSync(destPath, payload)
68+
const expectedIntegrity = sha512OfBuffer(payload)
69+
const result = await downloadBinaryFile('https://example.com/x', destPath)
70+
expect(result).toBe(expectedIntegrity)
71+
// httpDownload should NOT be called when the file is pre-staged.
72+
expect(httpDownload).not.toHaveBeenCalled()
73+
})
74+
75+
it('verifies integrity and accepts matching hash', async () => {
76+
const payload = Buffer.from('expected-content')
77+
vi.mocked(httpDownload).mockImplementationOnce(async (_url, destPath) => {
78+
writeFileSync(destPath, payload)
79+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80+
return { ok: true, status: 200, path: destPath } as any
81+
})
82+
const expectedIntegrity = sha512OfBuffer(payload)
83+
const destPath = path.join(testDir, 'verified')
84+
const result = await downloadBinaryFile(
85+
'https://example.com/x',
86+
destPath,
87+
expectedIntegrity,
88+
)
89+
expect(result).toBe(expectedIntegrity)
90+
})
91+
92+
it('throws integrity-mismatch and removes the bad file', async () => {
93+
const payload = Buffer.from('actual-content')
94+
vi.mocked(httpDownload).mockImplementationOnce(async (_url, destPath) => {
95+
writeFileSync(destPath, payload)
96+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
97+
return { ok: true, status: 200, path: destPath } as any
98+
})
99+
const wrongIntegrity = sha512OfBuffer(Buffer.from('different-content'))
100+
const destPath = path.join(testDir, 'mismatch')
101+
await expect(
102+
downloadBinaryFile('https://example.com/x', destPath, wrongIntegrity),
103+
).rejects.toThrow(/Integrity mismatch/)
104+
// Bad file removed by safeDelete.
105+
expect(existsSync(destPath)).toBe(false)
106+
})
107+
108+
it('wraps download errors with URL + destination context', async () => {
109+
vi.mocked(httpDownload).mockRejectedValueOnce(new Error('network-down'))
110+
const destPath = path.join(testDir, 'err')
111+
await expect(
112+
downloadBinaryFile('https://example.com/x', destPath),
113+
).rejects.toThrow(
114+
/Failed to download binary from https:\/\/example\.com\/x/,
115+
)
116+
})
117+
118+
it('passes sha256 option through to httpDownload when provided', async () => {
119+
const destPath = path.join(testDir, 'with-sha256')
120+
await downloadBinaryFile(
121+
'https://example.com/x',
122+
destPath,
123+
undefined,
124+
'a'.repeat(64),
125+
)
126+
const args = vi.mocked(httpDownload).mock.calls[0]!
127+
expect(args[2]).toEqual({ sha256: 'a'.repeat(64) })
128+
})
129+
})
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* @fileoverview Tests for the toolPrefix tag-resolution branch and the
3+
* asset-not-found throw in src/releases/github-downloads.ts that the
4+
* existing releases-github-downloads.test.mts doesn't cover.
5+
*
6+
* Mocks getLatestRelease + getReleaseAssetUrl + httpDownload so the
7+
* tests run hermetically.
8+
*/
9+
10+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
11+
import { tmpdir } from 'node:os'
12+
import path from 'node:path'
13+
14+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
15+
16+
import {
17+
downloadGitHubRelease,
18+
downloadReleaseAsset,
19+
} from '../../src/releases/github-downloads'
20+
21+
import {
22+
getLatestRelease,
23+
getReleaseAssetUrl,
24+
} from '../../src/releases/github-api'
25+
import { httpDownload } from '../../src/http-request'
26+
27+
vi.mock('../../src/releases/github-api', async importOriginal => {
28+
const original =
29+
await importOriginal<typeof import('../../src/releases/github-api')>()
30+
return {
31+
...original,
32+
getLatestRelease: vi.fn(),
33+
getReleaseAssetUrl: vi.fn(),
34+
}
35+
})
36+
37+
vi.mock('../../src/http-request', async importOriginal => {
38+
const original =
39+
await importOriginal<typeof import('../../src/http-request')>()
40+
return {
41+
...original,
42+
httpDownload: vi.fn(
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
async (_url: string, destPath: string, _opts?: any) => {
45+
// Write a real file at destPath so subsequent fs ops succeed.
46+
writeFileSync(destPath, 'fake-binary-content')
47+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
48+
return { ok: true, status: 200, path: destPath } as any
49+
},
50+
),
51+
}
52+
})
53+
54+
const REPO = { owner: 'SocketDev', repo: 'socket-btm' }
55+
56+
describe.sequential('releases/github-downloads — extras', () => {
57+
let testDir: string
58+
59+
beforeEach(() => {
60+
testDir = mkdtempSync(path.join(tmpdir(), 'gh-downloads-extras-'))
61+
vi.mocked(getLatestRelease).mockClear()
62+
vi.mocked(getReleaseAssetUrl).mockClear()
63+
vi.mocked(httpDownload).mockClear()
64+
})
65+
66+
afterEach(() => {
67+
vi.restoreAllMocks()
68+
})
69+
70+
describe('downloadGitHubRelease — toolPrefix tag resolution', () => {
71+
it('resolves latest tag from toolPrefix when explicit tag is absent', async () => {
72+
vi.mocked(getLatestRelease).mockResolvedValueOnce('mytool-v1.2.3')
73+
vi.mocked(getReleaseAssetUrl).mockResolvedValueOnce(
74+
'https://example.com/asset.tar.gz',
75+
)
76+
const downloadDir = path.join(testDir, 'tool-prefix-ok')
77+
mkdirSync(downloadDir, { recursive: true })
78+
const result = await downloadGitHubRelease({
79+
...REPO,
80+
binaryName: 'mytool',
81+
downloadDir,
82+
cwd: testDir,
83+
platformArch: 'darwin-arm64',
84+
toolName: 'mytool',
85+
toolPrefix: 'mytool-',
86+
assetName: 'mytool-darwin-arm64.tar.gz',
87+
quiet: true,
88+
})
89+
expect(result).toContain('mytool')
90+
expect(getLatestRelease).toHaveBeenCalled()
91+
})
92+
93+
it('throws when toolPrefix has no matching release', async () => {
94+
vi.mocked(getLatestRelease).mockResolvedValueOnce(undefined)
95+
const downloadDir = path.join(testDir, 'tool-prefix-fail')
96+
mkdirSync(downloadDir, { recursive: true })
97+
await expect(
98+
downloadGitHubRelease({
99+
...REPO,
100+
binaryName: 'mytool',
101+
downloadDir,
102+
cwd: testDir,
103+
platformArch: 'darwin-arm64',
104+
toolName: 'mytool',
105+
toolPrefix: 'unknown-',
106+
assetName: 'asset.tar.gz',
107+
quiet: true,
108+
}),
109+
).rejects.toThrow(/No unknown- release found/)
110+
})
111+
112+
it('throws when neither tag nor toolPrefix are provided', async () => {
113+
const downloadDir = path.join(testDir, 'no-tag')
114+
mkdirSync(downloadDir, { recursive: true })
115+
await expect(
116+
downloadGitHubRelease({
117+
...REPO,
118+
binaryName: 'mytool',
119+
downloadDir,
120+
cwd: testDir,
121+
platformArch: 'darwin-arm64',
122+
toolName: 'mytool',
123+
assetName: 'asset.tar.gz',
124+
quiet: true,
125+
}),
126+
).rejects.toThrow(/Either toolPrefix or tag must be provided/)
127+
})
128+
129+
it('honors explicit tag over toolPrefix', async () => {
130+
vi.mocked(getReleaseAssetUrl).mockResolvedValueOnce(
131+
'https://example.com/asset.tar.gz',
132+
)
133+
const downloadDir = path.join(testDir, 'explicit-tag')
134+
mkdirSync(downloadDir, { recursive: true })
135+
const result = await downloadGitHubRelease({
136+
...REPO,
137+
binaryName: 'mytool',
138+
downloadDir,
139+
cwd: testDir,
140+
platformArch: 'darwin-arm64',
141+
toolName: 'mytool',
142+
tag: 'v9.9.9',
143+
assetName: 'asset.tar.gz',
144+
quiet: true,
145+
})
146+
expect(result).toContain('mytool')
147+
expect(getLatestRelease).not.toHaveBeenCalled()
148+
})
149+
150+
it('uses cached binary when version file matches tag', async () => {
151+
const downloadDir = path.join(testDir, 'cache-hit')
152+
mkdirSync(downloadDir, { recursive: true })
153+
// Pre-seed the binary and version file.
154+
writeFileSync(path.join(downloadDir, 'mytool'), 'cached-binary')
155+
writeFileSync(path.join(downloadDir, '.version'), 'v1.0.0')
156+
const result = await downloadGitHubRelease({
157+
...REPO,
158+
binaryName: 'mytool',
159+
downloadDir,
160+
cwd: testDir,
161+
platformArch: 'darwin-arm64',
162+
toolName: 'mytool',
163+
tag: 'v1.0.0',
164+
assetName: 'asset.tar.gz',
165+
quiet: true,
166+
})
167+
expect(result).toContain('mytool')
168+
// Cached path: httpDownload should not be called.
169+
expect(httpDownload).not.toHaveBeenCalled()
170+
})
171+
})
172+
173+
describe('downloadReleaseAsset — error path', () => {
174+
it('throws when getReleaseAssetUrl returns undefined', async () => {
175+
vi.mocked(getReleaseAssetUrl).mockResolvedValueOnce(undefined)
176+
await expect(
177+
downloadReleaseAsset(
178+
'v1.0.0',
179+
'missing-asset.tar.gz',
180+
path.join(testDir, 'output'),
181+
REPO,
182+
{ quiet: true },
183+
),
184+
).rejects.toThrow(/Asset .+ not found in release v1\.0\.0/)
185+
})
186+
187+
it('uses object pattern description when assetPattern is not a string', async () => {
188+
vi.mocked(getReleaseAssetUrl).mockResolvedValueOnce(undefined)
189+
await expect(
190+
downloadReleaseAsset(
191+
'v1.0.0',
192+
{ prefix: 'asset-', suffix: '.zip' },
193+
path.join(testDir, 'output'),
194+
REPO,
195+
{ quiet: true },
196+
),
197+
).rejects.toThrow(/Asset matching pattern not found/)
198+
})
199+
})
200+
})

0 commit comments

Comments
 (0)