From aea686e28504f37e807b44f2a57f0fe3a65dbccc Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 10:28:07 -0400 Subject: [PATCH] test: give each synthesizer test its own TalkSystemSpeechSynthesizer `preCancelledCallerThrows` failed in CI (run 36644330107, attempt 2) with `recorder.calls.isEmpty` false. It installs a recorder on `TalkSystemSpeechSynthesizer.shared` and suspends at `await attempt.result`. The main actor then ran `GatewayStateReportingWiringTests .systemSpeechReportsSpeakingThenIdle` from another suite, whose simulated start/finish on the same instance published to that recorder. `.serialized` only orders tests within a suite, so the two suites overlapped. A DEBUG-only `_test_make()` returns a fresh synthesizer (init stays private, no public API change). Every test that configured `shared` now uses its own instance, so the defers that restored `shared` are gone. Assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../TalkSystemSpeechSynthesizer.swift | 6 ++++ .../GatewayStateReportingWiringTests.swift | 4 +-- .../NowPlayingPublishingTests.swift | 30 +++++-------------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift b/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift index b7bdd0f..f70a4d5 100644 --- a/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift +++ b/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift @@ -282,6 +282,12 @@ extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate { #if DEBUG extension TalkSystemSpeechSynthesizer { + // Package tests use their own synthesizer: suites run in parallel, so tests that configure + // `shared` would see each other's Now Playing and state reports. + static func _test_make() -> TalkSystemSpeechSynthesizer { + TalkSystemSpeechSynthesizer() + } + // Package tests drive the Now Playing lifecycle without native speech services. func _test_simulateStart() { let utterance = AVSpeechUtterance(string: "test") diff --git a/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift b/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift index dbca099..638ceca 100644 --- a/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift +++ b/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift @@ -120,10 +120,8 @@ struct GatewayStateReportingWiringTests { @Test func systemSpeechReportsSpeakingThenIdle() { let reporter = RecordingStateReporter() - let synthesizer = TalkSystemSpeechSynthesizer.shared - let previous = synthesizer.stateReporter + let synthesizer = TalkSystemSpeechSynthesizer._test_make() synthesizer.stateReporter = reporter - defer { synthesizer.stateReporter = previous } synthesizer._test_simulateStart() synthesizer._test_simulateFinish() let talk = reporter.transitions.filter { $0.domain == .talk } diff --git a/Tests/OpenClawKitTests/NowPlayingPublishingTests.swift b/Tests/OpenClawKitTests/NowPlayingPublishingTests.swift index cab9f84..bbbdc72 100644 --- a/Tests/OpenClawKitTests/NowPlayingPublishingTests.swift +++ b/Tests/OpenClawKitTests/NowPlayingPublishingTests.swift @@ -162,6 +162,8 @@ struct NowPlayingPublishingTests { #endif } +// Each test makes its own synthesizer. Suites run in parallel, so configuring `shared` here would +// leak Now Playing and state reports into (and from) other suites' tests. @Suite("Talk system speech synthesizer", .serialized) @MainActor struct TalkSystemSpeechSynthesizerTests { @@ -197,10 +199,9 @@ struct TalkSystemSpeechSynthesizerTests { @Test("a pre-cancelled caller throws without touching the synthesizer") func preCancelledCallerThrows() async { - let speaker = TalkSystemSpeechSynthesizer.shared + let speaker = TalkSystemSpeechSynthesizer._test_make() let recorder = RecordingNowPlayingPublisher() speaker.nowPlayingPublisher = recorder - defer { speaker.nowPlayingPublisher = nil } let attempt = Task { @MainActor in try await speaker.speak(text: "Cancelled successor speech.", language: "en-US") } @@ -213,17 +214,12 @@ struct TalkSystemSpeechSynthesizerTests { @Test("speech publishes live Talk metadata on start and clears on finish") func speechPublishesNowPlayingLifecycle() { - let speaker = TalkSystemSpeechSynthesizer.shared + let speaker = TalkSystemSpeechSynthesizer._test_make() let recorder = RecordingNowPlayingPublisher() var speaking: [Bool] = [] speaker.nowPlayingPublisher = recorder speaker.nowPlayingTitle = "Molty" speaker.onSpeakingChanged = { speaking.append($0) } - defer { - speaker.nowPlayingPublisher = nil - speaker.nowPlayingTitle = nil - speaker.onSpeakingChanged = nil - } speaker._test_simulateStart() speaker._test_simulateFinish() @@ -246,10 +242,9 @@ struct TalkSystemSpeechSynthesizerTests { @Test("remote pause stops speech and clears Now Playing") func remotePauseStopsSpeech() { - let speaker = TalkSystemSpeechSynthesizer.shared + let speaker = TalkSystemSpeechSynthesizer._test_make() let recorder = RecordingNowPlayingPublisher() speaker.nowPlayingPublisher = recorder - defer { speaker.nowPlayingPublisher = nil } speaker._test_simulateStart() recorder.send(.pause) @@ -260,13 +255,9 @@ struct TalkSystemSpeechSynthesizerTests { @Test("an audio-session interruption publishes the interrupted state") func interruptionPublishesInterruptedState() { - let speaker = TalkSystemSpeechSynthesizer.shared + let speaker = TalkSystemSpeechSynthesizer._test_make() let recorder = RecordingNowPlayingPublisher() speaker.nowPlayingPublisher = recorder - defer { - speaker.nowPlayingPublisher = nil - speaker.stop() - } speaker._test_simulateStart() speaker.interruptForAudioSession() @@ -281,15 +272,11 @@ struct TalkSystemSpeechSynthesizerTests { @Test("the audio session controller interrupts the synthesizer through the protocol requirement") func controllerInterruptsSynthesizerThroughProtocol() { - let speaker = TalkSystemSpeechSynthesizer.shared + let speaker = TalkSystemSpeechSynthesizer._test_make() let recorder = RecordingNowPlayingPublisher() speaker.nowPlayingPublisher = recorder let controller = TalkAudioSessionController(session: nil, observeSystemEvents: false) controller.speech = speaker - defer { - speaker.nowPlayingPublisher = nil - speaker.stop() - } speaker._test_simulateStart() controller.handle(.interrupted) @@ -304,10 +291,9 @@ struct TalkSystemSpeechSynthesizerTests { @Test("an interruption with nothing playing publishes nothing") func idleInterruptionPublishesNothing() { - let speaker = TalkSystemSpeechSynthesizer.shared + let speaker = TalkSystemSpeechSynthesizer._test_make() let recorder = RecordingNowPlayingPublisher() speaker.nowPlayingPublisher = recorder - defer { speaker.nowPlayingPublisher = nil } speaker.interruptForAudioSession()