From 309e032cbe1451ab101196f8e60f7488753fa168 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 09:58:45 -0300 Subject: [PATCH 1/7] ci(github): create CodeQL workflow --- .github/workflows/codeql.yaml | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/codeql.yaml diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..8ce5a42 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,41 @@ +name: CodeQL Analysis + +on: + push: + branches: + - main + - refactor + pull_request: + branches: + - main + schedule: + - cron: '0 0 * * 0' + +jobs: + analyze: + name: Analyze Code + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" \ No newline at end of file From 7275739b8953b42917651e3b8ceaa85ff4e6ca3b Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 10:19:17 -0300 Subject: [PATCH 2/7] fix(test): use secure temp dir creation to fix CWE-377 --- tests/e2e/check.test.ts | 10 ++++++---- tests/e2e/merge.test.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/e2e/check.test.ts b/tests/e2e/check.test.ts index 508da62..fcecfa3 100644 --- a/tests/e2e/check.test.ts +++ b/tests/e2e/check.test.ts @@ -1,17 +1,15 @@ import fs from 'fs'; -import { describe, expect, test, afterEach, beforeAll } from 'bun:test'; +import { describe, expect, test, afterEach, beforeAll, afterAll } from 'bun:test'; import { tmpdir } from 'os'; import path from 'path'; import { CliTester, Keys } from '../helpers/cli-tester.ts'; describe('E2E: JellyCC Check Menu', () => { let cli: CliTester; - const fakeHome = path.join(tmpdir(), 'jellycc-test-home-check'); + const fakeHome = fs.mkdtempSync(path.join(tmpdir(), 'jellycc-test-home-check-')); const fixturePath = (name: string) => path.join(process.cwd(), 'tests', 'fixtures', name); beforeAll(() => { - fs.rmSync(fakeHome, { recursive: true, force: true }); - const configDir = path.join(fakeHome, '.config', 'jellycc'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( @@ -29,6 +27,10 @@ describe('E2E: JellyCC Check Menu', () => { if (cli) cli.kill(); }); + afterAll(() => { + fs.rmSync(fakeHome, { recursive: true, force: true }); + }); + const acceptInitialTagEditing = async (downCount: number = 2) => { await cli.waitForText('One or more tracks with unknown language (UND) detected.'); cli.write(Keys.Enter); diff --git a/tests/e2e/merge.test.ts b/tests/e2e/merge.test.ts index 38a06d6..db5963f 100644 --- a/tests/e2e/merge.test.ts +++ b/tests/e2e/merge.test.ts @@ -1,19 +1,17 @@ import fs from 'fs'; import { spawnSync } from 'child_process'; -import { describe, expect, test, afterEach, beforeAll } from 'bun:test'; +import { describe, expect, test, afterEach, beforeAll, afterAll } from 'bun:test'; import { tmpdir } from 'os'; import path from 'path'; import { CliTester, Keys } from '../helpers/cli-tester.ts'; describe('E2E: JellyCC Merge Menu', () => { let cli: CliTester; - const fakeHome = path.join(tmpdir(), 'jellycc-test-home-merge'); + const fakeHome = fs.mkdtempSync(path.join(tmpdir(), 'jellycc-test-home-merge-')); const fixturePath = (name: string) => path.join(process.cwd(), 'tests', 'fixtures', name); const longMergeFixture = path.join(fakeHome, 'merge-long.mkv'); beforeAll(() => { - fs.rmSync(fakeHome, { recursive: true, force: true }); - const configDir = path.join(fakeHome, '.config', 'jellycc'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( @@ -55,6 +53,10 @@ describe('E2E: JellyCC Merge Menu', () => { if (cli) cli.kill(); }); + afterAll(() => { + fs.rmSync(fakeHome, { recursive: true, force: true }); + }); + const openMergePaths = async (pathA: string, pathB: string) => { await cli.waitForText('File A Path (Base/Reference):'); cli.write(pathA); From f357394068bd754aa03b623220d94c3082fd84e3 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 10:29:57 -0300 Subject: [PATCH 3/7] fix(i18n): remove fs.existsSync to fix TOCTOU race condition (CWE-367) --- src/utils/i18n.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index e732da5..19325b6 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -14,13 +14,13 @@ const dictionaries = { } as const; function detectLanguage(): keyof typeof dictionaries { - if (fs.existsSync(CONFIG_PATH)) { - try { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) as Partial; - if (config.lang && config.lang in dictionaries) { - return config.lang; - } - } catch (e) {} + try { + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) as Partial; + if (config.lang && config.lang in dictionaries) { + return config.lang; + } + } catch (e) { + // Ignored: File doesn't exist or JSON is invalid } const sysLocale = Intl.DateTimeFormat().resolvedOptions().locale; @@ -48,15 +48,16 @@ export function setLanguage(lang: string) { if (!(lang in dictionaries)) throw new Error(`Idioma ${lang} não suportado.`); let config: Partial = {}; - if (fs.existsSync(CONFIG_PATH)) { - try { - config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) as Partial; - } catch (e) { - config = {}; - } + + try { + config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) as Partial; + } catch (e) { + config = {}; } config.lang = lang as UserSettings['lang']; + + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); } From ce4a81f32e2dcea362cd8ea649681bba13ab5026 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 10:39:29 -0300 Subject: [PATCH 4/7] fix(ffprobe): migrate to execFileSync to prevent injection (CWE-78) --- src/utils/ffprobe.test.ts | 14 +++++++------- src/utils/ffprobe.ts | 31 +++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/utils/ffprobe.test.ts b/src/utils/ffprobe.test.ts index ccf2ddf..78cc046 100644 --- a/src/utils/ffprobe.test.ts +++ b/src/utils/ffprobe.test.ts @@ -11,20 +11,20 @@ mock.module('@clack/prompts', () => ({ })); describe('utils/ffprobe.ts', () => { - const execSyncSpy = spyOn(child_process, 'execSync'); + const execFileSyncSpy = spyOn(child_process, 'execFileSync'); afterEach(() => { - execSyncSpy.mockClear(); + execFileSyncSpy.mockClear(); }); test('runQuickScan should complete successfully on valid media', () => { - execSyncSpy.mockReturnValueOnce(Buffer.from('')); + execFileSyncSpy.mockReturnValueOnce(Buffer.from('')); expect(() => runQuickScan('valid.mkv')).not.toThrow(); }); test('runQuickScan should throw ValidationError when media is corrupted', () => { - execSyncSpy.mockImplementationOnce(() => { + execFileSyncSpy.mockImplementationOnce(() => { throw new Error('Command failed'); }); @@ -47,7 +47,7 @@ describe('utils/ffprobe.ts', () => { }); test('runQuickScan should throw JellyError when ffprobe is missing', () => { - execSyncSpy.mockImplementationOnce(() => { + execFileSyncSpy.mockImplementationOnce(() => { const err = new Error('spawn ENOENT'); (err as any).code = 'ENOENT'; throw err; @@ -73,7 +73,7 @@ describe('utils/ffprobe.ts', () => { test('getMediaInfo should return parsed JSON data', () => { const mockData = { format: { format_name: 'matroska' }, streams: [] }; - execSyncSpy.mockReturnValueOnce(Buffer.from(JSON.stringify(mockData))); + execFileSyncSpy.mockReturnValueOnce(Buffer.from(JSON.stringify(mockData))); const result = getMediaInfo('video.mkv'); @@ -84,7 +84,7 @@ describe('utils/ffprobe.ts', () => { }); test('getMediaInfo should throw JellyError on execution failure', () => { - execSyncSpy.mockImplementationOnce(() => { + execFileSyncSpy.mockImplementationOnce(() => { throw new Error('Parse fail'); }); diff --git a/src/utils/ffprobe.ts b/src/utils/ffprobe.ts index fbe643b..7de6710 100644 --- a/src/utils/ffprobe.ts +++ b/src/utils/ffprobe.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { spinner } from '@clack/prompts'; import pc from 'picocolors'; import { t } from './i18n.ts'; @@ -9,7 +9,16 @@ export function runQuickScan(videoPath: string) { const qsSpinner = spinner(); qsSpinner.start(t('scanQuickStart')); try { - execSync(`ffprobe -v error -show_entries format -of default=noprint_wrappers=1 "${videoPath}"`, { stdio: 'pipe' }); + execFileSync( + 'ffprobe', + [ + '-v', 'error', + '-show_entries', 'format', + '-of', 'default=noprint_wrappers=1', + videoPath + ], + { stdio: 'pipe' } + ); qsSpinner.stop(pc.green(t('scanQuickPass'))); } catch (err) { qsSpinner.stop(pc.red(t('scanQuickFail'))); @@ -25,13 +34,23 @@ export function getMediaInfo(videoPath: string): FFprobeData { s.start(t('scanAnalyze')); try { - const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`; - const result = execSync(cmd, { encoding: 'utf-8' }); - const probeData = JSON.parse(result) as FFprobeData; + const resultBuffer = execFileSync( + 'ffprobe', + [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', + videoPath + ], + { encoding: 'utf-8' } + ); + + const probeData = JSON.parse(resultBuffer as string) as FFprobeData; s.stop(t('scanAnalyzeDone')); return probeData; } catch (err) { s.stop(pc.red(t('scanAnalyzeErr'))); throw new JellyError(t('scanAnalyzeErr'), 'FFPROBE_JSON_ERROR'); } -} +} \ No newline at end of file From 495c267895c2aff8fa422e0383a1b25bd720f4fb Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 10:55:07 -0300 Subject: [PATCH 5/7] refactor(ui): clean up menu execution logic to fix CWE-570/563 --- src/utils/ui.ts | 74 ++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 7dfcb47..e1e5997 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -40,12 +40,10 @@ export async function handleExecutionMenu(options: { allowSyncAdjustment?: boolean; allowMyopicScan?: boolean; }): Promise { - let action = 'exit'; - let keepMenuOpen = true; let dsCompleted = options.deepScanCompleted || false; let fileHasErrors = options.hasErrors || false; - while (keepMenuOpen) { + while (true) { const menuOptions: Array<{ label: string; value: string }> = []; if (!options.isPerfect) { @@ -85,7 +83,7 @@ export async function handleExecutionMenu(options: { menuOptions.push({ label: t('exit'), value: 'exit' }); - action = onCancel(await select({ + const action = onCancel(await select({ message: t('whatToDo'), options: menuOptions })); @@ -93,45 +91,49 @@ export async function handleExecutionMenu(options: { if (action === 'deep_scan_selected') { fileHasErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); dsCompleted = true; - } else if (action === 'deep_scan_full') { + continue; + } + + if (action === 'deep_scan_full') { fileHasErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); dsCompleted = true; - } else if (action === 'select_streams' || action === 'adjust_sync' || action === 'edit_tags') { - return { action, deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; - } else { - keepMenuOpen = false; + continue; } - } - const runActions = ['run', 'run_and_scan', 'run_repair', 'run_repair_and_scan']; - - if (action && runActions.includes(action)) { - const isRepair = action === 'run_repair' || action === 'run_repair_and_scan'; - const cmdToRun = isRepair ? options.ffmpegRepairCmd! : options.ffmpegCmd; - - let actualOutputPath = options.outputPath; - if (isRepair) { - actualOutputPath = getRepairOutputPath(options.outputPath); + if (action === 'select_streams' || action === 'adjust_sync' || action === 'edit_tags') { + return { action, deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; } - await runConversion(cmdToRun, options.totalDuration, options.totalFrames); + const runActions = ['run', 'run_and_scan', 'run_repair', 'run_repair_and_scan']; - if (action === 'run_and_scan' || action === 'run_repair_and_scan') { - await runDeepScan([actualOutputPath], ['0'], options.totalDuration); - } + if (runActions.includes(action)) { + const isRepair = action === 'run_repair' || action === 'run_repair_and_scan'; + const cmdToRun = isRepair ? options.ffmpegRepairCmd! : options.ffmpegCmd; + + let actualOutputPath = options.outputPath; + if (isRepair) { + actualOutputPath = getRepairOutputPath(options.outputPath); + } - const successMsg = options.isMerge ? t('successMerge') : t('successOp'); - outro(pc.green(successMsg)); - return { action: 'done', deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; - } else if (action === 'exit') { - if (!options.isPerfect) { - console.log(`\n${pc.dim(t('cleanCmdGenerated'))}\n${pc.yellow(options.ffmpegCmd)}\n`); + await runConversion(cmdToRun, options.totalDuration, options.totalFrames); + + if (action === 'run_and_scan' || action === 'run_repair_and_scan') { + await runDeepScan([actualOutputPath], ['0'], options.totalDuration); + } + + const successMsg = options.isMerge ? t('successMerge') : t('successOp'); + outro(pc.green(successMsg)); + return { action: 'done', deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; + } + + if (action === 'exit') { + if (!options.isPerfect) { + console.log(`\n${pc.dim(t('cleanCmdGenerated'))}\n${pc.yellow(options.ffmpegCmd)}\n`); + } + outro(t('opFinished')); + return { action: 'exit', deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; } - outro(t('opFinished')); - return { action: 'exit', deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; } - - return { action: 'exit', deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; } export async function editTagsMenu( @@ -163,8 +165,7 @@ export async function editTagsMenu( if (editing === false) return selectedStreams; } - let looping = true; - while (looping) { + while (true) { const options = selectedStreams.map((s, index) => { const typeLabel = s.type === 'subtitle' ? t('typeSub') : (s.type === 'audio' ? t('typeAudio') : t('typeVideo')); let label = `[${typeLabel}] ${s.codec.toUpperCase()}`; @@ -183,7 +184,6 @@ export async function editTagsMenu( })); if (pickedIdx === -1) { - looping = false; break; } @@ -208,4 +208,4 @@ export async function editTagsMenu( } return selectedStreams; -} +} \ No newline at end of file From 398277990931271e3a99f58cf2deaa3ca331f2d9 Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 11:58:07 -0300 Subject: [PATCH 6/7] test(ui): refator ui.test to improve legibility --- src/utils/ui.test.ts | 112 ++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 72 deletions(-) diff --git a/src/utils/ui.test.ts b/src/utils/ui.test.ts index 4b08065..dbf59d6 100644 --- a/src/utils/ui.test.ts +++ b/src/utils/ui.test.ts @@ -32,22 +32,15 @@ describe('utils/ui.ts', () => { }); test('onCancel should throw UserCancelError on cancel symbol or return the value', () => { - const cancelSymbol = Symbol.for('cancel'); let caughtError: any; - try { - onCancel(cancelSymbol); + onCancel(Symbol.for('cancel')); } catch (e) { caughtError = e; } - expect({ - errorType: caughtError?.name, - validReturn: onCancel('valid_input') - }).toMatchObject({ - errorType: 'UserCancelError', - validReturn: 'valid_input' - }); + expect(caughtError?.name).toBe('UserCancelError'); + expect(onCancel('valid_input')).toBe('valid_input'); }); test('handleExecutionMenu should return exit state immediately when exit is selected', async () => { @@ -62,82 +55,69 @@ describe('utils/ui.ts', () => { totalFrames: 2400 }); - expect(result).toMatchObject({ - action: 'exit', - deepScanCompleted: false, - hasErrors: false - }); + expect(result.action).toBe('exit'); }); - test('handleExecutionMenu should return immediately for menu actions that modify state', async () => { + test('handleExecutionMenu should return immediately for state modifiers (select_streams, adjust_sync, edit_tags)', async () => { + const baseOpts = { ffmpegCmd: 'cmd', fullScanInputs: [], fullScanMaps: [], outputPath: 'out', totalDuration: 100, totalFrames: 24 }; + (clack.select as any).mockResolvedValueOnce('select_streams'); + expect((await handleExecutionMenu({ ...baseOpts, allowStreamSelection: true })).action).toBe('select_streams'); - const result = await handleExecutionMenu({ - ffmpegCmd: 'cmd', - fullScanInputs: [], - fullScanMaps: [], - outputPath: 'out.mkv', - totalDuration: 100, - totalFrames: 2400, - allowStreamSelection: true - }); + (clack.select as any).mockResolvedValueOnce('adjust_sync'); + expect((await handleExecutionMenu({ ...baseOpts, allowSyncAdjustment: true })).action).toBe('adjust_sync'); - expect(result).toMatchObject({ - action: 'select_streams', - deepScanCompleted: false, - hasErrors: false - }); + (clack.select as any).mockResolvedValueOnce('edit_tags'); + expect((await handleExecutionMenu({ ...baseOpts, allowStreamSelection: true })).action).toBe('edit_tags'); }); - test('handleExecutionMenu should execute deep scan and loop back to menu', async () => { + test('handleExecutionMenu should execute partial and full deep scans and loop back', async () => { (clack.select as any) .mockResolvedValueOnce('deep_scan_selected') + .mockResolvedValueOnce('deep_scan_full') .mockResolvedValueOnce('exit'); - const runScanSpy = spyOn(ffmpeg, 'runDeepScan').mockResolvedValueOnce(true as any); + const runScanSpy = spyOn(ffmpeg, 'runDeepScan').mockResolvedValue(true as any); + const result = await handleExecutionMenu({ ffmpegCmd: 'cmd', - fullScanInputs: [], - fullScanMaps: [], - selectedScanInputs: ['in.mkv'], - selectedScanMaps: ['0'], + fullScanInputs: ['full.mkv'], + fullScanMaps: ['0'], + selectedScanInputs: ['sel.mkv'], + selectedScanMaps: ['0:0'], outputPath: 'out.mkv', totalDuration: 100, totalFrames: 2400, allowMyopicScan: true }); - expect(runScanSpy).toHaveBeenCalled(); - expect(result).toMatchObject({ - action: 'exit', - deepScanCompleted: true, - hasErrors: true - }); + expect(runScanSpy).toHaveBeenCalledTimes(2); + expect(runScanSpy).toHaveBeenNthCalledWith(1, ['sel.mkv'], ['0:0'], 100); // selected + expect(runScanSpy).toHaveBeenNthCalledWith(2, ['full.mkv'], ['0'], 100); // full + expect(result.action).toBe('exit'); }); - test('handleExecutionMenu should execute conversion and return done state', async () => { - (clack.select as any).mockResolvedValueOnce('run'); + test('handleExecutionMenu should execute normal conversion with deep scan', async () => { + (clack.select as any).mockResolvedValueOnce('run_and_scan'); const runConvSpy = spyOn(ffmpeg, 'runConversion').mockResolvedValueOnce(undefined as any); + const runScanSpy = spyOn(ffmpeg, 'runDeepScan').mockResolvedValueOnce(false as any); const result = await handleExecutionMenu({ - ffmpegCmd: 'ffmpeg cmd', + ffmpegCmd: 'normal_cmd', fullScanInputs: [], fullScanMaps: [], - outputPath: 'out.mkv', + outputPath: '/fake/out.mkv', totalDuration: 100, totalFrames: 2400 }); - expect(runConvSpy).toHaveBeenCalled(); - expect(result).toMatchObject({ - action: 'done', - deepScanCompleted: false, - hasErrors: false - }); + expect(runConvSpy).toHaveBeenCalledWith('normal_cmd', 100, 2400); + expect(runScanSpy).toHaveBeenCalledWith(['/fake/out.mkv'], ['0'], 100); + expect(result.action).toBe('done'); }); - test('handleExecutionMenu should run repair command and subsequent scan', async () => { + test('handleExecutionMenu should execute repair conversion, scan with correct output path, and exit', async () => { (clack.select as any).mockResolvedValueOnce('run_repair_and_scan'); const runConvSpy = spyOn(ffmpeg, 'runConversion').mockResolvedValueOnce(undefined as any); @@ -155,10 +135,11 @@ describe('utils/ui.ts', () => { }); expect(runConvSpy).toHaveBeenCalledWith('repair_cmd', 100, 2400); - expect(runScanSpy).toHaveBeenCalled(); + expect(runScanSpy).toHaveBeenCalledWith(['/fake/out_repaired.mkv'], ['0'], 100); expect(result.action).toBe('done'); }); + // --- editTagsMenu --- test('editTagsMenu should initialize missing tags from ffprobe data and allow early exit', async () => { (clack.select as any).mockResolvedValueOnce(-1); @@ -167,10 +148,7 @@ describe('utils/ui.ts', () => { const result = await editTagsMenu(selectedStreams, infoA); - expect(result[0]).toMatchObject({ - language: 'por', - title: 'Dublado' - }); + expect(result[0]).toMatchObject({ language: 'por', title: 'Dublado' }); }); test('editTagsMenu should map from infoB for secondary files and handle subtitles', async () => { @@ -182,19 +160,14 @@ describe('utils/ui.ts', () => { const result = await editTagsMenu(selectedStreams, infoA, infoB); - expect(result[0]).toMatchObject({ - language: 'eng', - title: 'Subs' - }); + expect(result[0]).toMatchObject({ language: 'eng', title: 'Subs' }); }); test('editTagsMenu should skip autoPromptUnd if no undefined non-video languages exist', async () => { const selectedStreams = [{ streamIndex: 0, type: 'video', codec: 'h264', language: 'und', title: '' } as any]; const infoA = { streams: [{ index: 0 }] } as any; - const result = await editTagsMenu(selectedStreams, infoA, undefined, true); - - expect(result).toMatchObject(selectedStreams); + expect(await editTagsMenu(selectedStreams, infoA, undefined, true)).toMatchObject(selectedStreams); }); test('editTagsMenu should handle autoPromptUnd rejection', async () => { @@ -203,9 +176,7 @@ describe('utils/ui.ts', () => { const selectedStreams = [{ streamIndex: 0, type: 'audio', codec: 'aac', language: 'und', title: '' } as any]; const infoA = { streams: [{ index: 0 }] } as any; - const result = await editTagsMenu(selectedStreams, infoA, undefined, true); - - expect(result).toMatchObject(selectedStreams); + expect(await editTagsMenu(selectedStreams, infoA, undefined, true)).toMatchObject(selectedStreams); }); test('editTagsMenu should update stream tags through interactive text prompts', async () => { @@ -221,9 +192,6 @@ describe('utils/ui.ts', () => { const result = await editTagsMenu(selectedStreams, infoA); - expect(result[0]).toMatchObject({ - language: 'jpn', - title: 'Original Mix' - }); + expect(result[0]).toMatchObject({ language: 'jpn', title: 'Original Mix' }); }); }); \ No newline at end of file From 6485173cb9c2dba537cc702a5e85aa77dde93ded Mon Sep 17 00:00:00 2001 From: Patrick Luan Date: Mon, 25 May 2026 12:09:58 -0300 Subject: [PATCH 7/7] test(i18n): add detectLanguage test coverage --- src/utils/i18n.test.ts | 9 ++++++++- src/utils/i18n.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/i18n.test.ts b/src/utils/i18n.test.ts index cadf80a..05a1293 100644 --- a/src/utils/i18n.test.ts +++ b/src/utils/i18n.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, spyOn } from 'bun:test'; import fs from 'fs'; -import { t, setLanguage, availableLanguages } from './i18n.ts'; +import { t, setLanguage, availableLanguages, detectLanguage } from './i18n.ts'; describe('utils/i18n.ts', () => { test('availableLanguages should expose only supported locales', () => { @@ -38,4 +38,11 @@ describe('utils/i18n.ts', () => { writeSpy.mockRestore(); }); + + test('detectLanguage should return lang from config file if valid', () => { + const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ lang: 'pt-BR' })); + + expect(detectLanguage()).toBe('pt-BR'); + readSpy.mockRestore(); + }); }); \ No newline at end of file diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 19325b6..f5625ec 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -13,7 +13,7 @@ const dictionaries = { 'en-US': enUS } as const; -function detectLanguage(): keyof typeof dictionaries { +export function detectLanguage(): keyof typeof dictionaries { try { const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) as Partial; if (config.lang && config.lang in dictionaries) {