import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vm from 'node:vm'; import * as ts from 'typescript'; import { describe, expect, it } from 'vitest'; type Input = { value: string; checked: boolean; }; const inputIds = [ 'clientId', 'clientSecret', 'sidebarSplitViewToggle', 'downloadMode', 'partMinutes', 'parallelDownloads', 'performanceMode', 'smartSchedulerToggle', 'duplicatePreventionToggle', 'persistQueueToggle', 'autoResumeQueueToggle', 'notifyEachCompletionToggle', 'streamlinkDisableAdsToggle', 'downloadChatReplayToggle', 'captureLiveChatToggle', 'logStreamEventsToggle', 'autoResumeLiveRecordingToggle', 'autoMergeResumedPartsToggle', 'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', 'discordNotifyVodAutoQueuedToggle', 'autoVodPollMinutes', 'autoVodMaxAgeHours', 'autoCleanupEnabledToggle', 'autoCleanupDays', 'autoCleanupTarget', 'autoCleanupAction', 'streamlinkQuality', 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate', 'downloadThrottleMiBps', 'downloadWindows', 'downloadPolicyValidation' ]; function createInput(value = '', checked = false): Input { return { value, checked }; } class InteractiveInput { value = ''; checked = false; disabled = false; textContent = ''; className = ''; readonly classList = { add: (..._tokens: string[]) => undefined, remove: (..._tokens: string[]) => undefined, contains: (_token: string) => false, toggle: (_token: string, force?: boolean) => force ?? false }; private readonly listeners = new Map void>>(); addEventListener(type: string, listener: () => void): void { this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); } dispatch(type: string): void { for (const listener of this.listeners.get(type) ?? []) listener(); } setAttribute(_name: string, _value: string): void { } select(): void { } } type AutosaveRuntime = { inputs: Map; saveConfigCalls: Array>; scheduled: Array<() => void>; }; function createAutosaveRuntime(): AutosaveRuntime { const inputs = new Map(inputIds.map((id) => [id, new InteractiveInput()])); for (const id of ['partMinutesLabel', 'downloadPolicyStatus', 'templateLint']) { inputs.set(id, new InteractiveInput()); } const saveConfigCalls: Array> = []; const scheduled: Array<() => void> = []; const config = { download_policy: { throttle: null, windows: [] }, auto_resume_live_recording: true, auto_merge_resumed_parts: false, delete_parts_after_merge: false, discord_notify_vod_auto_queued: false, auto_vod_download_poll_minutes: 15, auto_vod_max_age_hours: 24 }; const window = { api: { setClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), getDownloadPolicyStatus: () => Promise.resolve({ waiting: false, nextStart: null }), onDownloadPolicyStatus: () => undefined, saveConfig(payload: Record) { saveConfigCalls.push(payload); return Promise.resolve(payload); } }, setTimeout(callback: () => void) { scheduled.push(callback); return scheduled.length; }, clearTimeout: () => undefined, addEventListener: () => undefined }; const sandbox = { window, config, UI_TEXT: { status: {}, static: { downloadThrottleInvalid: 'Invalid rate', downloadWindowsInvalid: 'Invalid window', downloadPolicyReady: 'Ready', downloadPolicyWaiting: 'Waiting until {time}', templateLintOk: 'Valid', templateLintWarn: 'Invalid' }, streamers: {} }, byId: (id: string) => { if (!inputs.has(id)) inputs.set(id, new InteractiveInput()); return inputs.get(id); }, collectUnknownTemplatePlaceholders: () => [], applySidebarLayoutPreference: () => undefined, formatUiDateTime: (value: string) => value, document: { hidden: false, querySelector: () => null, getElementById: () => null, addEventListener: () => undefined }, setTimeout, clearTimeout, console }; const context = vm.createContext(sandbox); const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, }).outputText; vm.runInContext(compiled, context); vm.runInContext('initSettingsAutoSave()', context); return { inputs, saveConfigCalls, scheduled }; } async function settleAutosave(): Promise { await new Promise((resolve) => setImmediate(resolve)); } function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string | null) => string { const localeName = language === 'de' ? 'UI_TEXT_DE' : 'UI_TEXT_EN'; const localeSource = fs.readFileSync(path.join(process.cwd(), 'src', `renderer-locale-${language}.ts`), 'utf8'); const settingsSource = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); const compiled = ts.transpileModule( `${localeSource}\nlet UI_TEXT = ${localeName};\n${settingsSource}\nglobalThis.__getRuntimeErrorClassLabel = getRuntimeErrorClassLabel;`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } } ).outputText; const context = vm.createContext({ console, window: {} }); vm.runInContext(compiled, context); return context.__getRuntimeErrorClassLabel as (errorClass: string | null) => string; } describe('renderer settings autosave orchestration', () => { it.each([ ['autoResumeLiveRecordingToggle', 'auto_resume_live_recording', false], ['autoMergeResumedPartsToggle', 'auto_merge_resumed_parts', true], ['deletePartsAfterMergeToggle', 'delete_parts_after_merge', true], ['discordNotifyVodAutoQueuedToggle', 'discord_notify_vod_auto_queued', true] ] as const)('persists %s through its change listener', async (controlId, configKey, nextValue) => { const runtime = createAutosaveRuntime(); const control = runtime.inputs.get(controlId)!; control.checked = nextValue; control.dispatch('change'); await settleAutosave(); expect(runtime.saveConfigCalls).toHaveLength(1); expect(runtime.saveConfigCalls[0][configKey]).toBe(nextValue); }); it.each([ ['autoVodPollMinutes', 'auto_vod_download_poll_minutes', '30', 30], ['autoVodMaxAgeHours', 'auto_vod_max_age_hours', '48', 48] ] as const)('persists %s through its debounced input listener', async (controlId, configKey, nextValue, expectedValue) => { const runtime = createAutosaveRuntime(); const control = runtime.inputs.get(controlId)!; control.value = nextValue; control.dispatch('input'); expect(runtime.scheduled).toHaveLength(1); runtime.scheduled[0](); await settleAutosave(); expect(runtime.saveConfigCalls).toHaveLength(1); expect(runtime.saveConfigCalls[0][configKey]).toBe(expectedValue); }); it('persists a pure download policy change through the real autosave fingerprint', async () => { const inputs = new Map(inputIds.map((id) => [id, createInput()])); inputs.get('downloadThrottleMiBps')!.value = '1'; inputs.get('downloadWindows')!.value = '22:00-06:00'; const saveConfigCalls: Array> = []; const window = { api: { setClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), saveConfig(payload: Record) { saveConfigCalls.push(payload); return Promise.resolve(payload); }, }, }; const sandbox = { window, config: { download_policy: { throttle: { maxBytesPerSecond: 1_048_576 }, windows: [{ start: '22:00', end: '06:00' }] } }, UI_TEXT: { status: {}, static: {}, streamers: {} }, byId: (id: string) => inputs.get(id) ?? createInput(), collectUnknownTemplatePlaceholders: () => [], document: { hidden: false, querySelector: () => null, getElementById: () => null }, setTimeout, clearTimeout, console, }; const context = vm.createContext(sandbox); const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, }).outputText; vm.runInContext(compiled, context); vm.runInContext('lastPersistedSettingsFingerprint = getSettingsFingerprint(collectAutoSavePayload())', context); inputs.get('downloadThrottleMiBps')!.value = '1.5'; await (vm.runInContext('flushSettingsAutoSave(false)', context) as Promise); expect(saveConfigCalls).toHaveLength(1); expect(saveConfigCalls[0].download_policy).toEqual({ throttle: { maxBytesPerSecond: 1_572_864 }, windows: [{ start: '22:00', end: '06:00' }] }); }); it('persists a newer secret after an earlier asynchronous save settles', async () => { const inputs = new Map(inputIds.map((id) => [id, createInput()])); inputs.get('clientSecret')!.value = 'A'; let resolveFirstSecret: ((status: unknown) => void) | undefined; const setClientSecretCalls: string[] = []; const saveConfigCalls: unknown[] = []; const window = { api: { setClientSecret(value: string) { setClientSecretCalls.push(value); if (value === 'A') { return new Promise((resolve) => { resolveFirstSecret = resolve; }); } return Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: true, discordWebhookConfigured: false }); }, clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: true }), clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), saveConfig(payload: unknown) { saveConfigCalls.push(payload); return Promise.resolve(payload); }, }, }; const sandbox = { window, config: {}, UI_TEXT: { status: {}, static: {}, streamers: {} }, byId: (id: string) => inputs.get(id) ?? createInput(), collectUnknownTemplatePlaceholders: () => [], document: { hidden: false, querySelector: () => null, getElementById: () => null }, setTimeout, clearTimeout, console, }; const context = vm.createContext(sandbox); const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, }).outputText; vm.runInContext(compiled, context); const firstSave = vm.runInContext('flushSettingsAutoSave(false)', context) as Promise; expect(setClientSecretCalls).toEqual(['A']); expect(vm.runInContext('settingsAutoSaveInFlight', context)).toBe(true); vm.runInContext("byId('clientSecret').value = 'B'; secretInputGenerations.clientSecret += 1; if (typeof settingsInputGeneration === 'number') settingsInputGeneration += 1; void flushSettingsAutoSave(false);", context); expect(vm.runInContext('secretInputGenerations.clientSecret', context)).toBe(1); resolveFirstSecret?.({ encryptionAvailable: true, clientSecretConfigured: true, discordWebhookConfigured: false }); await firstSave; await new Promise((resolve) => setImmediate(resolve)); expect(setClientSecretCalls).toEqual(['A', 'B']); expect(saveConfigCalls).toHaveLength(2); }); }); describe('renderer runtime metrics localization', () => { it('renders every runtime error class and unknown values as human-readable German and English labels', () => { const german = loadRuntimeErrorFormatter('de'); const english = loadRuntimeErrorFormatter('en'); const cases = [ { errorClass: 'network', german: 'Netzwerk', english: 'Network' }, { errorClass: 'rate_limit', german: 'Anfragelimit', english: 'Rate limit' }, { errorClass: 'auth', german: 'Authentifizierung', english: 'Authentication' }, { errorClass: 'tooling', german: 'Externe Tools', english: 'External tools' }, { errorClass: 'integrity', german: 'Integrität', english: 'Integrity' }, { errorClass: 'io', german: 'Dateisystem', english: 'File system' }, { errorClass: 'validation', german: 'Validierung', english: 'Validation' }, { errorClass: 'unknown', german: 'Unbekannt', english: 'Unknown' }, { errorClass: 'future_error_class', german: 'Unbekannt', english: 'Unknown' }, { errorClass: null, german: '-', english: '-' } ]; expect(cases.map(({ errorClass }) => german(errorClass))).toEqual(cases.map(({ german: label }) => label)); expect(cases.map(({ errorClass }) => english(errorClass))).toEqual(cases.map(({ english: label }) => label)); }); });