From 8e4bcf0fc714628be615f1c8041f222d9c3292d2 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 14:41:00 +0200 Subject: [PATCH] fix(logging): isolate expected test errors Inject typed error logging into dictation and paste flows so negative-path tests can record or discard expected failures without writing unified-log error entries. Production defaults retain the existing subsystem, categories, severity, and messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/ErrorLogger.swift | 18 ++++++ Sources/OpenWritr/OpenWritrApp.swift | 43 ++++++++------- Sources/OpenWritr/PasteManager.swift | 13 +++-- .../AppViewModelDictationFlowTests.swift | 24 +++++++- Tests/OpenWritrTests/PasteManagerTests.swift | 55 +++++++++++++++---- 5 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 Sources/OpenWritr/ErrorLogger.swift diff --git a/Sources/OpenWritr/ErrorLogger.swift b/Sources/OpenWritr/ErrorLogger.swift new file mode 100644 index 0000000..b958bcc --- /dev/null +++ b/Sources/OpenWritr/ErrorLogger.swift @@ -0,0 +1,18 @@ +import os.log + +@MainActor +protocol ErrorLogging { + func logError(_ message: String) +} + +struct UnifiedErrorLogger: ErrorLogging { + private let logger: Logger + + init(category: String) { + logger = Logger(subsystem: "com.openwritr.app", category: category) + } + + func logError(_ message: String) { + logger.error("\(message, privacy: .public)") + } +} diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index fea91a6..8a13b48 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -97,6 +97,7 @@ final class AppViewModel { private let captureDrainTimeout: Duration = .milliseconds(350) private let doneDisplayDuration: Duration private let transientErrorDisplayDuration: Duration + private let errorLogger: any ErrorLogging private var didConfigure = false private var didAttemptInitialSetup = false private var isInitializing = false @@ -125,6 +126,7 @@ final class AppViewModel { grammarEnhancer: any TranscriptEnhancing = GrammarEnhancer(), pasteManager: any TextPasting = PasteManager(), overlayPanel: any OverlayPresenting = OverlayPanel(), + errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "AppViewModel"), startsOperational: Bool = false, doneDisplayDuration: Duration = AppViewModel.defaultDoneDisplayDuration, transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration @@ -134,6 +136,7 @@ final class AppViewModel { self.grammarEnhancer = grammarEnhancer self.pasteManager = pasteManager self.overlayPanel = overlayPanel + self.errorLogger = errorLogger self.doneDisplayDuration = doneDisplayDuration self.transientErrorDisplayDuration = transientErrorDisplayDuration if startsOperational { @@ -274,8 +277,8 @@ final class AppViewModel { } modelsLoaded = true } catch { - appLog.error( - "Model initialization failed: \(error.localizedDescription, privacy: .public)" + errorLogger.logError( + "Model initialization failed: \(error.localizedDescription)" ) presentInitializationError( errorPresentation( @@ -303,7 +306,7 @@ final class AppViewModel { state = .loading if case .failure(let error) = prepareAudioForStartup() { - appLog.error("Audio initialization failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Audio initialization failed: \(error.localizedDescription)") presentInitializationError( errorPresentation(kind: .audio, title: "Microphone Initialization Failed", error: error) ) @@ -312,7 +315,7 @@ final class AppViewModel { updateInputDeviceStatusMessage() if case .failure(let error) = hotkeyManager.start() { - appLog.error("Hotkey initialization failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Hotkey initialization failed: \(error.localizedDescription)") presentInitializationError( errorPresentation(kind: .audio, title: "Push-to-Talk Initialization Failed", error: error) ) @@ -721,7 +724,7 @@ final class AppViewModel { state = .ready } case .failure(let error): - appLog.error("Input device validation failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Input device validation failed: \(error.localizedDescription)") if device == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -736,7 +739,7 @@ final class AppViewModel { clearSelectedInputDevice() inputDeviceStatusMessage = "The previous macOS system input could not be restored." } - appLog.error("Input device selection failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Input device selection failed: \(error.localizedDescription)") if device == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -776,8 +779,8 @@ final class AppViewModel { guard !didShutdown, isOperational else { return } if let operationID = captureOperationID, captureHandle == nil { - appLog.error( - "Runtime audio failure invalidated pending capture: \(error.localizedDescription, privacy: .public)" + errorLogger.logError( + "Runtime audio failure invalidated pending capture: \(error.localizedDescription)" ) pendingStartTask?.cancel() invalidateCaptureOperation(ifCurrent: operationID) @@ -795,7 +798,7 @@ final class AppViewModel { guard let operationID = captureOperationID, let handle = captureHandle else { - appLog.error("Runtime audio failure: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Runtime audio failure: \(error.localizedDescription)") inputDeviceStatusMessage = "The microphone configuration failed: \(error.localizedDescription)" if selectedInputDeviceID == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) @@ -808,8 +811,8 @@ final class AppViewModel { return } - appLog.error( - "Runtime audio failure invalidated capture generation \(handle.generation): \(error.localizedDescription, privacy: .public)" + errorLogger.logError( + "Runtime audio failure invalidated capture generation \(handle.generation): \(error.localizedDescription)" ) activeProcessingTask?.cancel() pendingStartTask?.cancel() @@ -847,7 +850,7 @@ final class AppViewModel { overlayPanel.dismiss() state = .ready case .failure(let error): - appLog.error("Microphone retry validation failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Microphone retry validation failed: \(error.localizedDescription)") if selectedInputDeviceID == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -1110,7 +1113,7 @@ final class AppViewModel { prompts: customEnhancementPrompts ) guard let data = try? JSONEncoder().encode(store) else { - appLog.error("Failed to encode custom enhancement prompts") + errorLogger.logError("Failed to encode custom enhancement prompts") return } UserDefaults.standard.set(data, forKey: Self.customPromptsPreferenceKey) @@ -1130,8 +1133,8 @@ final class AppViewModel { } } catch { launchAtLogin = SMAppService.mainApp.status == .enabled - appLog.error( - "Launch-at-login update failed: \(error.localizedDescription, privacy: .public)" + errorLogger.logError( + "Launch-at-login update failed: \(error.localizedDescription)" ) if isOperational { presentRuntimeError( @@ -1272,7 +1275,7 @@ final class AppViewModel { !Task.isCancelled, case .preparingMicrophone = state else { return } - appLog.error("Microphone start failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Microphone start failed: \(error.localizedDescription)") if selectedInputDeviceID == nil { scheduleSystemDefaultRecovery(showTransientError: true, initialError: error) } else { @@ -1363,7 +1366,7 @@ final class AppViewModel { expectedState: expectedState ) else { return } guard let samples else { - appLog.error("Capture generation \(handle.generation) stopped without samples") + errorLogger.logError("Capture generation \(handle.generation) stopped without samples") invalidateCaptureOperation(ifCurrent: operationID) presentRuntimeError( AppErrorPresentation( @@ -1431,8 +1434,8 @@ final class AppViewModel { handle: handle, expectedState: .transcribing ) else { return } - appLog.error( - "Transcription failed for \(samples.count) captured samples: \(error.localizedDescription, privacy: .public)" + errorLogger.logError( + "Transcription failed for \(samples.count) captured samples: \(error.localizedDescription)" ) clearCaptureOperation(ifCurrent: operationID) presentRuntimeError( @@ -1531,7 +1534,7 @@ final class AppViewModel { } catch is CancellationError { return } catch { - appLog.error("Done overlay delay failed: \(error.localizedDescription, privacy: .public)") + errorLogger.logError("Done overlay delay failed: \(error.localizedDescription)") } guard isOperational, !didShutdown else { return } diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index f5e5aae..93f8030 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -159,15 +159,18 @@ final class PasteManager: TextPasting { private let pasteboard: any PasteboardManaging private let commandPoster: any PasteCommandPosting private let restoreScheduler: any PasteRestoreScheduling + private let errorLogger: any ErrorLogging init( pasteboard: any PasteboardManaging = SystemPasteboard(), commandPoster: any PasteCommandPosting = SystemPasteCommandPoster(), - restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler() + restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler(), + errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "PasteManager") ) { self.pasteboard = pasteboard self.commandPoster = commandPoster self.restoreScheduler = restoreScheduler + self.errorLogger = errorLogger } @discardableResult @@ -199,7 +202,7 @@ final class PasteManager: TextPasting { } guard let preparedTranscript = pasteboard.prepareWrite([transcriptItem]) else { - pasteLog.error("Failed to prepare transcript for the pasteboard") + errorLogger.logError("Failed to prepare transcript for the pasteboard") return .cancelled } @@ -210,7 +213,7 @@ final class PasteManager: TextPasting { let transcriptOwnershipChangeCount = pasteboard.clearContents() guard preparedTranscript.write() else { - pasteLog.error("Failed to write transcript to the pasteboard") + errorLogger.logError("Failed to write transcript to the pasteboard") _ = restore(snapshot, to: pasteboard, ifUnchangedSince: transcriptOwnershipChangeCount) return .cancelled } @@ -292,7 +295,7 @@ final class PasteManager: TextPasting { ifUnchangedSince expectedChangeCount: Int ) -> Bool { guard let preparedRestore = pasteboard.prepareWrite(snapshot.items) else { - pasteLog.error("Failed to prepare clipboard contents for restoration") + errorLogger.logError("Failed to prepare clipboard contents for restoration") return false } @@ -303,7 +306,7 @@ final class PasteManager: TextPasting { pasteboard.clearContents() guard preparedRestore.write() else { - pasteLog.error("Failed to restore clipboard contents") + errorLogger.logError("Failed to restore clipboard contents") return false } diff --git a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift index d2ffb83..6a4fb3f 100644 --- a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift +++ b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift @@ -112,6 +112,11 @@ struct AppViewModelDictationFlowTests { #expect(isRuntimeError(viewModel.state, kind: .transcription)) #expect(dependencies.overlay.didShowError("Transcription failed")) + #expect( + dependencies.errorLogger.messages.contains { + $0.hasPrefix("Transcription failed for 16000 captured samples:") + } + ) await waitUntil { viewModel.state.isReady } @@ -148,6 +153,11 @@ struct AppViewModelDictationFlowTests { dependencies.audio.onFailure?(.inputDeviceUnavailable(42)) await waitUntil { isRuntimeError(viewModel.state, kind: .audio) } + #expect( + dependencies.errorLogger.messages.contains { + $0.hasPrefix("Runtime audio failure invalidated capture generation 1:") + } + ) viewModel.retryMicrophone() #expect(viewModel.state.isReady) @@ -259,7 +269,8 @@ struct AppViewModelDictationFlowTests { transcriber: FakeTranscriber(behaviors: transcriptions), enhancer: FakeEnhancer(results: enhancements), paster: FakeTextPaster(outcomes: pasteOutcomes), - overlay: FakeOverlayPresenter() + overlay: FakeOverlayPresenter(), + errorLogger: RecordingErrorLogger() ) } @@ -318,6 +329,7 @@ private struct DictationDependencies { let enhancer: FakeEnhancer let paster: FakeTextPaster let overlay: FakeOverlayPresenter + let errorLogger: RecordingErrorLogger func makeViewModel( transientErrorDisplayDuration: Duration = .milliseconds(10) @@ -328,6 +340,7 @@ private struct DictationDependencies { grammarEnhancer: enhancer, pasteManager: paster, overlayPanel: overlay, + errorLogger: errorLogger, startsOperational: true, doneDisplayDuration: .milliseconds(1), transientErrorDisplayDuration: transientErrorDisplayDuration @@ -335,6 +348,15 @@ private struct DictationDependencies { } } +@MainActor +private final class RecordingErrorLogger: ErrorLogging { + private(set) var messages: [String] = [] + + func logError(_ message: String) { + messages.append(message) + } +} + private final class FakeAudioCapture: AudioCapturing, @unchecked Sendable { var onDevicesChanged: (@Sendable () -> Void)? var onFailure: (@Sendable (AudioEngineError) -> Void)? diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index 19dc90d..3e4adc8 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -8,7 +8,7 @@ struct PasteManagerTests { @Test func emptyClipboardPastesAndRestoresEmpty() { let pasteboard = FakePasteboard(items: [], returnsNilItemsWhenEmpty: true) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("Synthetic transcript") @@ -24,7 +24,7 @@ struct PasteManagerTests { @Test func savesReplacesPastesAndRestoresClipboard() { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("Synthetic transcript") @@ -40,7 +40,7 @@ struct PasteManagerTests { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) pasteboard.mutateWhenReading = [.text("External clipboard")] let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("Synthetic transcript") @@ -58,7 +58,7 @@ struct PasteManagerTests { ] ) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) let outcome = manager.pasteText("Synthetic transcript") @@ -72,7 +72,7 @@ struct PasteManagerTests { @Test func unreadableOnlyClipboardCancelsPasteWithoutClearing() { let pasteboard = FakePasteboard(items: [.unreadable(type: .fileURL)]) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("Synthetic transcript") @@ -89,7 +89,7 @@ struct PasteManagerTests { ] ) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("Synthetic transcript") @@ -101,7 +101,7 @@ struct PasteManagerTests { @Test func secondPasteRestoresOriginalClipboardAfterPendingRestore() { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("First synthetic transcript") manager.pasteText("Second synthetic transcript") @@ -117,7 +117,7 @@ struct PasteManagerTests { @Test func externalClipboardChangePreventsRestoreOverwrite() { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) manager.pasteText("Synthetic transcript") pasteboard.replaceExternally(with: [.text("External clipboard")]) @@ -130,7 +130,12 @@ struct PasteManagerTests { @Test func restorePreparationFailureLeavesCurrentClipboardUntouched() { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let errorLogger = RecordingPasteErrorLogger() + let manager = PasteManager( + pasteboard: pasteboard, + commandPoster: poster, + errorLogger: errorLogger + ) manager.pasteText("Synthetic transcript") pasteboard.failNextPreparation = true @@ -139,12 +144,13 @@ struct PasteManagerTests { #expect(pasteboard.text == "Synthetic transcript") #expect(pasteboard.clearCount == 1) #expect(poster.postCount == 1) + #expect(errorLogger.messages == ["Failed to prepare clipboard contents for restoration"]) } @Test func priorRestoreFailureCancelsNextPasteWithoutClearing() { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) let poster = FakePasteCommandPoster() - let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + let manager = makeManager(pasteboard: pasteboard, commandPoster: poster) #expect(manager.pasteText("First synthetic transcript") == .pasted) pasteboard.failNextPreparation = true @@ -161,7 +167,7 @@ struct PasteManagerTests { let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) let poster = FakePasteCommandPoster() let scheduler = FakePasteRestoreScheduler() - let manager = PasteManager( + let manager = makeManager( pasteboard: pasteboard, commandPoster: poster, restoreScheduler: scheduler @@ -183,6 +189,33 @@ struct PasteManagerTests { #expect(pasteboard.text == "Replacement clipboard") #expect(poster.postCount == 2) } + + private func makeManager( + pasteboard: any PasteboardManaging, + commandPoster: any PasteCommandPosting, + restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler() + ) -> PasteManager { + PasteManager( + pasteboard: pasteboard, + commandPoster: commandPoster, + restoreScheduler: restoreScheduler, + errorLogger: NoOpPasteErrorLogger() + ) + } +} + +@MainActor +private struct NoOpPasteErrorLogger: ErrorLogging { + func logError(_ message: String) {} +} + +@MainActor +private final class RecordingPasteErrorLogger: ErrorLogging { + private(set) var messages: [String] = [] + + func logError(_ message: String) { + messages.append(message) + } } @MainActor