Files
AeroFetch/test/buildArgs.test.ts
T
debont80 a822a2bb52 Complete Phase C (custom commands & yt-dlp updater) and Phase D (library, UX & notifications)
Phase C had been implemented in the working tree but never committed:
named custom-command templates (CRUD + per-download override), command
preview, and the in-app yt-dlp self-updater.

Phase D adds: history search/kind-filter/multi-select/bulk-delete/
re-download; native OS notifications on completion/failure; a private/
incognito download mode that skips history; settings+template backup/
restore via JSON; and a persistent error log surfaced as a Diagnostics
report in Settings.

See ROADMAP.md for the full per-item breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 06:19:45 -04:00

363 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
formatCommandLine,
CROP_SQUARE_PPA,
ARIA2C_ARGS,
type AccessOptions
} from '../src/main/buildArgs'
import {
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
type StartDownloadOptions
} from '@shared/ipc'
// --- Fixtures ---------------------------------------------------------------
const BIN = 'C:/fake/bin'
const OUT = 'C:/out/%(title)s.%(ext)s'
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const NO_ACCESS: AccessOptions = { proxy: '', rateLimit: '', restrictFilenames: false }
function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides }
}
function audio(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
return opts({ kind: 'audio', quality: 'Best (MP3)', ...overrides })
}
function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
}
function build(
o: StartDownloadOptions,
d: DownloadOptions,
access: AccessOptions = NO_ACCESS,
extraArgs: string[] = []
): string[] {
return buildArgs(o, OUT, d, BIN, access, extraArgs)
}
/** True when `seq` appears as a contiguous run inside `argv`. */
function hasSeq(argv: string[], ...seq: string[]): boolean {
if (seq.length === 0) return true
for (let i = 0; i + seq.length <= argv.length; i++) {
if (seq.every((s, j) => argv[i + j] === s)) return true
}
return false
}
// --- Invariants shared by every recipe --------------------------------------
describe('buildArgs scaffolding', () => {
it('pins ffmpeg location to the supplied binDir', () => {
expect(hasSeq(build(opts(), dlo()), '--ffmpeg-location', BIN)).toBe(true)
})
it('terminates options with `--` immediately before the URL', () => {
const argv = build(opts(), dlo())
expect(argv[argv.length - 2]).toBe('--')
expect(argv[argv.length - 1]).toBe(URL)
})
})
// --- Network: proxy / rate limit / aria2c -----------------------------------
describe('network options', () => {
it('emits nothing when proxy/rateLimit are empty and aria2c is unset', () => {
const argv = build(opts(), dlo(), NO_ACCESS)
expect(argv).not.toContain('--proxy')
expect(argv).not.toContain('--limit-rate')
expect(argv).not.toContain('--downloader')
})
it('emits --proxy with the trimmed value', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, proxy: ' socks5://127.0.0.1:1080 ' })
expect(hasSeq(argv, '--proxy', 'socks5://127.0.0.1:1080')).toBe(true)
})
it('emits --limit-rate with the trimmed value', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, rateLimit: ' 2M ' })
expect(hasSeq(argv, '--limit-rate', '2M')).toBe(true)
})
it('emits --downloader <path> --downloader-args when an aria2c path is given', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, aria2cPath: 'C:/fake/bin/aria2c.exe' })
expect(hasSeq(argv, '--downloader', 'C:/fake/bin/aria2c.exe', '--downloader-args', ARIA2C_ARGS)).toBe(
true
)
})
it('omits --downloader when aria2cPath is not set', () => {
const argv = build(opts(), dlo(), NO_ACCESS)
expect(argv).not.toContain('--downloader-args')
})
})
// --- Cookies: browser store vs. sign-in window's exported file --------------
describe('cookies', () => {
it('emits nothing when no cookie source is configured', () => {
const argv = build(opts(), dlo(), NO_ACCESS)
expect(argv).not.toContain('--cookies-from-browser')
expect(argv).not.toContain('--cookies')
})
it('emits --cookies-from-browser <name> when cookiesFromBrowser is set', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFromBrowser: 'firefox' })
expect(hasSeq(argv, '--cookies-from-browser', 'firefox')).toBe(true)
expect(argv).not.toContain('--cookies')
})
it('emits --cookies <path> when only cookiesFile is set', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFile: 'C:/userdata/cookies.txt' })
expect(hasSeq(argv, '--cookies', 'C:/userdata/cookies.txt')).toBe(true)
expect(argv).not.toContain('--cookies-from-browser')
})
it('prefers cookiesFromBrowser over cookiesFile when both are set', () => {
const argv = build(opts(), dlo(), {
...NO_ACCESS,
cookiesFromBrowser: 'chrome',
cookiesFile: 'C:/userdata/cookies.txt'
})
expect(hasSeq(argv, '--cookies-from-browser', 'chrome')).toBe(true)
expect(argv).not.toContain('--cookies')
})
})
// --- Restrict filenames / download archive -----------------------------------
describe('restrictFilenames / downloadArchivePath', () => {
it('emits --restrict-filenames when set', () => {
expect(build(opts(), dlo(), { ...NO_ACCESS, restrictFilenames: true })).toContain(
'--restrict-filenames'
)
})
it('omits --restrict-filenames by default', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--restrict-filenames')
})
it('emits --download-archive <path> when a path is given', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, downloadArchivePath: 'C:/userdata/archive.txt' })
expect(hasSeq(argv, '--download-archive', 'C:/userdata/archive.txt')).toBe(true)
})
it('omits --download-archive when no path is given', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--download-archive')
})
})
// --- Audio extraction format ------------------------------------------------
describe('audio: -x --audio-format <fmt>', () => {
it.each(['mp3', 'opus', 'flac'] as const)('emits -x --audio-format %s', (fmt) => {
const argv = build(audio(), dlo({ audioFormat: fmt }))
expect(hasSeq(argv, '-x', '--audio-format', fmt)).toBe(true)
})
it('maps the quality label to an --audio-quality value', () => {
expect(hasSeq(build(audio({ quality: 'Best (MP3)' }), dlo()), '--audio-quality', '0')).toBe(true)
expect(hasSeq(build(audio({ quality: '320 kbps' }), dlo()), '--audio-quality', '320K')).toBe(
true
)
expect(hasSeq(build(audio({ quality: '128 kbps' }), dlo()), '--audio-quality', '128K')).toBe(
true
)
})
})
// --- Subtitles (video only) -------------------------------------------------
describe('embedSubtitles', () => {
it('emits write/embed/sub-langs/convert without auto-subs by default', () => {
const argv = build(opts(), dlo({ embedSubtitles: true }))
expect(
hasSeq(argv, '--write-subs', '--embed-subs', '--sub-langs', 'en', '--convert-subs', 'srt')
).toBe(true)
expect(argv).not.toContain('--write-auto-subs')
})
it('inserts --write-auto-subs before --convert-subs when autoSubtitles is on', () => {
const argv = build(opts(), dlo({ embedSubtitles: true, autoSubtitles: true }))
expect(
hasSeq(
argv,
'--write-subs',
'--embed-subs',
'--sub-langs',
'en',
'--write-auto-subs',
'--convert-subs',
'srt'
)
).toBe(true)
})
it('honours a custom subtitleLanguages selector', () => {
const argv = build(opts(), dlo({ embedSubtitles: true, subtitleLanguages: 'en.*,es' }))
expect(hasSeq(argv, '--sub-langs', 'en.*,es')).toBe(true)
})
it('never writes subtitles for an audio download', () => {
const argv = build(audio(), dlo({ embedSubtitles: true, autoSubtitles: true }))
expect(argv).not.toContain('--write-subs')
expect(argv).not.toContain('--embed-subs')
})
})
// --- SponsorBlock -----------------------------------------------------------
describe('sponsorBlock', () => {
it('remove → --sponsorblock-remove <cats> --force-keyframes-at-cuts', () => {
const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'remove' }))
expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor', '--force-keyframes-at-cuts')).toBe(true)
expect(argv).not.toContain('--sponsorblock-mark')
})
it('mark → --sponsorblock-mark <cats>, no keyframe forcing', () => {
const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'mark' }))
expect(hasSeq(argv, '--sponsorblock-mark', 'sponsor')).toBe(true)
expect(argv).not.toContain('--sponsorblock-remove')
expect(argv).not.toContain('--force-keyframes-at-cuts')
})
it('joins multiple categories with commas', () => {
const argv = build(
opts(),
dlo({ sponsorBlock: true, sponsorBlockCategories: ['sponsor', 'intro', 'outro'] })
)
expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor,intro,outro')).toBe(true)
})
it('emits nothing when sponsorBlock is off or category list is empty', () => {
expect(build(opts(), dlo({ sponsorBlock: false })).join(' ')).not.toContain('sponsorblock')
expect(
build(opts(), dlo({ sponsorBlock: true, sponsorBlockCategories: [] })).join(' ')
).not.toContain('sponsorblock')
})
})
// --- Preferred video codec (sort tiebreaker) --------------------------------
describe('preferredVideoCodec → -S res,fps,vcodec:<token>', () => {
it('maps av1 to the av01 codec token', () => {
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'av1' })), '-S', 'res,fps,vcodec:av01')).toBe(
true
)
})
it('passes h264 / vp9 through verbatim', () => {
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'h264' })), '-S', 'res,fps,vcodec:h264')).toBe(
true
)
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'vp9' })), '-S', 'res,fps,vcodec:vp9')).toBe(
true
)
})
it('omits -S entirely when codec preference is "any"', () => {
expect(build(opts(), dlo({ preferredVideoCodec: 'any' }))).not.toContain('-S')
})
})
// --- Cropped square cover art (the highest-risk ffmpeg quoting) --------------
describe('cropThumbnail → --ppa crop', () => {
it('emits --embed-thumbnail then the square-crop --ppa string', () => {
const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: true }))
expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
})
it('uses single-quoted, comma-protected crop expressions for ffmpeg', () => {
// The commas live inside gt(...) and MUST stay quoted so ffmpeg does not read
// them as filtergraph separators. The targeted-postprocessor key plus the
// ffmpeg_o output-args selector frame the whole value.
expect(CROP_SQUARE_PPA).toBe(
"EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
)
})
it('does not add --ppa when cropThumbnail is off', () => {
const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: false }))
expect(argv).not.toContain('--ppa')
expect(argv).toContain('--embed-thumbnail')
})
it('does not add --ppa when the thumbnail itself is not embedded', () => {
const argv = build(audio(), dlo({ embedThumbnail: false, cropThumbnail: true }))
expect(argv).not.toContain('--ppa')
expect(argv).not.toContain('--embed-thumbnail')
})
})
// --- Custom commands (Phase C): extra args + display formatting ------------
describe('parseExtraArgs', () => {
it('splits on whitespace', () => {
expect(parseExtraArgs('--write-thumbnail --no-mtime')).toEqual([
'--write-thumbnail',
'--no-mtime'
])
})
it('keeps a double-quoted span with spaces as one token', () => {
expect(parseExtraArgs('--proxy "http://example.com:8080"')).toEqual([
'--proxy',
'http://example.com:8080'
])
})
it('keeps a single-quoted span with spaces as one token', () => {
expect(parseExtraArgs("--output-na 'My Title - %(id)s.%(ext)s'")).toEqual([
'--output-na',
'My Title - %(id)s.%(ext)s'
])
})
it('returns an empty array for blank input', () => {
expect(parseExtraArgs('')).toEqual([])
expect(parseExtraArgs(' ')).toEqual([])
})
it('collapses repeated whitespace between tokens', () => {
expect(parseExtraArgs('--verbose --no-progress')).toEqual(['--verbose', '--no-progress'])
})
})
describe('formatCommandLine', () => {
it('joins the exe and args with spaces when nothing needs quoting', () => {
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
'yt-dlp.exe -f best -- https://x.test/v'
)
})
it('quotes an arg containing a space', () => {
expect(formatCommandLine('yt-dlp.exe', ['--ppa', 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg'])).toBe(
'yt-dlp.exe --ppa "EmbedThumbnail+ffmpeg_o:-c:v mjpeg"'
)
})
it('escapes embedded double quotes', () => {
expect(formatCommandLine('yt-dlp.exe', ['say "hi"'])).toBe('yt-dlp.exe "say \\"hi\\""')
})
it('renders an empty-string arg as ""', () => {
expect(formatCommandLine('yt-dlp.exe', [''])).toBe('yt-dlp.exe ""')
})
})
describe('buildArgs extraArgs', () => {
it('appends extraArgs after every other option, still before -- url', () => {
const argv = build(opts(), dlo(), NO_ACCESS, ['--write-thumbnail', '--no-mtime'])
expect(hasSeq(argv, '--write-thumbnail', '--no-mtime', '--', URL)).toBe(true)
})
it('omits nothing extra when extraArgs is empty', () => {
const withEmpty = build(opts(), dlo(), NO_ACCESS, [])
const withoutParam = build(opts(), dlo())
expect(withEmpty).toEqual(withoutParam)
})
})