|
|
|
@@ -0,0 +1,224 @@
|
|
|
|
|
/**
|
|
|
|
|
* Tests for the first-run ffmpeg acquisition (ffmpegSetup.ts). The shared streaming +
|
|
|
|
|
* checksum loop is mocked here — it's already pinned by updaterDownload.test.ts — so these
|
|
|
|
|
* focus on the ffmpeg-specific behaviour: the upstream trust gate, readiness + dev seed,
|
|
|
|
|
* the download → tar-extract → install-into-managed-dir flow, and the manual-locate
|
|
|
|
|
* fallback (accept a valid folder, reject a missing/blocked one).
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
|
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
import type { WebContents } from 'electron'
|
|
|
|
|
|
|
|
|
|
const ROOT = mkdtempSync(join(tmpdir(), 'aerofetch-ffmpeg-test-'))
|
|
|
|
|
const USERDATA = join(ROOT, 'userData')
|
|
|
|
|
const TEMP = join(ROOT, 'temp')
|
|
|
|
|
const RES = join(ROOT, 'resources')
|
|
|
|
|
const MANAGED = join(USERDATA, 'bin')
|
|
|
|
|
// getBinDir() resolves the dev seed under <getAppPath()>/resources/bin (its is.dev branch),
|
|
|
|
|
// so mocking is.dev=true + app.getAppPath()=ROOT points it at RES/bin. Deliberately NOT
|
|
|
|
|
// mutating the process-global process.resourcesPath, which would leak across test files.
|
|
|
|
|
|
|
|
|
|
// Shared, per-test-tunable control surface (hoisted so the vi.mock factories can read it).
|
|
|
|
|
const h = vi.hoisted(() => ({
|
|
|
|
|
streamOk: true,
|
|
|
|
|
streamError: 'network boom',
|
|
|
|
|
tarFail: false,
|
|
|
|
|
extractWrites: true,
|
|
|
|
|
validateOk: true,
|
|
|
|
|
dialog: { canceled: false, filePaths: [] as string[] }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }))
|
|
|
|
|
|
|
|
|
|
vi.mock('electron', () => ({
|
|
|
|
|
app: {
|
|
|
|
|
getPath: (k: string) => (k === 'userData' ? join(ROOT, 'userData') : join(ROOT, 'temp')),
|
|
|
|
|
getAppPath: () => ROOT
|
|
|
|
|
},
|
|
|
|
|
dialog: { showOpenDialog: async () => h.dialog },
|
|
|
|
|
BrowserWindow: class {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('../src/main/logger', () => ({
|
|
|
|
|
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// ffmpeg version read is display-only; stub it so readiness comes purely from file presence.
|
|
|
|
|
vi.mock('../src/main/ffmpeg', () => ({
|
|
|
|
|
getFfmpegVersions: async () => ({ ffmpeg: 'mock', ffprobe: 'mock' })
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// The streamer is pinned elsewhere: simulate a written archive + one progress tick.
|
|
|
|
|
vi.mock('../src/main/lib/verifiedDownload', () => ({
|
|
|
|
|
streamVerifiedFile: async (opts: {
|
|
|
|
|
filePath: string
|
|
|
|
|
onProgress: (p: { received: number; total: number; fraction: number }) => void
|
|
|
|
|
}) => {
|
|
|
|
|
if (!h.streamOk) return { ok: false, error: h.streamError }
|
|
|
|
|
writeFileSync(opts.filePath, 'FAKE-ZIP')
|
|
|
|
|
opts.onProgress({ received: 100, total: 100, fraction: 1 })
|
|
|
|
|
return { ok: true, filePath: opts.filePath }
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// tar.exe extract + `<tool> -version` validation both go through execFile.
|
|
|
|
|
vi.mock('child_process', () => ({
|
|
|
|
|
execFile: (
|
|
|
|
|
file: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
_opts: unknown,
|
|
|
|
|
cb: (err: Error | null, stdout?: string) => void
|
|
|
|
|
) => {
|
|
|
|
|
if (file.endsWith('tar.exe')) {
|
|
|
|
|
if (h.tarFail) return cb(new Error('tar failed'))
|
|
|
|
|
const dest = args[args.indexOf('-C') + 1]
|
|
|
|
|
if (h.extractWrites) {
|
|
|
|
|
writeFileSync(join(dest, 'ffmpeg.exe'), 'FF')
|
|
|
|
|
writeFileSync(join(dest, 'ffprobe.exe'), 'FP')
|
|
|
|
|
}
|
|
|
|
|
return cb(null)
|
|
|
|
|
}
|
|
|
|
|
if (args.includes('-version')) {
|
|
|
|
|
return h.validateOk ? cb(null, 'ffmpeg version 8.1.2') : cb(new Error('blocked'))
|
|
|
|
|
}
|
|
|
|
|
return cb(null)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const { isTrustedFfmpegUrl, getFfmpegSetupStatus, downloadFfmpeg, locateFfmpegManually } =
|
|
|
|
|
await import('../src/main/ffmpegSetup')
|
|
|
|
|
|
|
|
|
|
function fakeWc(): { wc: WebContents; sends: unknown[] } {
|
|
|
|
|
const sends: unknown[] = []
|
|
|
|
|
const wc = {
|
|
|
|
|
isDestroyed: () => false,
|
|
|
|
|
send: (_ch: string, p: unknown) => sends.push(p)
|
|
|
|
|
} as unknown as WebContents
|
|
|
|
|
return { wc, sends }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
rmSync(MANAGED, { recursive: true, force: true })
|
|
|
|
|
rmSync(join(RES, 'bin'), { recursive: true, force: true })
|
|
|
|
|
mkdirSync(TEMP, { recursive: true })
|
|
|
|
|
Object.assign(h, {
|
|
|
|
|
streamOk: true,
|
|
|
|
|
streamError: 'network boom',
|
|
|
|
|
tarFail: false,
|
|
|
|
|
extractWrites: true,
|
|
|
|
|
validateOk: true,
|
|
|
|
|
dialog: { canceled: false, filePaths: [] }
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterAll(() => rmSync(ROOT, { recursive: true, force: true }))
|
|
|
|
|
|
|
|
|
|
describe('isTrustedFfmpegUrl', () => {
|
|
|
|
|
it('accepts HTTPS on an allowlisted upstream host', () => {
|
|
|
|
|
expect(
|
|
|
|
|
isTrustedFfmpegUrl('https://github.com/GyanD/codexffmpeg/releases/download/x/f.zip')
|
|
|
|
|
).toBe(true)
|
|
|
|
|
expect(isTrustedFfmpegUrl('https://release-assets.githubusercontent.com/abc?x=1')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects http, an off-list host, and garbage', () => {
|
|
|
|
|
expect(isTrustedFfmpegUrl('http://github.com/x.zip')).toBe(false)
|
|
|
|
|
expect(isTrustedFfmpegUrl('https://evil.example/x.zip')).toBe(false)
|
|
|
|
|
expect(isTrustedFfmpegUrl('not a url')).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('getFfmpegSetupStatus', () => {
|
|
|
|
|
it('is ready only when both binaries are present', async () => {
|
|
|
|
|
mkdirSync(MANAGED, { recursive: true })
|
|
|
|
|
writeFileSync(join(MANAGED, 'ffmpeg.exe'), 'x')
|
|
|
|
|
expect((await getFfmpegSetupStatus()).ready).toBe(false) // ffprobe missing
|
|
|
|
|
writeFileSync(join(MANAGED, 'ffprobe.exe'), 'x')
|
|
|
|
|
expect((await getFfmpegSetupStatus()).ready).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('seeds the managed copies from the dev bundle when present', async () => {
|
|
|
|
|
mkdirSync(join(RES, 'bin'), { recursive: true })
|
|
|
|
|
writeFileSync(join(RES, 'bin', 'ffmpeg.exe'), 'seed')
|
|
|
|
|
writeFileSync(join(RES, 'bin', 'ffprobe.exe'), 'seed')
|
|
|
|
|
const status = await getFfmpegSetupStatus()
|
|
|
|
|
expect(status.ready).toBe(true)
|
|
|
|
|
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('downloadFfmpeg', () => {
|
|
|
|
|
it('downloads, extracts, and installs both binaries into the managed dir', async () => {
|
|
|
|
|
const { wc, sends } = fakeWc()
|
|
|
|
|
const r = await downloadFfmpeg(wc)
|
|
|
|
|
expect(r).toEqual({ ok: true })
|
|
|
|
|
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(true)
|
|
|
|
|
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
|
|
|
|
|
// A downloading tick and the extracting phase both reach the renderer.
|
|
|
|
|
expect(sends).toContainEqual(expect.objectContaining({ phase: 'downloading' }))
|
|
|
|
|
expect(sends).toContainEqual(expect.objectContaining({ phase: 'extracting' }))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('surfaces a download failure and installs nothing', async () => {
|
|
|
|
|
h.streamOk = false
|
|
|
|
|
const { wc } = fakeWc()
|
|
|
|
|
const r = await downloadFfmpeg(wc)
|
|
|
|
|
expect(r.ok).toBe(false)
|
|
|
|
|
expect(r.error).toMatch(/network boom/)
|
|
|
|
|
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('fails when extraction fails, leaving the managed dir empty', async () => {
|
|
|
|
|
h.tarFail = true
|
|
|
|
|
const { wc } = fakeWc()
|
|
|
|
|
const r = await downloadFfmpeg(wc)
|
|
|
|
|
expect(r.ok).toBe(false)
|
|
|
|
|
expect(existsSync(join(MANAGED, 'ffmpeg.exe'))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('fails when the archive lacks the expected binaries', async () => {
|
|
|
|
|
h.extractWrites = false
|
|
|
|
|
const { wc } = fakeWc()
|
|
|
|
|
const r = await downloadFfmpeg(wc)
|
|
|
|
|
expect(r.ok).toBe(false)
|
|
|
|
|
expect(r.error).toMatch(/missing/i)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('locateFfmpegManually', () => {
|
|
|
|
|
function folderWith(files: string[]): string {
|
|
|
|
|
const dir = mkdtempSync(join(ROOT, 'located-'))
|
|
|
|
|
for (const f of files) writeFileSync(join(dir, f), 'x')
|
|
|
|
|
return dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('installs from a folder that holds both runnable binaries', async () => {
|
|
|
|
|
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
|
|
|
|
|
const r = await locateFfmpegManually()
|
|
|
|
|
expect(r).toEqual({ ok: true })
|
|
|
|
|
expect(existsSync(join(MANAGED, 'ffprobe.exe'))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a folder missing a binary', async () => {
|
|
|
|
|
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe'])] }
|
|
|
|
|
const r = await locateFfmpegManually()
|
|
|
|
|
expect(r.ok).toBe(false)
|
|
|
|
|
expect(r.error).toMatch(/doesn't contain/i)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects binaries that will not run', async () => {
|
|
|
|
|
h.validateOk = false
|
|
|
|
|
h.dialog = { canceled: false, filePaths: [folderWith(['ffmpeg.exe', 'ffprobe.exe'])] }
|
|
|
|
|
const r = await locateFfmpegManually()
|
|
|
|
|
expect(r.ok).toBe(false)
|
|
|
|
|
expect(r.error).toMatch(/could not be run/i)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a dismissed folder picker without installing anything', async () => {
|
|
|
|
|
h.dialog = { canceled: true, filePaths: [] }
|
|
|
|
|
const r = await locateFfmpegManually()
|
|
|
|
|
expect(r.ok).toBe(false)
|
|
|
|
|
expect(r.error).toMatch(/no folder selected/i)
|
|
|
|
|
})
|
|
|
|
|
})
|