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 { return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides } } function audio(overrides: Partial = {}): StartDownloadOptions { return opts({ kind: 'audio', quality: 'Best (MP3)', ...overrides }) } function dlo(overrides: Partial = {}): 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 --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 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 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 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 ', () => { 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 --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 , 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:', () => { 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) }) })