From eefd7f136d3751733c30e0da02989bebbb62ac3c Mon Sep 17 00:00:00 2001 From: asyouplz <136051230+asyouplz@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:09:49 +0900 Subject: [PATCH] fix: address reviewbot feedback and update tests (#61) * fix: address reviewbot feedback and update tests Align request handling with Obsidian requestUrl and clean up UI text/lint directives while updating tests for the new mocks. Co-Authored-By: Claude Sonnet 4.5 * chore: ignore release_log.txt Add release_log.txt to gitignore to keep local release logs out of version control. Co-Authored-By: Claude Sonnet 4.5 * fix: align requesturl handling with review feedback centralize requesturl availability errors, bind provider defaults once, and stabilize timeout test. Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- .gitignore | 1 + .../transcription/TranscriptionService.ts | 105 +++++-------- src/infrastructure/api/WhisperService.ts | 109 +++++--------- .../api/providers/factory/ProviderSelector.ts | 6 +- src/patterns/Singleton.ts | 6 +- src/testing/TestingFramework.ts | 9 +- src/ui/formatting/FormatOptions.ts | 2 +- src/ui/managers/SettingsTabManager.ts | 2 +- src/ui/notifications/NotificationManager.ts | 4 - src/ui/progress/StatusMessage.ts | 2 +- src/ui/settings/EnhancedSettingsTab.ts | 10 +- src/ui/settings/SettingsTab.ts | 10 +- src/ui/settings/SettingsTabRefactored.ts | 2 +- src/ui/settings/SimpleSettingsTab.ts | 10 +- .../settings/components/DeepgramSettings.ts | 20 +-- .../settings/components/ProviderSettings.ts | 8 +- .../provider/ProviderSettingsContainer.ts | 22 +-- .../ProviderSettingsContainerRefactored.ts | 6 +- .../provider/components/APIKeyManager.ts | 2 +- .../components/APIKeyManagerRefactored.ts | 2 +- tests/e2e/error-handling.e2e.test.ts | 142 ++++++++---------- tests/e2e/file-conversion-flow.e2e.test.ts | 31 ++-- tests/e2e/settings-flow.e2e.test.ts | 8 +- tests/helpers/e2e.setup.ts | 17 +-- tests/unit/bugfixes.test.ts | 5 +- 25 files changed, 225 insertions(+), 316 deletions(-) diff --git a/.gitignore b/.gitignore index ecd762e..de352ff 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ $RECYCLE.BIN/ # Logs logs/ *.log +release_log.txt npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/src/core/transcription/TranscriptionService.ts b/src/core/transcription/TranscriptionService.ts index 5020ef7..f51602d 100644 --- a/src/core/transcription/TranscriptionService.ts +++ b/src/core/transcription/TranscriptionService.ts @@ -22,6 +22,7 @@ export class TranscriptionService implements ITranscriptionService { private eventManager: IEventManager; private logger: ILogger; private settings?: Record; + private readonly requestUrlAvailable: boolean; constructor( whisperServiceOrSettings: IWhisperService | Record, @@ -46,6 +47,13 @@ export class TranscriptionService implements ITranscriptionService { this.eventManager = eventManager ?? this.createNoopEventManager(); this.logger = logger ?? this.createNoopLogger(); } + + this.requestUrlAvailable = typeof requestUrl === 'function'; + if (!this.requestUrlAvailable) { + this.logger.error( + 'requestUrl API is not available. Update Obsidian to the latest version.' + ); + } } async transcribe( @@ -201,6 +209,15 @@ export class TranscriptionService implements ITranscriptionService { return this.status; } + private ensureRequestUrlAvailable(): void { + if (this.requestUrlAvailable) { + return; + } + throw new Error( + 'requestUrl API is not available. Please update Obsidian to the latest version.' + ); + } + private createNoopLogger(): ILogger { return { debug: () => undefined, @@ -393,76 +410,26 @@ export class TranscriptionService implements ITranscriptionService { headers['Authorization'] = `Bearer ${apiKey}`; } - const requestResult = - typeof requestUrl === 'function' - ? requestUrl({ - url, - method: 'POST', - headers, - body: body as string | ArrayBuffer, - throw: false, - }) - : undefined; + this.ensureRequestUrlAvailable(); - const responsePromise = - requestResult && typeof (requestResult as PromiseLike).then === 'function' - ? ( - requestResult as PromiseLike<{ - status: number; - json?: unknown; - text?: string; - }> - ).then((response) => ({ - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: String(response.status), - json: response.json, - text: response.text, - })) - : (async () => { - if (typeof fetch !== 'function') { - throw new Error('Fetch API not available'); - } - const fetchResponse = await fetch(url, { - method: 'POST', - headers, - body: body as BodyInit, - signal, - }); - let json: unknown; - let text: string | undefined; - if (typeof fetchResponse.json === 'function') { - try { - json = await fetchResponse.json(); - } catch { - json = undefined; - } - } - if (json === undefined && typeof fetchResponse.text === 'function') { - try { - text = await fetchResponse.text(); - } catch { - text = undefined; - } - } - const status = - typeof fetchResponse.status === 'number' - ? fetchResponse.status - : fetchResponse.ok - ? 200 - : 500; - const ok = - typeof fetchResponse.ok === 'boolean' - ? fetchResponse.ok - : status >= 200 && status < 300; - return { - ok, - status, - statusText: fetchResponse.statusText ?? String(status), - json, - text, - }; - })(); + const responsePromise = requestUrl({ + url, + method: 'POST', + headers, + body: body as string | ArrayBuffer, + throw: false, + }).then((response) => { + const status = response.status; + const json = response.json as unknown; + const text = response.text; + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + json, + text, + }; + }); const effectiveFetchPromise = this.isTestEnvironment() && delayForAbort diff --git a/src/infrastructure/api/WhisperService.ts b/src/infrastructure/api/WhisperService.ts index ca132d3..5296941 100644 --- a/src/infrastructure/api/WhisperService.ts +++ b/src/infrastructure/api/WhisperService.ts @@ -228,6 +228,7 @@ export class WhisperService implements IWhisperService { private apiKey: string; private logger: ILogger; + private readonly requestUrlAvailable: boolean; private abortController?: AbortController; private retryStrategy: RetryStrategy; @@ -251,6 +252,14 @@ export class WhisperService implements IWhisperService { this.timeoutMs = config.timeout; } } + + this.requestUrlAvailable = typeof requestUrl === 'function'; + if (!this.requestUrlAvailable) { + this.logger.error( + 'requestUrl API is not available. Update Obsidian to the latest version.' + ); + } + this.retryStrategy = new ExponentialBackoffRetry(this.logger); this.circuitBreaker = new CircuitBreaker(this.logger); } @@ -264,6 +273,15 @@ export class WhisperService implements IWhisperService { }; } + private ensureRequestUrlAvailable(): void { + if (this.requestUrlAvailable) { + return; + } + throw new Error( + 'requestUrl API is not available. Please update Obsidian to the latest version.' + ); + } + private async resolveAudioBuffer(audio: ArrayBuffer | Blob): Promise { if (audio instanceof ArrayBuffer) { return audio; @@ -405,7 +423,7 @@ export class WhisperService implements IWhisperService { }) ); - const response = await this.executeRequest(requestParams, formData); + const response = await this.executeRequest(requestParams); const processingTime = Date.now() - startTime; this.logger.info(`Transcription completed in ${processingTime}ms`, { @@ -472,7 +490,9 @@ export class WhisperService implements IWhisperService { return formData; } - private buildRequestParams(formData: FormData): RequestUrlParam { + private buildRequestParams( + formData: FormData + ): RequestUrlParam & { body: RequestUrlParam['body']; timeout: number } { const requestParams = { url: this.API_ENDPOINT, method: 'POST', @@ -482,92 +502,31 @@ export class WhisperService implements IWhisperService { body: formData as unknown as RequestUrlParam['body'], timeout: this.timeoutMs, throw: false, - } as RequestUrlParam & { timeout: number }; + } as RequestUrlParam & { body: RequestUrlParam['body']; timeout: number }; return requestParams; } private async executeRequest( - requestParams: RequestUrlParam & { timeout?: number }, - formData: FormData + requestParams: RequestUrlParam & { body: RequestUrlParam['body']; timeout?: number } ): Promise<{ status: number; headers?: Record; json?: unknown; text?: string; }> { - const requestResult = - typeof requestUrl === 'function' ? requestUrl(requestParams) : undefined; + this.ensureRequestUrlAvailable(); - if (requestResult && typeof (requestResult as PromiseLike).then === 'function') { - const response = await requestResult; - return { - status: response.status, - headers: response.headers, - json: response.json as unknown, - text: response.text, - }; + if (!requestParams.body) { + throw new Error('Request body not set for transcription'); } - if (typeof fetch !== 'function') { - throw new Error('Fetch API not available'); - } - - const controller = this.abortController; - const timeoutMs = - typeof requestParams.timeout === 'number' ? requestParams.timeout : this.timeoutMs; - const timeoutId = - timeoutMs > 0 && controller - ? window.setTimeout(() => controller.abort(), timeoutMs) - : undefined; - - try { - const fetchResponse = await fetch(requestParams.url, { - method: requestParams.method, - headers: requestParams.headers as HeadersInit, - body: (requestParams.body as BodyInit) ?? (formData as BodyInit), - signal: controller?.signal, - }); - - let json: unknown; - let text: string | undefined; - if (typeof fetchResponse.json === 'function') { - try { - json = await fetchResponse.json(); - } catch { - json = undefined; - } - } - if (json === undefined && typeof fetchResponse.text === 'function') { - try { - text = await fetchResponse.text(); - } catch { - text = undefined; - } - } - - const headers: Record = {}; - if (fetchResponse.headers) { - fetchResponse.headers.forEach((value, key) => { - headers[key.toLowerCase()] = value; - }); - } - - return { - status: - typeof fetchResponse.status === 'number' - ? fetchResponse.status - : fetchResponse.ok - ? 200 - : 500, - headers, - json, - text, - }; - } finally { - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - } + const response = await requestUrl(requestParams); + return { + status: response.status, + headers: response.headers, + json: response.json as unknown, + text: response.text, + }; } private parseResponse(json: unknown, processingTime: number): WhisperResponse { diff --git a/src/infrastructure/api/providers/factory/ProviderSelector.ts b/src/infrastructure/api/providers/factory/ProviderSelector.ts index 087845a..84f4c21 100644 --- a/src/infrastructure/api/providers/factory/ProviderSelector.ts +++ b/src/infrastructure/api/providers/factory/ProviderSelector.ts @@ -8,8 +8,10 @@ import { MetricsTracker } from './MetricsTracker'; export class ProviderSelector { private roundRobinIndex = 0; private readonly strategies: Map; + private readonly defaultStrategyFn: SelectionFunction; constructor(private readonly logger: ILogger, private readonly metricsTracker: MetricsTracker) { + this.defaultStrategyFn = this.defaultStrategy.bind(this); this.strategies = this.initializeStrategies(); } @@ -27,7 +29,7 @@ export class ProviderSelector { throw new Error('No transcription providers available'); } - const strategyFunction = this.strategies.get(strategy) ?? this.defaultStrategy; + const strategyFunction = this.strategies.get(strategy) ?? this.defaultStrategyFn; return strategyFunction(availableProviders, context); } @@ -41,7 +43,7 @@ export class ProviderSelector { [SelectionStrategy.QUALITY_OPTIMIZED, this.selectByQuality.bind(this)], [SelectionStrategy.ROUND_ROBIN, this.selectRoundRobin.bind(this)], [SelectionStrategy.AB_TEST, this.selectForABTest.bind(this)], - [SelectionStrategy.MANUAL, this.defaultStrategy.bind(this)], + [SelectionStrategy.MANUAL, this.defaultStrategyFn], ]); } diff --git a/src/patterns/Singleton.ts b/src/patterns/Singleton.ts index fe31bda..fc82c61 100644 --- a/src/patterns/Singleton.ts +++ b/src/patterns/Singleton.ts @@ -66,18 +66,18 @@ export function createSingleton(factory: () => T): () => T { * 클래스 데코레이터를 사용한 Singleton 패턴 */ export function SingletonDecorator< - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require any[] params + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- required by TS mixin constructor typing T extends new (...args: any[]) => object >(constructor: T): T { let instance: InstanceType | undefined; return class extends constructor { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require any[] params + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- required by TS mixin constructor typing constructor(...args: any[]) { if (instance) { return instance; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- args are passed through to base constructor + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- required by TS mixin constructor typing super(...args); instance = this as InstanceType; } diff --git a/src/testing/TestingFramework.ts b/src/testing/TestingFramework.ts index 05acc42..d53daef 100644 --- a/src/testing/TestingFramework.ts +++ b/src/testing/TestingFramework.ts @@ -1,8 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ - import { App, Plugin } from 'obsidian'; import { DependencyContainer } from '../architecture/DependencyContainer'; @@ -27,7 +22,9 @@ export class MockFactory { workspace: { getActiveViewOfType: jest.fn(), openLinkText: jest.fn(), - onLayoutReady: jest.fn((callback) => callback()), + onLayoutReady: jest.fn((callback: () => void) => { + callback(); + }), layoutReady: true, activeLeaf: null, } as unknown as App['workspace'], diff --git a/src/ui/formatting/FormatOptions.ts b/src/ui/formatting/FormatOptions.ts index 78207c5..f0ea461 100644 --- a/src/ui/formatting/FormatOptions.ts +++ b/src/ui/formatting/FormatOptions.ts @@ -249,7 +249,7 @@ export class FormatOptionsModal extends Modal { case 'heading': new Setting(targetContainer) .setName('Heading level') - .setDesc('Level of the heading (1-6)') + .setDesc('Heading level (1-6)') .addDropdown((dropdown) => { for (let i = 1; i <= 6; i++) { dropdown.addOption(String(i), `H${i}`); diff --git a/src/ui/managers/SettingsTabManager.ts b/src/ui/managers/SettingsTabManager.ts index a0a7024..703f933 100644 --- a/src/ui/managers/SettingsTabManager.ts +++ b/src/ui/managers/SettingsTabManager.ts @@ -244,7 +244,7 @@ class MinimalSettingsTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - new Setting(containerEl).setName('Transcription settings').setHeading(); + new Setting(containerEl).setName('Transcription').setHeading(); containerEl.createEl('p', { text: 'Settings are temporarily unavailable. Please restart Obsidian if this persists.', cls: 'settings-error-message', diff --git a/src/ui/notifications/NotificationManager.ts b/src/ui/notifications/NotificationManager.ts index 21b53bb..02bf093 100644 --- a/src/ui/notifications/NotificationManager.ts +++ b/src/ui/notifications/NotificationManager.ts @@ -4,10 +4,6 @@ * Toast, Modal, StatusBar, Sound 알림을 통합 관리합니다. */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - import { INotificationAPI, NotificationOptions, diff --git a/src/ui/progress/StatusMessage.ts b/src/ui/progress/StatusMessage.ts index b495006..690c5b0 100644 --- a/src/ui/progress/StatusMessage.ts +++ b/src/ui/progress/StatusMessage.ts @@ -328,7 +328,7 @@ export class StatusMessageDisplay { const header = createEl('div', { cls: 'status-message-display__header' }); const title = createEl('h3', { - text: this.getLocalizedText('Status Messages', '상태 메시지'), + text: this.getLocalizedText('Status messages', '상태 메시지'), }); header.appendChild(title); diff --git a/src/ui/settings/EnhancedSettingsTab.ts b/src/ui/settings/EnhancedSettingsTab.ts index 3c42347..18759a8 100644 --- a/src/ui/settings/EnhancedSettingsTab.ts +++ b/src/ui/settings/EnhancedSettingsTab.ts @@ -392,8 +392,8 @@ export class EnhancedSettingsTab extends PluginSettingTab { .setDesc('Select the transcription service provider') .addDropdown((dropdown) => { dropdown - .addOption('openai', 'OpenAI whisper') - .addOption('azure', 'Azure speech services') + .addOption('openai', 'OpenAI Whisper') + .addOption('azure', 'Azure Speech Services') .addOption('custom', 'Custom endpoint'); void this.settingsAPI.get('api').then((api) => { @@ -620,7 +620,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { // 샘플 레이트 new Setting(section) .setName('Sample rate') - .setDesc('Audio sample rate in hz') + .setDesc('Audio sample rate in Hz') .addDropdown((dropdown) => { const rates = [8000, 16000, 22050, 44100, 48000]; rates.forEach((rate) => { @@ -707,7 +707,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { new Setting(cacheSection) .setName('Max cache size') - .setDesc('Maximum cache size in mb') + .setDesc('Maximum cache size in MB') .addSlider((slider) => { slider .setLimits(10, 500, 10) @@ -756,7 +756,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { new Setting(perfSection) .setName('Chunk size') - .setDesc('File chunk size in mb') + .setDesc('File chunk size in MB') .addSlider((slider) => { slider .setLimits(0.5, 10, 0.5) diff --git a/src/ui/settings/SettingsTab.ts b/src/ui/settings/SettingsTab.ts index 66a516a..7b26b63 100644 --- a/src/ui/settings/SettingsTab.ts +++ b/src/ui/settings/SettingsTab.ts @@ -152,7 +152,7 @@ export class SettingsTab extends PluginSettingTab { dropdown .addOption('auto', 'Auto (intelligent selection)') - .addOption('whisper', 'OpenAI whisper') + .addOption('whisper', 'OpenAI Whisper') .addOption('deepgram', 'Deepgram') .setValue(this.plugin.settings.provider || 'auto') .onChange(async (value) => { @@ -366,7 +366,7 @@ export class SettingsTab extends PluginSettingTab { private renderWhisperApiKey(containerEl: HTMLElement): void { new Setting(containerEl) .setName('OpenAI API key') - .setDesc('Enter your OpenAI API key for whisper transcription') + .setDesc('Enter your OpenAI API key for Whisper transcription') .addText((text) => { text.setPlaceholder('sk-...') .setValue(this.maskApiKey(this.plugin.settings.apiKey || '')) @@ -439,7 +439,7 @@ export class SettingsTab extends PluginSettingTab { const descriptions = { auto: '🤖 Intelligent selection between providers based on your configured strategy. Automatically chooses the best provider for each request.', whisper: - '🎯 OpenAI whisper - high-quality transcription with support for multiple languages. Best for general-purpose transcription.', + '🎯 OpenAI Whisper - high-quality transcription with support for multiple languages. Best for general-purpose transcription.', deepgram: '⚡ Deepgram - fast, accurate transcription with advanced AI features. Best for real-time processing and speaker diarization.', }; @@ -524,7 +524,7 @@ export class SettingsTab extends PluginSettingTab { if (provider === 'whisper' || provider === 'auto') { new Setting(containerEl) .setName('Whisper model') - .setDesc('Select the whisper model to use') + .setDesc('Select the Whisper model to use') .addDropdown((dropdown) => dropdown .addOption('whisper-1', 'Whisper v1 (default)') @@ -702,4 +702,4 @@ export class SettingsTab extends PluginSettingTab { return key.substring(0, visibleStart) + masked + key.substring(key.length - visibleEnd); } } -/* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */ +/* eslint-enable @typescript-eslint/no-unsafe-enum-comparison -- re-enable after SettingsTab type guards */ diff --git a/src/ui/settings/SettingsTabRefactored.ts b/src/ui/settings/SettingsTabRefactored.ts index 68821bd..60c4ca2 100644 --- a/src/ui/settings/SettingsTabRefactored.ts +++ b/src/ui/settings/SettingsTabRefactored.ts @@ -140,7 +140,7 @@ export class SettingsTabRefactored extends PluginSettingTab { * 일반 설정 섹션 */ private createGeneralSection(containerEl: HTMLElement): void { - const sectionEl = this.createSection(containerEl, 'General', '기본 동작 설정'); + const sectionEl = this.createSection(containerEl, 'Basics', '기본 동작 설정'); const component = this.getComponent('generalSettings', GeneralSettingsWrapper); component?.render(sectionEl); } diff --git a/src/ui/settings/SimpleSettingsTab.ts b/src/ui/settings/SimpleSettingsTab.ts index ccd300e..adc09fe 100644 --- a/src/ui/settings/SimpleSettingsTab.ts +++ b/src/ui/settings/SimpleSettingsTab.ts @@ -23,7 +23,7 @@ export class SimpleSettingsTab extends PluginSettingTab { containerEl.empty(); // 제목 - new Setting(containerEl).setName('Transcription settings').setHeading(); + new Setting(containerEl).setName('Transcription').setHeading(); // Provider 선택 드롭다운 - 가장 중요한 기능 new Setting(containerEl).setName('API configuration').setHeading(); @@ -39,7 +39,7 @@ export class SimpleSettingsTab extends PluginSettingTab { this.debug('Adding options to dropdown...'); dropdown .addOption('auto', 'Auto (intelligent selection)') - .addOption('whisper', 'OpenAI whisper') + .addOption('whisper', 'OpenAI Whisper') .addOption('deepgram', 'Deepgram') .setValue(this.plugin.settings.provider || 'auto') .onChange(async (value) => { @@ -62,7 +62,7 @@ export class SimpleSettingsTab extends PluginSettingTab { if (provider === 'auto' || provider === 'whisper') { new Setting(containerEl) .setName('OpenAI API key') - .setDesc('Enter your OpenAI API key for whisper') + .setDesc('Enter your OpenAI API key for Whisper') .addText((text) => text .setPlaceholder('sk-...') @@ -93,7 +93,7 @@ export class SimpleSettingsTab extends PluginSettingTab { if (provider === 'deepgram') { new Setting(containerEl) .setName('Deepgram model') - .setDesc('Select the deepgram model to use') + .setDesc('Select the Deepgram model to use') .addDropdown((dropdown) => dropdown .addOption('nova-2', 'Nova 2 (premium)') @@ -120,7 +120,7 @@ export class SimpleSettingsTab extends PluginSettingTab { } // 기본 설정들 - new Setting(containerEl).setName('General settings').setHeading(); + new Setting(containerEl).setName('Basics').setHeading(); new Setting(containerEl) .setName('Language') diff --git a/src/ui/settings/components/DeepgramSettings.ts b/src/ui/settings/components/DeepgramSettings.ts index 71ab864..996af4b 100644 --- a/src/ui/settings/components/DeepgramSettings.ts +++ b/src/ui/settings/components/DeepgramSettings.ts @@ -578,7 +578,7 @@ export class DeepgramSettings { // Maximum chunk size const chunkSizeSetting = new Setting(container) .setName('Maximum chunk size') - .setDesc('Maximum size per chunk in mb (recommended: 50mb)') + .setDesc('Maximum size per chunk in MB (recommended: 50MB)') .addSlider((slider) => { slider .setLimits(10, 100, 10) @@ -648,20 +648,22 @@ export class DeepgramSettings { this.plugin.settings.monthlyBudget ); - if (!estimation) { - this.uiBuilder.updateCostDetails(this.estimatedCostEl.parentElement!, null); + const parentEl = this.estimatedCostEl.parentElement; + if (!parentEl) { + this.logger.warn('Cost estimation container not available'); return; } - this.uiBuilder.updateCostDetails( - this.estimatedCostEl.parentElement!, - this.selectedModel, - estimation.monthly - ); + if (!estimation) { + this.uiBuilder.updateCostDetails(parentEl, null); + return; + } + + this.uiBuilder.updateCostDetails(parentEl, this.selectedModel, estimation.monthly); if (estimation.exceeedsBudget && this.plugin.settings.monthlyBudget) { this.uiBuilder.addBudgetWarning( - this.estimatedCostEl.parentElement!, + parentEl, estimation.monthly, this.plugin.settings.monthlyBudget ); diff --git a/src/ui/settings/components/ProviderSettings.ts b/src/ui/settings/components/ProviderSettings.ts index 3c393d2..39920c3 100644 --- a/src/ui/settings/components/ProviderSettings.ts +++ b/src/ui/settings/components/ProviderSettings.ts @@ -81,7 +81,7 @@ export class ProviderSettings { .addDropdown((dropdown) => { dropdown .addOption('auto', '🤖 Automatic (recommended)') - .addOption('whisper', '🎯 OpenAI whisper') + .addOption('whisper', '🎯 OpenAI Whisper') .addOption('deepgram', '🚀 Deepgram') .setValue(this.currentProvider) .onChange(async (value) => { @@ -306,7 +306,7 @@ export class ProviderSettings { private renderABTestDetails(containerEl: HTMLElement): void { new Setting(containerEl) .setName('Traffic split') - .setDesc('Percentage of requests to send to whisper vs deepgram') + .setDesc('Percentage of requests to send to Whisper vs Deepgram') .addSlider((slider) => { const currentSplit = this.plugin.settings.abTestSplit || 50; @@ -574,7 +574,7 @@ export class ProviderSettings { private showProviderInfo(provider: string): void { const info: Record = { auto: '🤖 System will automatically select the best provider based on performance and availability', - whisper: '🎯 OpenAI whisper - high accuracy, supports 50+ languages', + whisper: '🎯 OpenAI Whisper - high accuracy, supports 50+ languages', deepgram: '🚀 Deepgram - fast real-time transcription with excellent accuracy', }; @@ -601,7 +601,7 @@ export class ProviderSettings { */ private getProviderDisplayName(provider: TranscriptionProvider): string { const names: Record = { - whisper: 'OpenAI whisper', + whisper: 'OpenAI Whisper', deepgram: 'Deepgram', }; return names[provider] || provider; diff --git a/src/ui/settings/provider/ProviderSettingsContainer.ts b/src/ui/settings/provider/ProviderSettingsContainer.ts index 409d7a6..20dfa63 100644 --- a/src/ui/settings/provider/ProviderSettingsContainer.ts +++ b/src/ui/settings/provider/ProviderSettingsContainer.ts @@ -195,7 +195,7 @@ export class ProviderSettingsContainer { .addDropdown((dropdown) => { dropdown .addOption('auto', '🤖 Automatic (recommended)') - .addOption('whisper', '🎯 OpenAI whisper only') + .addOption('whisper', '🎯 OpenAI Whisper only') .addOption('deepgram', '🚀 Deepgram only') .setValue(this.currentProvider) .onChange(async (value) => { @@ -358,7 +358,7 @@ export class ProviderSettingsContainer { */ private getProviderDisplayName(provider: string): string { const names: Record = { - whisper: 'OpenAI whisper', + whisper: 'OpenAI Whisper', deepgram: 'Deepgram', auto: 'Automatic', }; @@ -400,7 +400,7 @@ export class ProviderSettingsContainer { private showProviderNotice(provider: string): void { const messages: Record = { auto: '🤖 System will automatically select the best provider', - whisper: '🎯 Using OpenAI whisper exclusively', + whisper: '🎯 Using OpenAI Whisper exclusively', deepgram: '🚀 Using Deepgram exclusively', }; new Notice(messages[provider] || 'Provider updated'); @@ -427,7 +427,7 @@ export class ProviderSettingsContainer { ], }, { - title: '🎯 OpenAI whisper', + title: '🎯 OpenAI Whisper', bullets: [ 'Excellent accuracy for 50+ languages', 'Best for long-form content', @@ -776,7 +776,7 @@ class ProviderDetailsModal extends Modal { } private getProviderName(): string { - return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram'; + return this.provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram'; } private renderStatus(containerEl: HTMLElement): void { @@ -785,8 +785,8 @@ class ProviderDetailsModal extends Modal { // TODO: 실제 상태 가져오기 const statusItems = [ { label: 'Connection', value: '✅ Connected', cls: 'status-good' }, - { label: 'API Key', value: '🔑 Configured', cls: 'status-good' }, - { label: 'Last Used', value: '5 minutes ago', cls: 'status-info' }, + { label: 'API key', value: '🔑 Configured', cls: 'status-good' }, + { label: 'Last used', value: '5 minutes ago', cls: 'status-info' }, { label: 'Health', value: '100%', cls: 'status-good' }, ]; @@ -803,10 +803,10 @@ class ProviderDetailsModal extends Modal { // TODO: 실제 통계 가져오기 const stats = [ - { label: 'Total Requests', value: '1,234' }, - { label: 'Success Rate', value: '99.5%' }, - { label: 'Avg. Latency', value: '1.2s' }, - { label: 'Total Cost', value: '$12.34' }, + { label: 'Total requests', value: '1,234' }, + { label: 'Success rate', value: '99.5%' }, + { label: 'Avg. latency', value: '1.2s' }, + { label: 'Total cost', value: '$12.34' }, ]; const gridEl = containerEl.createDiv({ cls: 'stats-grid' }); diff --git a/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts b/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts index 03e56e9..d412375 100644 --- a/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts +++ b/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts @@ -287,7 +287,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { ).addDropdown((dropdown) => { dropdown .addOption('auto', '🤖 Automatic') - .addOption('whisper', '🎯 OpenAI whisper') + .addOption('whisper', '🎯 OpenAI Whisper') .addOption('deepgram', '🚀 Deepgram') .setValue(this.state.get().currentProvider) .onChange((value) => { @@ -567,7 +567,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { */ private getProviderDisplayName(provider: string): string { const names: Record = { - whisper: 'OpenAI whisper', + whisper: 'OpenAI Whisper', deepgram: 'Deepgram', auto: '자동', }; @@ -828,7 +828,7 @@ class ProviderDetailsModal extends Modal { } private getProviderName(): string { - return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram'; + return this.provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram'; } private createStatusContent(): HTMLElement { diff --git a/src/ui/settings/provider/components/APIKeyManager.ts b/src/ui/settings/provider/components/APIKeyManager.ts index 479a511..9a6830f 100644 --- a/src/ui/settings/provider/components/APIKeyManager.ts +++ b/src/ui/settings/provider/components/APIKeyManager.ts @@ -684,7 +684,7 @@ export class APIKeyManager { * Provider 이름 가져오기 */ private getProviderName(provider: TranscriptionProvider): string { - return provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram'; + return provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram'; } /** diff --git a/src/ui/settings/provider/components/APIKeyManagerRefactored.ts b/src/ui/settings/provider/components/APIKeyManagerRefactored.ts index 87e783d..9e0a726 100644 --- a/src/ui/settings/provider/components/APIKeyManagerRefactored.ts +++ b/src/ui/settings/provider/components/APIKeyManagerRefactored.ts @@ -66,7 +66,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent { [ 'whisper', { - name: 'OpenAI whisper', + name: 'OpenAI Whisper', placeholder: 'sk-...', pattern: /^sk-[A-Za-z0-9]{48,}$/, validateEndpoint: 'https://api.openai.com/v1/models', diff --git a/tests/e2e/error-handling.e2e.test.ts b/tests/e2e/error-handling.e2e.test.ts index d2e6527..3e6663c 100644 --- a/tests/e2e/error-handling.e2e.test.ts +++ b/tests/e2e/error-handling.e2e.test.ts @@ -9,7 +9,7 @@ * 5. 복구 메커니즘 */ -import { App } from 'obsidian'; +import { App, requestUrl } from 'obsidian'; import { TranscriptionService } from '../../src/core/transcription/TranscriptionService'; import { WhisperService } from '../../src/infrastructure/api/WhisperService'; import { ErrorHandler } from '../../src/utils/ErrorHandler'; @@ -107,14 +107,14 @@ describe('E2E: 에러 처리 시나리오', () => { const maxRetries = settings.retryAttempts; // 네트워크 에러 시뮬레이션 - (global.fetch as jest.Mock) = jest.fn().mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { attemptCount++; if (attemptCount <= 2) { return Promise.reject(new Error('Network request failed')); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve('변환된 텍스트'), + status: 200, + text: '변환된 텍스트', }); }); @@ -135,10 +135,13 @@ describe('E2E: 에러 처리 시나리오', () => { test('타임아웃 처리', async () => { // 타임아웃 시뮬레이션 - (global.fetch as jest.Mock) = jest.fn().mockImplementation(() => { - return new Promise((resolve) => { - // 타임아웃보다 긴 시간 대기 - setTimeout(resolve, settings.timeout + 5000); + (requestUrl as jest.Mock).mockImplementation(() => { + return new Promise((_, reject) => { + // 타임아웃 이후에 실패하도록 지연 + setTimeout( + () => reject(new Error('Request exceeded timeout')), + settings.timeout + 50 + ); }); }); @@ -166,9 +169,7 @@ describe('E2E: 에러 처리 시나리오', () => { test('CORS 에러 처리', async () => { // CORS 에러 시뮬레이션 - (global.fetch as jest.Mock) = jest - .fn() - .mockRejectedValue(new TypeError('Failed to fetch')); + (requestUrl as jest.Mock).mockRejectedValue(new TypeError('Failed to fetch')); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); @@ -188,17 +189,14 @@ describe('E2E: 에러 처리 시나리오', () => { describe('API 에러 응답 처리', () => { test('401 Unauthorized - API 키 에러', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ - ok: false, + (requestUrl as jest.Mock).mockResolvedValue({ status: 401, - statusText: 'Unauthorized', - json: () => - Promise.resolve({ - error: { - message: 'Invalid API key provided', - type: 'invalid_request_error', - }, - }), + json: { + error: { + message: 'Invalid API key provided', + type: 'invalid_request_error', + }, + }, }); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); @@ -220,28 +218,23 @@ describe('E2E: 에러 처리 시나리오', () => { let requestCount = 0; const retryAfter = 60; // 60초 후 재시도 - (global.fetch as jest.Mock) = jest.fn().mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { requestCount++; if (requestCount === 1) { return Promise.resolve({ - ok: false, status: 429, - statusText: 'Too Many Requests', - headers: new Headers({ - 'Retry-After': retryAfter.toString(), - }), - json: () => - Promise.resolve({ - error: { - message: 'Rate limit exceeded', - type: 'rate_limit_error', - }, - }), + headers: { 'retry-after': retryAfter.toString() }, + json: { + error: { + message: 'Rate limit exceeded', + type: 'rate_limit_error', + }, + }, }); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve('변환된 텍스트'), + status: 200, + text: '변환된 텍스트', }); }); @@ -263,17 +256,14 @@ describe('E2E: 에러 처리 시나리오', () => { }); test('413 Payload Too Large - 파일 크기 초과', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ - ok: false, + (requestUrl as jest.Mock).mockResolvedValue({ status: 413, - statusText: 'Payload Too Large', - json: () => - Promise.resolve({ - error: { - message: 'Maximum file size exceeded', - type: 'invalid_request_error', - }, - }), + json: { + error: { + message: 'Maximum file size exceeded', + type: 'invalid_request_error', + }, + }, }); const largeFile = new File([new ArrayBuffer(30 * 1024 * 1024)], 'large.mp3', { @@ -296,25 +286,22 @@ describe('E2E: 에러 처리 시나리오', () => { test('500 Internal Server Error - 서버 에러', async () => { let attemptCount = 0; - (global.fetch as jest.Mock) = jest.fn().mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { attemptCount++; if (attemptCount <= 2) { return Promise.resolve({ - ok: false, status: 500, - statusText: 'Internal Server Error', - json: () => - Promise.resolve({ - error: { - message: 'An error occurred during processing', - type: 'server_error', - }, - }), + json: { + error: { + message: 'An error occurred during processing', + type: 'server_error', + }, + }, }); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve('변환된 텍스트'), + status: 200, + text: '변환된 텍스트', }); }); @@ -348,17 +335,14 @@ describe('E2E: 에러 처리 시나리오', () => { }); test('손상된 오디오 파일', async () => { - (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ - ok: false, + (requestUrl as jest.Mock).mockResolvedValue({ status: 400, - statusText: 'Bad Request', - json: () => - Promise.resolve({ - error: { - message: 'Invalid audio file', - type: 'invalid_request_error', - }, - }), + json: { + error: { + message: 'Invalid audio file', + type: 'invalid_request_error', + }, + }, }); const corruptedFile = new File(['corrupted data'], 'corrupted.mp3', { @@ -399,14 +383,14 @@ describe('E2E: 에러 처리 시나리오', () => { const delays: number[] = []; let attemptCount = 0; - (global.fetch as jest.Mock) = jest.fn().mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { attemptCount++; if (attemptCount < 3) { return Promise.reject(new Error('Temporary failure')); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve('변환된 텍스트'), + status: 200, + text: '변환된 텍스트', }); }); @@ -434,15 +418,15 @@ describe('E2E: 에러 처리 시나리오', () => { ]; let callCount = 0; - (global.fetch as jest.Mock) = jest.fn().mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { callCount++; if (callCount === 2) { // 두 번째 파일만 실패 return Promise.reject(new Error('Processing failed')); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve(`변환된 텍스트 ${callCount}`), + status: 200, + text: `변환된 텍스트 ${callCount}`, }); }); @@ -474,7 +458,7 @@ describe('E2E: 에러 처리 시나리오', () => { // 에러 리포터 등록 errorManager.setReporter(errorReportSpy); - (global.fetch as jest.Mock) = jest.fn().mockRejectedValue(new Error('Critical error')); + (requestUrl as jest.Mock).mockRejectedValue(new Error('Critical error')); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); @@ -501,14 +485,14 @@ describe('E2E: 에러 처리 시나리오', () => { const fallbackApiUrl = 'https://fallback-api.example.com/transcribe'; let apiCallCount = 0; - (global.fetch as jest.Mock) = jest.fn().mockImplementation((url) => { + (requestUrl as jest.Mock).mockImplementation((params: { url?: string }) => { apiCallCount++; - if (url === mainApiUrl && apiCallCount === 1) { + if (params.url === mainApiUrl && apiCallCount === 1) { return Promise.reject(new Error('Main API failed')); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve('폴백 API 결과'), + status: 200, + text: '폴백 API 결과', }); }); diff --git a/tests/e2e/file-conversion-flow.e2e.test.ts b/tests/e2e/file-conversion-flow.e2e.test.ts index ea246dc..ac73d06 100644 --- a/tests/e2e/file-conversion-flow.e2e.test.ts +++ b/tests/e2e/file-conversion-flow.e2e.test.ts @@ -10,7 +10,7 @@ * 6. 성공 알림 확인 */ -import { App, Modal, Editor, Notice } from 'obsidian'; +import { App, Modal, Editor, Notice, requestUrl } from 'obsidian'; import { FilePickerModal } from '../../src/ui/modals/FilePickerModal'; import { TranscriptionService } from '../../src/core/transcription/TranscriptionService'; import { EditorService } from '../../src/application/EditorService'; @@ -90,10 +90,10 @@ describe('E2E: 파일 변환 플로우', () => { transcriptionService = new TranscriptionService(settings); // Mock API 응답 - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ text: '변환된 텍스트' }), - text: jest.fn().mockResolvedValue('변환된 텍스트'), + (requestUrl as jest.Mock).mockResolvedValue({ + status: 200, + json: { text: '변환된 텍스트' }, + text: '변환된 텍스트', }); }); @@ -155,9 +155,9 @@ describe('E2E: 파일 변환 플로우', () => { await filePickerModal.onChooseFile(mockFile); // API 호출 검증 - expect(global.fetch).toHaveBeenCalledWith( - settings.apiUrl, + expect(requestUrl).toHaveBeenCalledWith( expect.objectContaining({ + url: settings.apiUrl, method: 'POST', headers: expect.objectContaining({ Authorization: `Bearer ${settings.apiKey}`, @@ -206,7 +206,7 @@ describe('E2E: 파일 변환 플로우', () => { await new Promise((resolve) => setTimeout(resolve, 100)); // 검증 - expect(global.fetch).toHaveBeenCalled(); + expect(requestUrl).toHaveBeenCalled(); document.body.removeChild(dropZone); }); @@ -222,11 +222,11 @@ describe('E2E: 파일 변환 플로우', () => { const results: string[] = []; // 각 파일에 대한 mock 응답 설정 - (global.fetch as jest.Mock).mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { processedCount++; return Promise.resolve({ - ok: true, - text: () => Promise.resolve(`변환된 텍스트 ${processedCount}`), + status: 200, + text: `변환된 텍스트 ${processedCount}`, }); }); @@ -282,14 +282,14 @@ describe('E2E: 파일 변환 플로우', () => { test('네트워크 에러 처리 및 재시도', async () => { let attemptCount = 0; - (global.fetch as jest.Mock).mockImplementation(() => { + (requestUrl as jest.Mock).mockImplementation(() => { attemptCount++; if (attemptCount < 3) { return Promise.reject(new Error('Network error')); } return Promise.resolve({ - ok: true, - text: () => Promise.resolve('변환된 텍스트'), + status: 200, + text: '변환된 텍스트', }); }); @@ -301,7 +301,7 @@ describe('E2E: 파일 변환 플로우', () => { }); test('타임아웃 처리', async () => { - (global.fetch as jest.Mock).mockImplementation( + (requestUrl as jest.Mock).mockImplementation( () => new Promise((resolve) => { setTimeout(resolve, settings.timeout + 1000); @@ -350,6 +350,7 @@ describe('E2E: 파일 변환 플로우', () => { let isCancelled = false; const abortController = new AbortController(); + (requestUrl as jest.Mock).mockImplementation(() => new Promise(() => undefined)); // 변환 시작 const transcribePromise = transcriptionService.transcribe(mockFile, { diff --git a/tests/e2e/settings-flow.e2e.test.ts b/tests/e2e/settings-flow.e2e.test.ts index 36d887b..22723b8 100644 --- a/tests/e2e/settings-flow.e2e.test.ts +++ b/tests/e2e/settings-flow.e2e.test.ts @@ -10,7 +10,7 @@ * 6. 설정 마이그레이션 */ -import { App, PluginSettingTab, Setting } from 'obsidian'; +import { App, PluginSettingTab, Setting, requestUrl } from 'obsidian'; import { SettingsTab } from '../../src/ui/settings/SettingsTab'; import { SettingsManager } from '../../src/infrastructure/storage/SettingsManager'; import { SettingsValidator } from '../../src/infrastructure/api/SettingsValidator'; @@ -472,9 +472,9 @@ describe('E2E: 설정 플로우', () => { apiKeyInput.dispatchEvent(new Event('input')); // Mock API 검증 성공 - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ models: ['whisper-1'] }), + (requestUrl as jest.Mock).mockResolvedValue({ + status: 200, + json: { data: [{ id: 'whisper-1' }] }, }); validateButton.click(); diff --git a/tests/helpers/e2e.setup.ts b/tests/helpers/e2e.setup.ts index 1958df3..7a70b0f 100644 --- a/tests/helpers/e2e.setup.ts +++ b/tests/helpers/e2e.setup.ts @@ -8,6 +8,7 @@ */ import '@testing-library/jest-dom'; +import { requestUrl } from 'obsidian'; import { TextEncoder, TextDecoder } from 'util'; // 전역 객체 설정 @@ -105,8 +106,8 @@ global.clearInterval = ((id: ReturnType) => { return originalClearInterval(id); }) as typeof clearInterval; -// Fetch API Mock -global.fetch = jest.fn(); +// Obsidian requestUrl Mock +(requestUrl as jest.Mock).mockClear(); // LocalStorage Mock const localStorageMock = { @@ -406,18 +407,16 @@ export const waitFor = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); export const mockApiResponse = (data: any, status = 200): void => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: status >= 200 && status < 300, + (requestUrl as jest.Mock).mockResolvedValueOnce({ status, - statusText: status === 200 ? 'OK' : 'Error', - json: () => Promise.resolve(data), - text: () => Promise.resolve(JSON.stringify(data)), + json: data, + text: JSON.stringify(data), headers: new Headers(), }); }; export const mockApiError = (error: string, status = 500): void => { - (global.fetch as jest.Mock).mockRejectedValueOnce(new Error(error)); + (requestUrl as jest.Mock).mockRejectedValueOnce(new Error(error)); }; export const createMockFile = (name: string, size: number, type: string): File => { @@ -434,7 +433,7 @@ export const dispatchCustomEvent = (element: Element, eventName: string, detail? beforeEach(() => { jest.clearAllMocks(); localStorageMock.clear(); - (global.fetch as jest.Mock).mockClear(); + (requestUrl as jest.Mock).mockClear(); document.body.innerHTML = ''; }); diff --git a/tests/unit/bugfixes.test.ts b/tests/unit/bugfixes.test.ts index a14e99a..6f001b9 100644 --- a/tests/unit/bugfixes.test.ts +++ b/tests/unit/bugfixes.test.ts @@ -6,6 +6,7 @@ import '../helpers/testSetup'; // 테스트 환경 설정 임포트 import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { requestUrl } from 'obsidian'; import { WhisperService } from '../../src/infrastructure/api/WhisperService'; import { FileUploadManager } from '../../src/infrastructure/api/FileUploadManager'; import { NotificationManager } from '../../src/ui/notifications/NotificationManager'; @@ -243,8 +244,8 @@ describe('Integration Tests', () => { const mockLogger = new Logger('test') as jest.Mocked; const whisperService = new WhisperService('invalid-key', mockLogger); - // Mock fetch to simulate API error - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + // Mock requestUrl to simulate API error + (requestUrl as jest.Mock).mockRejectedValue(new Error('Network error')); await expect(whisperService.transcribe(new ArrayBuffer(1000))).rejects.toThrow();