Files
AeroFetch/test/buildArgs.test.ts
T
debont80 94c5723246 Fix L23/L78/L79: diagnostics hint, empty-state copy, SponsorBlock defaults
L23: Diagnostics error list now shows "Showing 20 of N errors." when there
     are more than 20 logged errors.
L78: DownloadsView empty-state copy updated from "Paste a URL above to get
     started" to "Paste a URL above, then click Download" -- matches the UI.
L79: SponsorBlock default categories expanded from ['sponsor'] to
     ['sponsor', 'selfpromo', 'interaction'] -- sponsor-only was silently
     covering almost nothing; the three new defaults are universally unwanted.
     Existing SB tests updated to pass explicit categories instead of using defaults.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:57:53 -04:00

752 lines
27 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
parseTrimSections,
selectExtraArgs,
formatCommandLine,
sanitizeDirSegment,
collectionOutputTemplate,
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', sponsorBlockCategories: ['sponsor'] })
)
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', sponsorBlockCategories: ['sponsor'] })
)
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('targets the EmbedThumbnail post-processor and uses ffmpeg output args (L39)', () => {
// Structural test: verify the key invariants without locking in the exact string,
// so minor whitespace or quoting style tweaks don't break CI.
expect(CROP_SQUARE_PPA).toMatch(/^EmbedThumbnail\+ffmpeg_o:/)
expect(CROP_SQUARE_PPA).toContain('-c:v mjpeg')
expect(CROP_SQUARE_PPA).toMatch(/-vf\s+crop=/)
// Commas inside gt() MUST be inside quotes so ffmpeg doesn't split on them.
expect(CROP_SQUARE_PPA).toMatch(/crop="[^"]*,/)
})
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')
})
it('suppresses --embed-thumbnail for webm (not supported by the container, L38)', () => {
const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'webm' }))
expect(argv).not.toContain('--embed-thumbnail')
})
it('keeps --embed-thumbnail for mp4 container (L38)', () => {
const argv = build(opts(), dlo({ embedThumbnail: true, videoContainer: 'mp4' }))
expect(argv).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('selectExtraArgs — custom-command consent gate (audit F2)', () => {
const templates = [
{ id: 'thumb', args: '--write-thumbnail' },
{ id: 'danger', args: '--exec "calc.exe"' }
]
it('returns [] when custom commands are disabled, even with a per-download override', () => {
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
expect(
selectExtraArgs({
customCommandEnabled: false,
perDownloadExtraArgs: '--exec "calc.exe"',
defaultTemplateId: 'danger',
templates
})
).toEqual([])
})
it('returns [] when disabled even if a default template id is set', () => {
expect(
selectExtraArgs({
customCommandEnabled: false,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
})
).toEqual([])
})
it('parses a per-download override when enabled', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
defaultTemplateId: null,
templates
})
).toEqual(['--write-thumbnail', '--no-mtime'])
})
it('an explicit empty override yields [] even with a default template set', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: '',
defaultTemplateId: 'thumb',
templates
})
).toEqual([])
})
it('falls back to the default template when no per-download override is given', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
})
).toEqual(['--write-thumbnail'])
})
it('returns [] when the default template id matches nothing', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'missing',
templates
})
).toEqual([])
})
})
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)
})
})
// --- Media-server sidecar files (Phase K) -----------------------------------
describe('sidecar files', () => {
it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => {
const argv = build(
opts(),
dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true })
)
expect(argv).toContain('--write-info-json')
expect(argv).toContain('--write-thumbnail')
expect(argv).toContain('--write-description')
})
it('emits none of them by default', () => {
const argv = build(opts(), dlo())
expect(argv).not.toContain('--write-info-json')
expect(argv).not.toContain('--write-description')
// (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail)
expect(argv).not.toContain('--write-thumbnail')
})
})
describe('YouTube extractor-args', () => {
it('combines player_client and po_token into one youtube: group', () => {
const argv = build(opts(), dlo(), {
...NO_ACCESS,
youtubePlayerClient: 'web_safari',
youtubePoToken: 'web.gvs+ABC'
})
expect(
hasSeq(argv, '--extractor-args', 'youtube:player_client=web_safari;po_token=web.gvs+ABC')
).toBe(true)
})
it('emits only the fields that are set', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' })
expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true)
})
it('emits nothing when both are empty', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--extractor-args')
})
})
describe('format sorting (-S)', () => {
it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => {
const argv = build(
opts(),
dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' })
)
expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true)
expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false)
})
it('falls back to the codec tiebreaker when formatSort is empty', () => {
const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' }))
expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true)
})
})
describe('selectExtraArgs url-pattern matching', () => {
const base = {
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: null as string | null
}
const templates = [
{ id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' },
{ id: 'b', args: '--write-thumbnail' }
]
it('auto-applies a template whose urlPattern matches the URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' })
expect(out).toEqual(['--audio-quality', '0'])
})
it('does not apply a urlPattern template to a non-matching URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' })
expect(out).toEqual([])
})
it('urlPattern match takes precedence over the global default template', () => {
const out = selectExtraArgs({
...base,
defaultTemplateId: 'b',
templates,
url: 'https://soundcloud.com/x'
})
expect(out).toEqual(['--audio-quality', '0'])
})
it('a bad regex never matches (and is skipped safely)', () => {
const out = selectExtraArgs({
...base,
templates: [{ id: 'c', args: '--x', urlPattern: '(' }],
url: 'https://soundcloud.com/x'
})
expect(out).toEqual([])
})
})
describe('chapters', () => {
it('emits --split-chapters when splitChapters is on', () => {
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
})
it('does not emit --split-chapters by default', () => {
expect(build(opts(), dlo())).not.toContain('--split-chapters')
})
it('emits --split-chapters independently of --embed-chapters', () => {
const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false }))
expect(argv).toContain('--split-chapters')
expect(argv).not.toContain('--embed-chapters')
})
})
describe('parseTrimSections', () => {
it('normalises plain time ranges to *START-END specs', () => {
expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00'])
expect(parseTrimSections('90-120')).toEqual(['*90-120'])
expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20'])
})
it('splits on commas and newlines and trims whitespace', () => {
expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([
'*0:30-1:00',
'*2:00-2:30',
'*3:00-3:30'
])
})
it('keeps an existing leading * but does not double it', () => {
expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00'])
})
it('drops malformed tokens so garbage never reaches the argv', () => {
expect(parseTrimSections('not-a-range')).toEqual([])
expect(parseTrimSections('1:30')).toEqual([]) // no end
expect(parseTrimSections('')).toEqual([])
expect(parseTrimSections(undefined)).toEqual([])
})
it('rejects times with more than two colon groups (L146)', () => {
// H:MM:SS is the deepest valid form; a fourth component is nonsense and used
// to slip through to yt-dlp, failing there instead of being rejected here.
expect(parseTrimSections('1:2:3:4-5:6:7:8')).toEqual([])
expect(parseTrimSections('0:00:00:01-0:00:00:02')).toEqual([])
// The deepest valid form still passes.
expect(parseTrimSections('1:02:03-1:02:04')).toEqual(['*1:02:03-1:02:04'])
})
})
describe('trim (--download-sections)', () => {
it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => {
const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo())
expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true)
expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true)
expect(argv).toContain('--force-keyframes-at-cuts')
})
it('emits nothing when there is no trim', () => {
const argv = build(opts(), dlo())
expect(argv).not.toContain('--download-sections')
})
it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => {
const argv = build(
opts({ trim: '0:30-1:00' }),
dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
)
expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1)
})
})
// --- Collection folder paths (Phase G, media-manager) -----------------------
describe('sanitizeDirSegment', () => {
it('keeps an ordinary name (incl. spaces and hyphens) intact', () => {
expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips')
expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers')
})
it('replaces Windows-illegal characters with a space and collapses runs', () => {
expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F')
expect(sanitizeDirSegment('Best of 2024 <Live>')).toBe('Best of 2024 Live')
})
it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => {
expect(sanitizeDirSegment('..')).toBe('Untitled')
expect(sanitizeDirSegment('../../etc')).toBe('etc')
expect(sanitizeDirSegment(' trailing. ')).toBe('trailing')
expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist')
})
it('prefixes reserved Windows device names', () => {
expect(sanitizeDirSegment('CON')).toBe('_CON')
expect(sanitizeDirSegment('com1')).toBe('_com1')
})
it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
// a non-reserved name that merely starts with similar letters is left alone
expect(sanitizeDirSegment('console.log')).toBe('console.log')
})
it('falls back to Untitled for empty / all-illegal input', () => {
expect(sanitizeDirSegment('')).toBe('Untitled')
expect(sanitizeDirSegment('???')).toBe('Untitled')
})
it('caps length to keep the overall path bounded', () => {
expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80)
})
})
describe('collectionOutputTemplate', () => {
const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 }
it('files into <out>/<channel>/<playlist>/<NNN> - <filename>', () => {
const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s')
// normalise separators so the assertion is OS-independent
expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s')
})
it('zero-pads the index to three digits and clamps bad values to 001', () => {
expect(
collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/')
).toContain('/042 - f')
expect(
collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/')
).toContain('/001 - f')
expect(
collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/')
).toContain('/001 - f')
})
it('sanitizes the channel and playlist folder names', () => {
const t = collectionOutputTemplate(
'C:/out',
{ channel: 'A/B', playlist: '..', index: 1 },
'%(title)s.%(ext)s'
)
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
})
})
describe('metadata overrides (--replace-in-metadata)', () => {
it('emits --embed-metadata and --replace-in-metadata for each set field', () => {
const argv = build(opts(), dlo({ embedMetadata: false, metadataTitle: 'My Track' }))
expect(argv).toContain('--embed-metadata')
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'My Track')).toBe(true)
})
it('emits all three override fields when all are set', () => {
const argv = build(opts(), dlo({ metadataTitle: 'T', metadataArtist: 'A', metadataAlbum: 'B' }))
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'T')).toBe(true)
expect(hasSeq(argv, '--replace-in-metadata', 'artist', '^.*$', 'A')).toBe(true)
expect(hasSeq(argv, '--replace-in-metadata', 'album', '^.*$', 'B')).toBe(true)
})
it('passes a value containing a colon as a single arg without splitting', () => {
const argv = build(opts(), dlo({ metadataTitle: 'Foo: Bar' }))
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'Foo: Bar')).toBe(true)
})
it('escapes backslashes in the replacement string', () => {
const argv = build(opts(), dlo({ metadataTitle: 'A\\B' }))
expect(hasSeq(argv, '--replace-in-metadata', 'title', '^.*$', 'A\\\\B')).toBe(true)
})
it('skips blank or whitespace-only values', () => {
const argv = build(opts(), dlo({ metadataTitle: '', metadataArtist: ' ' }))
expect(argv).not.toContain('--replace-in-metadata')
})
it('forces --embed-metadata even when embedMetadata is false and an override is set', () => {
const argv = build(opts(), dlo({ embedMetadata: false, metadataAlbum: 'Soundtrack' }))
expect(argv).toContain('--embed-metadata')
})
it('emits no override flags when no fields are set', () => {
const argv = build(opts(), dlo({ embedMetadata: true }))
expect(argv).not.toContain('--replace-in-metadata')
})
})