diff --git a/Sources/OpenClawKit/GatewayChannel+Testing.swift b/Sources/OpenClawKit/GatewayChannel+Testing.swift index 3e5c9f3..6d7f3ef 100644 --- a/Sources/OpenClawKit/GatewayChannel+Testing.swift +++ b/Sources/OpenClawKit/GatewayChannel+Testing.swift @@ -22,6 +22,11 @@ extension GatewayChannelActor { func _test_setRequestResumedHandler(_ handler: (@Sendable () async -> Void)?) { self.testRequestResumedHandler = handler } + + /// Called on the actor just before hello-ok's issued tokens are handed to the persistence hop. + func _test_setDeviceTokenPersistenceStartedHandler(_ handler: (@Sendable () -> Void)?) { + self.testDeviceTokenPersistenceStartedHandler = handler + } #endif func _test_pendingRequestCount() -> Int { diff --git a/Sources/OpenClawKit/GatewayChannel.swift b/Sources/OpenClawKit/GatewayChannel.swift index 283afad..ca38b24 100644 --- a/Sources/OpenClawKit/GatewayChannel.swift +++ b/Sources/OpenClawKit/GatewayChannel.swift @@ -94,6 +94,7 @@ public actor GatewayChannelActor { var testConnectRunFinishedHandler: (@Sendable () -> Void)? var testConnectFailureBackoffWaitHandler: (@Sendable () async throws -> Void)? var testRequestResumedHandler: (@Sendable () async -> Void)? + var testDeviceTokenPersistenceStartedHandler: (@Sendable () -> Void)? #endif private let connectChallengeTimeoutSeconds: Double = 6.0 // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, @@ -1349,6 +1350,9 @@ extension GatewayChannelActor { } var persistedRoles = Set() if let identity, !writes.isEmpty { + #if DEBUG + self.testDeviceTokenPersistenceStartedHandler?() + #endif persistedRoles = await Self.persistDeviceTokens( writes, deviceId: identity.deviceId, diff --git a/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift b/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift index b3ac75c..bd6a400 100644 --- a/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift +++ b/Tests/OpenClawKitE2ETests/ChannelAdaptersE2ETests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Channel adapters E2E") +@Suite("Channel adapters E2E", .timeLimit(.minutes(1))) struct ChannelAdaptersE2ETests { actor TelegramInboundCollector { private(set) var messages: [InboundMessage] = [] @@ -115,14 +115,26 @@ struct ChannelAdaptersE2ETests { #expect(sent.first?.text == "pong") } + struct WaitTimeout: Error, CustomStringConvertible { + let label: String + var description: String { + "Timeout waiting for: \(self.label)" + } + } + /// Polls a condition instead of sleeping a fixed interval (fixed sleeps flaked under load). - static func waitFor(timeoutSeconds: Double = 15, _ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = Date().addingTimeInterval(timeoutSeconds) - while Date() < deadline { + /// + /// There is no wall-clock deadline either: a saturated test pool must only slow the wait down. The + /// suite's `.timeLimit` cancels a real hang, which ends the wait with ``WaitTimeout``. + static func waitFor(_ label: String, _ condition: @escaping @Sendable () async -> Bool) async throws { + while !Task.isCancelled { if await condition() { return } - try await Task.sleep(nanoseconds: 10_000_000) + try? await Task.sleep(nanoseconds: 10_000_000) } - #expect(await condition(), "timed out waiting for condition") + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = WaitTimeout(label: label) + Issue.record(timeout) + throw timeout } @Test @@ -150,7 +162,7 @@ struct ChannelAdaptersE2ETests { await collector1.append(inbound) } try await adapter1.start() - try await Self.waitFor { await !collector1.snapshot().isEmpty } + try await Self.waitFor("first run delivered") { await !collector1.snapshot().isEmpty } await adapter1.stop() let collector2 = TelegramInboundCollector() @@ -164,7 +176,7 @@ struct ChannelAdaptersE2ETests { await collector2.append(inbound) } try await adapter2.start() - try await Self.waitFor { await !collector2.snapshot().isEmpty } + try await Self.waitFor("second run delivered") { await !collector2.snapshot().isEmpty } await adapter2.stop() let firstRun = await collector1.snapshot() diff --git a/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift b/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift index fa2e70b..649aabc 100644 --- a/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift +++ b/Tests/OpenClawKitE2ETests/GatewayTransportE2ETests.swift @@ -250,7 +250,9 @@ struct GatewayTransportE2ETests { #expect(counter.get() == baseline) } - @Test + /// No wall-clock bound on the reconnect wait: a saturated test pool can stall the run for seconds. The + /// time limit ends the wait if the client stops reconnecting. + @Test(.timeLimit(.minutes(1))) func reconnectFailureSchedulesAnotherAttempt() async throws { let counter = Counter() let client = GatewayClient( @@ -272,9 +274,8 @@ struct GatewayTransportE2ETests { try await client.connect(to: GatewayEndpoint(url: URL(string: "ws://127.0.0.1:18789")!)) // Wait for the third socket (stale tick -> reconnect -> connect failure -> another attempt) // instead of a fixed 220 ms, which slower CI runners can overrun. - let deadline = Date().addingTimeInterval(10) - while counter.get() <= 2, Date() < deadline { - try await Task.sleep(nanoseconds: 10_000_000) + while counter.get() <= 2, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000) } #expect(counter.get() > 2) await client.disconnect() diff --git a/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift b/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift index d8808e7..d090c77 100644 --- a/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift +++ b/Tests/OpenClawKitTests/AsyncTimeoutRaceTests.swift @@ -21,21 +21,56 @@ private final class RaceCounter: @unchecked Sendable { } } +/// An operation that ignores cancellation entirely (the keepalive wedge): it parks on a checked +/// continuation until the test itself calls `open()`. +private final class RaceGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiter: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation in + self.lock.lock() + guard !self.isOpen else { + self.lock.unlock() + continuation.resume() + return + } + self.waiter = continuation + self.lock.unlock() + } + } + + func open() { + self.lock.lock() + self.isOpen = true + let waiter = self.waiter + self.waiter = nil + self.lock.unlock() + waiter?.resume() + } +} + @Suite("AsyncTimeout race") struct AsyncTimeoutRaceTests { - @Test + /// Only the 50 ms deadline can end the race: the operation stays parked until the test opens its + /// gate. A race that joined its loser would hang and trip the time limit, whose cancellation opens + /// the gate so the test body can end. No wall-clock bound: a saturated test pool can stall the run + /// for seconds. + @Test(.timeLimit(.minutes(1))) func operationThatIgnoresCancellationStillTimesOut() async { - let start = ContinuousClock.now - await #expect(throws: RaceTimeoutError.self) { - try await AsyncTimeout.withTimeout( - seconds: 0.05, - onTimeout: { RaceTimeoutError() }, - operation: { - // A never-resumed continuation ignores cancellation entirely (the keepalive wedge). - await withCheckedContinuation { (_: CheckedContinuation) in } - }) + let gate = RaceGate() + defer { gate.open() } + await withTaskCancellationHandler { + await #expect(throws: RaceTimeoutError.self) { + try await AsyncTimeout.withTimeout( + seconds: 0.05, + onTimeout: { RaceTimeoutError() }, + operation: { await gate.wait() }) + } + } onCancel: { + gate.open() } - #expect(ContinuousClock.now - start < .seconds(5)) } @Test diff --git a/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift b/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift index 0eae1a2..7f3b9c7 100644 --- a/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift +++ b/Tests/OpenClawKitTests/ChannelDeliveryHardeningTests.swift @@ -9,7 +9,7 @@ import Testing /// 2026.3.0 FX6 regressions: partial multi-part delivery, ambiguous 5xx, remote numeric input, /// iMessage post-write failures and media budgets. -@Suite("Channel delivery hardening") +@Suite("Channel delivery hardening", .timeLimit(.minutes(1))) struct ChannelDeliveryHardeningTests { actor OffsetStore: TelegramUpdateOffsetStore { func readLastUpdateID() async -> Int64? { nil } diff --git a/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift b/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift index f6f7a33..4e8afdc 100644 --- a/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift +++ b/Tests/OpenClawKitTests/ChannelErrorRedactionTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import Testing /// Credential redaction for channel health, `channels.status`, diagnostics and delivery failures. -@Suite("Channel error redaction") +@Suite("Channel error redaction", .timeLimit(.minutes(1))) struct ChannelErrorRedactionTests { actor OffsetStore: TelegramUpdateOffsetStore { func readLastUpdateID() async -> Int64? { nil } diff --git a/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift b/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift index 0832180..aa3f38d 100644 --- a/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift +++ b/Tests/OpenClawKitTests/ChannelPairingStoreTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import OpenClawChannels -@Suite("Channel DM pairing store") +@Suite("Channel DM pairing store", .timeLimit(.minutes(1))) struct ChannelPairingStoreTests { final class Clock: @unchecked Sendable { private let lock = NSLock() diff --git a/Tests/OpenClawKitTests/ChatComposerParityTests.swift b/Tests/OpenClawKitTests/ChatComposerParityTests.swift index 93a9c1e..e27f1b1 100644 --- a/Tests/OpenClawKitTests/ChatComposerParityTests.swift +++ b/Tests/OpenClawKitTests/ChatComposerParityTests.swift @@ -80,6 +80,7 @@ struct ChatReplyQuoteTests { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatComposerStateTests { @Test func `model selection target describes only gateway owned values`() { let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: ComposerParityTransport()) diff --git a/Tests/OpenClawKitTests/ChatComposerShellTests.swift b/Tests/OpenClawKitTests/ChatComposerShellTests.swift index bd6dae8..3d27a8f 100644 --- a/Tests/OpenClawKitTests/ChatComposerShellTests.swift +++ b/Tests/OpenClawKitTests/ChatComposerShellTests.swift @@ -264,6 +264,7 @@ struct ChatPrivateCloudQuotaNoticeTests { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatSessionPagingTests { @Test func `paging status summarizes truncated lists only`() { #expect(ChatSessionPagingStatus(loadedCount: 10, totalCount: 10, isTruncated: false) == nil) diff --git a/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift b/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift index f983944..def0d69 100644 --- a/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift +++ b/Tests/OpenClawKitTests/ChatCoreCompatibilityTests.swift @@ -140,7 +140,7 @@ private func bootstrappedViewModel(_ transport: ScriptedEventTransport) async th return viewModel } -@Suite("Chat core 2026.3.0 compatibility") +@Suite("Chat core 2026.3.0 compatibility", .timeLimit(.minutes(1))) struct ChatCoreCompatibilityTests { // MARK: Legacy transport bridge diff --git a/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift b/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift index 3249a1f..6b357b9 100644 --- a/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift +++ b/Tests/OpenClawKitTests/ChatGatewaySessionTransportTests.swift @@ -62,7 +62,7 @@ private func sentParams(_ socket: GatewayCoreFakeSocket, method: String) -> [[St socket.sentFrames(method: method).compactMap { $0["params"] as? [String: Any] } } -@Suite("Gateway session chat transport", .serialized) +@Suite("Gateway session chat transport", .serialized, .timeLimit(.minutes(1))) struct ChatGatewaySessionTransportTests { @Test func `init normalizes the agent and keeps exact gateway id bytes`() { let transport = OpenClawGatewaySessionChatTransport( @@ -384,7 +384,7 @@ struct ChatGatewaySessionTransportTests { // Same connection context, new socket: seqGap plus a fresh per-socket subscription. socket.emitReceiveFailure() - try await gatewayCoreWaitUntil("reconnect seqGap", timeoutSeconds: 15) { + try await gatewayCoreWaitUntil("reconnect seqGap") { recorder.values.contains("seqGap") } let reconnected = try #require(session.latestSocket) @@ -396,7 +396,7 @@ struct ChatGatewaySessionTransportTests { // A different endpoint is a different connection context: routeChanged. let replacement = transportTestSession(capabilities: []) try await gateway.connectForChatTransportTest("ws://replacement.example.invalid", session: replacement) - try await gatewayCoreWaitUntil("route change reported", timeoutSeconds: 15) { + try await gatewayCoreWaitUntil("route change reported") { recorder.values.contains("routeChanged") } let replacementSocket = try #require(replacement.latestSocket) diff --git a/Tests/OpenClawKitTests/ChatHapticsTests.swift b/Tests/OpenClawKitTests/ChatHapticsTests.swift index e855b67..2141011 100644 --- a/Tests/OpenClawKitTests/ChatHapticsTests.swift +++ b/Tests/OpenClawKitTests/ChatHapticsTests.swift @@ -92,6 +92,7 @@ private func sendHapticsTestMessage(_ viewModel: OpenClawChatViewModel) async { } } +@Suite(.timeLimit(.minutes(1))) struct ChatHapticsTests { @Test func `send acceptance fires message sent exactly once`() async throws { let (_, viewModel, recorder) = await makeHapticsViewModel(status: "started") diff --git a/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift b/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift index fae006b..cbf04ad 100644 --- a/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift +++ b/Tests/OpenClawKitTests/ChatLinkPreviewTests.swift @@ -1,5 +1,6 @@ import Foundation import ImageIO +import OpenClawKit import Testing import UniformTypeIdentifiers @testable import OpenClawChatUI @@ -212,9 +213,17 @@ struct ChatLinkPreviewNetworkTests { #expect(ChatLinkPreviewStubURLProtocol.lastAcceptHeader == "text/html") } - @Test func `total deadline can fire before the session starts`() async throws { + /// The protocol never answers and URLSession's own timeouts sit past the time limit, so only the + /// fetcher's zero-second deadline can end the fetch in time. A deadline lost before `start` could + /// leave the fetch unresumed even on cancellation, so it is awaited through a no-deadline + /// `AsyncTimeout` race, which the time limit's cancellation always ends. No wall-clock bound: a + /// saturated test pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) + func `total deadline can fire before the session starts`() async throws { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [ChatLinkPreviewHangingURLProtocol.self] + configuration.timeoutIntervalForRequest = 3600 + configuration.timeoutIntervalForResource = 3600 let fetcher = ChatLinkPreviewFetcher( configuration: configuration, timeout: 0, @@ -222,11 +231,11 @@ struct ChatLinkPreviewNetworkTests { resolutionPolicy: { _ in true }, connectionPolicy: { _ in true }) let url = try #require(URL(string: "https://preview.test/slow")) - let clock = ContinuousClock() - let start = clock.now - #expect(await fetcher.fetch(url) == .failed) - #expect(start.duration(to: clock.now) < .seconds(1)) + let result = try await AsyncTimeout.withTimeout(seconds: 0, onTimeout: { CancellationError() }) { + await fetcher.fetch(url) + } + #expect(result == .failed) } @Test func `image fetch accepts only images and enforces its body cap`() async throws { diff --git a/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift b/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift index e41e7ce..8df8da2 100644 --- a/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift +++ b/Tests/OpenClawKitTests/ChatSessionSidebarPreviewsTests.swift @@ -63,6 +63,7 @@ private actor SidebarPreviewCache: OpenClawChatTranscriptCache { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatSessionSidebarPreviewsTests { @Test(arguments: [false, true]) func `changing Gateway owners never reuses an identical session preview`(oldLoadPending: Bool) async throws { diff --git a/Tests/OpenClawKitTests/ChatStreamReplayTests.swift b/Tests/OpenClawKitTests/ChatStreamReplayTests.swift index 76f302c..fda8bc5 100644 --- a/Tests/OpenClawKitTests/ChatStreamReplayTests.swift +++ b/Tests/OpenClawKitTests/ChatStreamReplayTests.swift @@ -372,6 +372,7 @@ Closing paragraph with unicode — dashes, émojis 🦀🚀, and a trailing line /// Covers streaming accumulation, provisional-final reconciliation against durable /// `session.message` rows, duplicate delivery, out-of-order arrival, and reconnect /// convergence. Tracking: #100196. +@Suite(.timeLimit(.minutes(1))) struct ChatStreamReplayTests { @Test func `live session message marker produces a visible transcript row`() async throws { let harness = try await StreamReplayHarness.bootstrapped() diff --git a/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift b/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift index 9855899..1cf40b0 100644 --- a/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelAgentNavigationTests.swift @@ -250,6 +250,7 @@ private final class AgentNavigationFixture { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelAgentNavigationTests { private func globalSession(owner: String) -> OpenClawChatSessionEntry { var entry = OpenClawChatSessionEntry.placeholder(key: "global") diff --git a/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift b/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift index fb3666a..634172e 100644 --- a/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift @@ -261,6 +261,7 @@ private func chatAttachmentDimensions(for data: Data) -> (width: Int, height: In return (width.intValue, height.intValue) } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelAttachmentTests { @Test func imageAttachmentsAreProcessedBeforeStaging() async throws { let imageData = try makeChatAttachmentJPEG(width: 3000, height: 4000) diff --git a/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift b/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift index ed6294d..97ebd96 100644 --- a/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelOutboxSettingsTests.swift @@ -16,6 +16,7 @@ private actor SettingsPatchCounter { } } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelOutboxSettingsTests { @Test func `background replay uses its command owned session settings`() async throws { let (store, _, databaseDirectory) = try makeOutboxStore() diff --git a/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift b/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift index 99d9453..956c40a 100644 --- a/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift @@ -832,7 +832,7 @@ actor ScriptedOutbox: OpenClawChatCommandOutbox { } // Serialized: every case opens a real SQLite outbox (see ChatTranscriptCacheStoreTests). -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) struct ChatViewModelOutboxTests { @Test func `offline send queues durably and renders queued row`() async throws { let (store, _, databaseDirectory) = try makeOutboxStore() @@ -1714,7 +1714,7 @@ struct ChatViewModelOutboxTests { vm.messages.first { vm.outboxState(for: $0.id)?.isFailed == true }?.id }) await MainActor.run { vm.retryOutboxMessage(failedMessageID) } - try await waitUntil("retried command drained", timeoutSeconds: 30) { + try await waitUntil("retried command drained") { await store.loadCommands().isEmpty } #expect(await transport.state.sentIdempotencyKeys.count == 1) @@ -1856,7 +1856,7 @@ struct ChatViewModelOutboxTests { let messageID = try #require(await MainActor.run { vm.messages.last?.id }) await MainActor.run { vm.retryOutboxMessage(messageID) } - try await waitUntil("explicit retry drained", timeoutSeconds: 10) { + try await waitUntil("explicit retry drained") { await store.loadCommands().isEmpty } #expect(await transport.state.sentIdempotencyKeys == [preserved.id]) diff --git a/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift b/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift index d0bcf9c..adbda1e 100644 --- a/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelSessionActionTests.swift @@ -511,6 +511,7 @@ private struct BatchTestError: LocalizedError { } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelSessionActionTests { @Test func `batch mutations continue after per-row failure with bounded fan-out`() async { let probe = BatchMutationProbe() @@ -1402,81 +1403,38 @@ struct ChatViewModelSessionActionTests { #expect(await transport.forkedParentKeys() == ["main"]) } - private func waitForForkStart( - _ gate: SessionActionCompletionGate, - timeout: Duration = .seconds(15)) async -> Bool - { - // The stream controls ordering; this deadline only bounds a broken fake or call path. - await withTaskGroup(of: Bool.self) { group in - group.addTask { await gate.waitUntilStarted() } - group.addTask { - try? await Task.sleep(for: timeout) - return false - } - let started = await group.next() ?? false - group.cancelAll() - return started - } + private func waitForForkStart(_ gate: SessionActionCompletionGate) async -> Bool { + // The stream controls ordering; the suite's time limit bounds a broken fake or call path, + // and its cancellation ends the stream wait with `false`. + await gate.waitUntilStarted() } - private func waitForBranchSwitchActivityToClear( - _ viewModel: OpenClawChatViewModel, - timeout: Duration = .seconds(15)) async -> Bool - { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if viewModel.hasBlockingRunActivity == false { - return true - } - await Task.yield() - } - return false + private func waitForBranchSwitchActivityToClear(_ viewModel: OpenClawChatViewModel) async -> Bool { + await self.eventually { viewModel.hasBlockingRunActivity == false } } - private func waitForOutboxRestore( - _ viewModel: OpenClawChatViewModel, - timeout: Duration = .seconds(15)) async -> Bool - { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if viewModel.hasRestoredOutboxMessages, - viewModel.hasPendingOutboxCommandsForCurrentSession - { - return true - } - await Task.yield() + private func waitForOutboxRestore(_ viewModel: OpenClawChatViewModel) async -> Bool { + await self.eventually { + viewModel.hasRestoredOutboxMessages && viewModel.hasPendingOutboxCommandsForCurrentSession } - return false } - private func waitForSend( - _ transport: SessionActionTransport, - timeout: Duration = .seconds(15)) async -> Bool - { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if await transport.sentSessionKeys().isEmpty == false { - return true - } - await Task.yield() - } - return false + private func waitForSend(_ transport: SessionActionTransport) async -> Bool { + await self.eventually { await transport.sentSessionKeys().isEmpty == false } } private func waitForBranchReload( _ viewModel: OpenClawChatViewModel, - branches: [OpenClawChatSessionBranch], - timeout: Duration = .seconds(15)) async -> Bool + branches: [OpenClawChatSessionBranch]) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while clock.now < deadline { - if viewModel.sessionBranches == branches, !viewModel.isLoading { - return true - } + await self.eventually { viewModel.sessionBranches == branches && !viewModel.isLoading } + } + + /// Yields until `condition` holds. There is no wall-clock deadline, so a saturated test pool only + /// slows the wait down; the suite's time limit cancels a real hang, which ends the wait with `false`. + private func eventually(_ condition: () async -> Bool) async -> Bool { + while !Task.isCancelled { + if await condition() { return true } await Task.yield() } return false diff --git a/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift b/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift index cf78d4f..e27de06 100644 --- a/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelSessionDeletionTests.swift @@ -54,6 +54,7 @@ private final class DeleteSessionTestTransport: @unchecked Sendable, OpenClawCha } @MainActor +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelSessionDeletionTests { @Test func `deleting the active main session re-bootstraps in place`() async throws { let transport = DeleteSessionTestTransport() diff --git a/Tests/OpenClawKitTests/ChatViewModelTests.swift b/Tests/OpenClawKitTests/ChatViewModelTests.swift index 0894844..1a227ab 100644 --- a/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -1667,6 +1667,7 @@ private actor SwarmCapabilityScript { } } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelTests { @Test func `legacy plan renders only when progress card store is unavailable`() async throws { let (_, vm) = await makeViewModel( @@ -3853,7 +3854,7 @@ struct ChatViewModelTests { await historyCalls.current() >= 2 } #expect(await MainActor.run { vm.pendingRunCount == 1 }) - try await waitUntil("post-send fallback keeps known run ownership", timeoutSeconds: 7.0) { + try await waitUntil("post-send fallback keeps known run ownership") { let historyCount = await historyCalls.current() let pendingRunCount = await MainActor.run { vm.pendingRunCount } return historyCount >= 3 && pendingRunCount == 1 @@ -10833,7 +10834,7 @@ struct ChatViewModelTests { await staleFallbackReleasedCount.current() == 1 } - try await waitUntil("later fallback still runs", timeoutSeconds: 7.0) { + try await waitUntil("later fallback still runs") { await mainHistoryCount.current() >= 5 } try await waitUntil("later fallback applies assistant reply") { @@ -14032,7 +14033,7 @@ struct ChatViewModelTests { } } -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) struct ChatViewModelSessionManagementTests { @Test @MainActor func `session list organizer orders pinned first with key tiebreak`() { let organized = OpenClawChatSessionListOrganizer.organize([ diff --git a/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift b/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift index 3c02aee..c3ec4b1 100644 --- a/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelTranscriptCacheTests.swift @@ -177,6 +177,7 @@ private func makeViewModel( return vm } +@Suite(.timeLimit(.minutes(1))) struct ChatViewModelTranscriptCacheTests { @Test func `cold open paints cached transcript then live history replaces it`() async throws { let cache = TestTranscriptCache( diff --git a/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift b/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift index 444fb9d..a60baea 100644 --- a/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift +++ b/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift @@ -204,7 +204,7 @@ extension UnreadTestTransportState { } } -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) @MainActor struct ChatViewModelUnreadTests { @Test func `successful activation clears unread once`() async throws { diff --git a/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift b/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift index 38b6ccc..5eff39e 100644 --- a/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/DiscordChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Discord channel adapter") +@Suite("Discord channel adapter", .timeLimit(.minutes(1))) struct DiscordChannelAdapterTests { actor InboundCollector { private(set) var messages: [InboundMessage] = [] diff --git a/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift b/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift index 5e6692c..00d996e 100644 --- a/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift +++ b/Tests/OpenClawKitTests/DiscordGatewayAdapterTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("Discord gateway adapter") +@Suite("Discord gateway adapter", .timeLimit(.minutes(1))) struct DiscordGatewayAdapterTests { static let hello = #"{"op":10,"d":{"heartbeat_interval":45000}}"# static let ready = #"{"op":0,"s":1,"t":"READY","d":{"session_id":"sess-1","resume_gateway_url":"wss://resume.example","user":{"id":"bot-id"}}}"# @@ -175,7 +175,7 @@ struct DiscordGatewayAdapterTests { try await waitUntil("other channel delivered while the slow turn runs") { await collector.messages.contains { $0.peerID == "fast" } } - try await waitUntil("at least two heartbeats acknowledged", timeoutSeconds: 10) { + try await waitUntil("at least two heartbeats acknowledged") { await socket.sentFrames().filter { (jsonObject($0)["op"] as? Int) == 1 }.count >= 2 } // Give a missed ACK time to trip the zombie check (next beat after the first). diff --git a/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift b/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift index d33dc18..e84a465 100644 --- a/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift +++ b/Tests/OpenClawKitTests/FileTransferNodeCommandsTests.swift @@ -217,15 +217,23 @@ struct FileTransferNodeCommandsTests { // MARK: - Drift between preflight and the final call - @Test func `file fetch refuses a FIFO promptly`() async throws { + /// A fetch that opened the FIFO without `O_NONBLOCK` would wait forever for a writer and trip the + /// time limit. That hang is a syscall, not an await, so the time limit's cancellation opens the write + /// end once to release the reader and let the test body end. No wall-clock bound: a saturated test + /// pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) + func `file fetch refuses a FIFO promptly`() async throws { let sandbox = try FileTransferSandbox() defer { sandbox.cleanUp() } let fifo = sandbox.root + "/pipe" try #require(mkfifo(fifo, 0o600) == 0) let commands = sandbox.commands - let started = ContinuousClock.now - let result = try await self.invoke(commands, "file.fetch", ["path": fifo]) - #expect(ContinuousClock.now - started < .seconds(2)) + let result = try await withTaskCancellationHandler { + try await self.invoke(commands, "file.fetch", ["path": fifo]) + } onCancel: { + let writer = open(fifo, O_WRONLY | O_NONBLOCK) + if writer >= 0 { close(writer) } + } #expect(result["ok"] as? Bool == false) #expect(result["code"] as? String == "IS_DIRECTORY") } diff --git a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift index b711fae..2e05193 100644 --- a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift +++ b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift @@ -37,7 +37,7 @@ private func makeChannel( extraHeadersProvider: extraHeadersProvider) } -@Suite("Gateway channel lifecycle") +@Suite("Gateway channel lifecycle", .timeLimit(.minutes(1))) struct GatewayChannelLifecycleTests { @Test func operatorConnectOffersProtocolFourWithPlatformDefaults() async throws { diff --git a/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift b/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift index ba7f8aa..7cc763a 100644 --- a/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift +++ b/Tests/OpenClawKitTests/GatewayConnectRecoveryTests.swift @@ -37,7 +37,7 @@ private let pinMismatch = GatewayTLSValidationFailure( systemTrustOk: true, port: 443) -@Suite("Gateway connect recovery", .serialized) +@Suite("Gateway connect recovery", .serialized, .timeLimit(.minutes(1))) struct GatewayConnectRecoveryTests { // MARK: TLS pin mismatch @@ -232,7 +232,7 @@ struct GatewayConnectRecoveryTests { await channel.reconnectPauseReason() == .authFailure } #expect(session.makeCount == 2) - try await gatewayCoreWaitUntil("resumed after retryAfterMs", timeoutSeconds: 10) { + try await gatewayCoreWaitUntil("resumed after retryAfterMs") { guard session.makeCount == 3 else { return false } return await channel.currentConnectionGeneration() != nil } diff --git a/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift b/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift index e05807a..3c5c243 100644 --- a/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift +++ b/Tests/OpenClawKitTests/GatewayCoreTestSupport.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawProtocol +import Testing @testable import OpenClawKit // In-memory WebSocket transport for gateway channel and node session tests. It scripts @@ -392,18 +393,23 @@ struct GatewayCoreWaitTimeout: Error, CustomStringConvertible { } } +/// Polls `condition` until it holds. +/// +/// There is no wall-clock deadline: a saturated test pool must only slow the wait down. Every suite +/// that calls this carries a `.timeLimit`, whose cancellation ends the wait with +/// ``GatewayCoreWaitTimeout``. func gatewayCoreWaitUntil( _ label: String, - timeoutSeconds: Double = 10, _ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = ContinuousClock.now.advanced(by: .milliseconds(Int64(timeoutSeconds * 1000))) - while ContinuousClock.now < deadline { + while !Task.isCancelled { if await condition() { return } - try await Task.sleep(for: .milliseconds(5)) + try? await Task.sleep(for: .milliseconds(5)) } - if await condition() { return } - throw GatewayCoreWaitTimeout(label: label) + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = GatewayCoreWaitTimeout(label: label) + Issue.record(timeout) + throw timeout } func gatewayCoreTemporaryStateDirectory() throws -> URL { diff --git a/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift b/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift index 47887c9..782a52a 100644 --- a/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift +++ b/Tests/OpenClawKitTests/GatewayDeviceAuthOffActorTests.swift @@ -35,8 +35,13 @@ private final class StateDatabaseWriteLock: @unchecked Sendable { } } -@Suite("Gateway device auth off the channel actor", .serialized) +@Suite("Gateway device auth off the channel actor", .serialized, .timeLimit(.minutes(1))) struct GatewayDeviceAuthOffActorTests { + /// Ordering, not elapsed time, proves the actor stayed responsive: the lock is released only after + /// the actor answers, so the token can reach disk only if the actor answered while the write was + /// still pending. A write on the actor would hold every call until SQLite's 30 s busy timeout fails + /// it; the token never lands and the final wait trips the time limit. The only timing left is that + /// the healthy write must see the release within the same 30 s busy timeout. @Test func issuedTokenPersistenceNeverBlocksTheChannelActor() async throws { let directory = try gatewayCoreTemporaryStateDirectory() @@ -59,15 +64,17 @@ struct GatewayDeviceAuthOffActorTests { token: "shared", session: WebSocketSessionBox(session: session), connectOptions: gatewayCoreOptions(includeDeviceIdentity: true)) + let (persistenceStarts, persistenceStarted) = AsyncStream.makeStream(of: Void.self) + await channel._test_setDeviceTokenPersistenceStartedHandler { persistenceStarted.yield() } let connect = Task { try await channel.connect() } - try await gatewayCoreWaitUntil("write lock held") { writeLock.isHeld } - try await Task.sleep(for: .milliseconds(200)) + // Wait until hello-ok's token is handed to the persistence hop, which shutdown cannot stop. + var starts = persistenceStarts.makeAsyncIterator() + guard await starts.next() != nil else { throw CancellationError() } + try #require(writeLock.isHeld) - // The token write waits on SQLite's 30 s busy timeout; the actor must stay responsive. - let started = ContinuousClock.now + // The write cannot finish while the lock is held; the actor must still answer. #expect(await channel.currentConnectionGeneration() == nil) await channel.shutdown() - #expect(ContinuousClock.now - started < .seconds(3)) writeLock.release() _ = try? await connect.value diff --git a/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift b/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift index 8a36913..3603f8b 100644 --- a/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift +++ b/Tests/OpenClawKitTests/GatewayNodeSessionRouteTests.swift @@ -148,7 +148,7 @@ private func respondToSurfaceRefresh( reply: .ok(["surface": "canvas", "pluginSurfaceUrls": ["canvas": url]])) } -@Suite("Gateway node session routes", .serialized) +@Suite("Gateway node session routes", .serialized, .timeLimit(.minutes(1))) struct GatewayNodeSessionRouteTests { @Test func invokeMetadataReachesTheHandlerAndResultCarriesStructuredPayload() async throws { diff --git a/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift b/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift index d915160..6313210 100644 --- a/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift +++ b/Tests/OpenClawKitTests/GatewayRequestBudgetTests.swift @@ -10,7 +10,7 @@ private func budgetChannel(session: GatewayCoreFakeSession) throws -> GatewayCha connectOptions: gatewayCoreOptions()) } -@Suite("Gateway request budget", .serialized) +@Suite("Gateway request budget", .serialized, .timeLimit(.minutes(1))) struct GatewayRequestBudgetTests { @Test func slowConnectDoesNotConsumeTheRequestBudget() async throws { diff --git a/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift b/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift index 638ceca..fcdf638 100644 --- a/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift +++ b/Tests/OpenClawKitTests/GatewayStateReportingWiringTests.swift @@ -21,7 +21,7 @@ private func gatewayLabels(_ reporter: RecordingStateReporter) -> [String?] { reporter.transitions.filter { $0.domain == .gateway }.map(\.label) } -@Suite("Gateway state reporting wiring", .serialized) +@Suite("Gateway state reporting wiring", .serialized, .timeLimit(.minutes(1))) struct GatewayStateReportingWiringTests { @Test func connectReportsTheHandshakeLifecycleWithStableContext() async throws { diff --git a/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift b/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift index 85dc577..36c6a3c 100644 --- a/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift +++ b/Tests/OpenClawKitTests/GatewayTLSPinRotationRecoveryTests.swift @@ -16,7 +16,7 @@ private func mismatch(storeKey: String) -> GatewayTLSValidationFailure { port: 443) } -@Suite("Gateway TLS pin rotation recovery", .serialized, .gatewayTLSStoreIsolated) +@Suite("Gateway TLS pin rotation recovery", .serialized, .gatewayTLSStoreIsolated, .timeLimit(.minutes(1))) struct GatewayTLSPinRotationRecoveryTests { @Test func pinningSessionAcceptsAReviewedRotationInPlace() throws { diff --git a/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift b/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift index b960237..4de5612 100644 --- a/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift +++ b/Tests/OpenClawKitTests/IMAPMailboxWatcherTests.swift @@ -122,7 +122,7 @@ actor FakeIMAPServer: IMAPTransport { } } -@Suite("IMAP mailbox watcher") +@Suite("IMAP mailbox watcher", .timeLimit(.minutes(1))) struct IMAPMailboxWatcherTests { actor Turns { private(set) var turns: [IMAPHookTurn] = [] diff --git a/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift b/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift index bd778c9..7c821cf 100644 --- a/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift +++ b/Tests/OpenClawKitTests/IMsgRPCTransportTests.swift @@ -64,7 +64,7 @@ struct RPCParams: @unchecked Sendable { } } -@Suite("imsg JSON-RPC transport") +@Suite("imsg JSON-RPC transport", .timeLimit(.minutes(1))) struct IMsgRPCTransportTests { static func standardResponder(_ method: String, _: [String: Any]) -> String? { switch method { @@ -231,7 +231,7 @@ struct IMsgRPCTransportTests { } } -@Suite("iMessage private-API actions and catch-up") +@Suite("iMessage private-API actions and catch-up", .timeLimit(.minutes(1))) struct IMessagePrivateActionsTests { @Test func actionsUseChatGUIDFromInboundAndTypingDisablesAfterFailure() async throws { diff --git a/Tests/OpenClawKitTests/OpenClawChatUITests.swift b/Tests/OpenClawKitTests/OpenClawChatUITests.swift index 114fba9..26ffadb 100644 --- a/Tests/OpenClawKitTests/OpenClawChatUITests.swift +++ b/Tests/OpenClawKitTests/OpenClawChatUITests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import OpenClawChatUI -@Suite("OpenClaw chat UI") +@Suite("OpenClaw chat UI", .timeLimit(.minutes(1))) struct OpenClawChatUITests { @Test func chatPayloadDecodingSupportsLegacyStringContentAndUsageFallback() throws { @@ -77,7 +77,7 @@ struct OpenClawChatUITests { } @Test - func chatViewModelBootstrapLoadsHistoryModelsAndSessions() async { + func chatViewModelBootstrapLoadsHistoryModelsAndSessions() async throws { let transport = MockChatTransport( historyBySession: [ "main": OpenClawChatHistoryPayload( @@ -152,7 +152,7 @@ struct OpenClawChatUITests { viewModel.load() } - let loaded = await Self.waitUntil { + try await waitUntil("bootstrap loaded history, models and sessions") { await MainActor.run { !viewModel.isLoading && viewModel.healthOK && @@ -161,7 +161,6 @@ struct OpenClawChatUITests { } } - #expect(loaded) await MainActor.run { #expect(viewModel.thinkingLevel == "high") #expect(viewModel.messages.first?.content.first?.text == "Please help.") @@ -175,7 +174,7 @@ struct OpenClawChatUITests { } @Test - func chatViewModelAppliesAgentStreamAndPendingToolEvents() async { + func chatViewModelAppliesAgentStreamAndPendingToolEvents() async throws { let transport = MockChatTransport( historyBySession: [ "main": OpenClawChatHistoryPayload( @@ -226,10 +225,9 @@ struct OpenClawChatUITests { viewModel.load() } - let bootstrapped = await Self.waitUntil { + try await waitUntil("bootstrap") { await MainActor.run { !viewModel.isLoading && viewModel.healthOK } } - #expect(bootstrapped) transport.emit( .agent( @@ -243,10 +241,9 @@ struct OpenClawChatUITests { ) ) - let sawStreamingText = await Self.waitUntil { + try await waitUntil("streaming text applied") { await MainActor.run { viewModel.streamingAssistantText == "streaming reply" } } - #expect(sawStreamingText) transport.emit( .agent( @@ -265,10 +262,9 @@ struct OpenClawChatUITests { ) ) - let sawPendingTool = await Self.waitUntil { + try await waitUntil("pending tool call applied") { await MainActor.run { viewModel.pendingToolCalls.count == 1 } } - #expect(sawPendingTool) await MainActor.run { #expect(viewModel.pendingToolCalls.first?.name == "browser") #expect(viewModel.pendingToolCalls.first?.args?.dictionaryValue?["url"] == AnyCodable("https://docs.openclaw.ai")) @@ -290,24 +286,9 @@ struct OpenClawChatUITests { ) ) - let clearedTool = await Self.waitUntil { + try await waitUntil("pending tool call cleared") { await MainActor.run { viewModel.pendingToolCalls.isEmpty } } - #expect(clearedTool) - } - - private static func waitUntil( - timeoutMs: Int = 1_000, - condition: @escaping @Sendable () async -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000) - while Date() < deadline { - if await condition() { - return true - } - try? await Task.sleep(nanoseconds: 10_000_000) - } - return await condition() } } diff --git a/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift b/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift index eb96eef..78dfa65 100644 --- a/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/SMSChannelAdapterTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("SMS (Twilio) channel adapter") +@Suite("SMS (Twilio) channel adapter", .timeLimit(.minutes(1))) struct SMSChannelAdapterTests { actor DiagnosticsCollector { var events: [RuntimeDiagnosticEvent] = [] diff --git a/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift b/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift index 451acbe..43e12e4 100644 --- a/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift +++ b/Tests/OpenClawKitTests/SignalAdapterRefreshTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("Signal adapter 2026.9.6 refresh") +@Suite("Signal adapter 2026.9.6 refresh", .timeLimit(.minutes(1))) struct SignalAdapterRefreshTests { struct ScriptedLines: ChannelLineStreaming { let lines: [String] diff --git a/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift b/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift index 60d8a4d..2d3767d 100644 --- a/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/SignalChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Signal channel adapter") +@Suite("Signal channel adapter", .timeLimit(.minutes(1))) struct SignalChannelAdapterTests { /// Container WebSocket upgrades fail, so these tests exercise the GET /v1/receive fallback. struct NoWebSocketConnector: ChannelWebSocketConnecting { diff --git a/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift b/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift index 0b76c96..aa52451 100644 --- a/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift +++ b/Tests/OpenClawKitTests/SlackAdapterRefreshTests.swift @@ -6,7 +6,7 @@ import FoundationNetworking import OpenClawCore import Testing -@Suite("Slack adapter 2026.9.6 refresh") +@Suite("Slack adapter 2026.9.6 refresh", .timeLimit(.minutes(1))) struct SlackAdapterRefreshTests { static let auth = #"{"ok":true,"user_id":"UBOT","bot_id":"B1","user":"claw","team":"acme"}"# diff --git a/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift b/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift index 1ac7934..2d35841 100644 --- a/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/SlackChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Slack channel adapter") +@Suite("Slack channel adapter", .timeLimit(.minutes(1))) struct SlackChannelAdapterTests { actor InboundCollector { private(set) var messages: [InboundMessage] = [] diff --git a/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift b/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift index c5a377f..fcc443b 100644 --- a/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift +++ b/Tests/OpenClawKitTests/TelegramAdapterRefreshTests.swift @@ -7,7 +7,7 @@ import OpenClawCore import OpenClawProtocol import Testing -@Suite("Telegram adapter 2026.9.6 refresh") +@Suite("Telegram adapter 2026.9.6 refresh", .timeLimit(.minutes(1))) struct TelegramAdapterRefreshTests { actor MemoryOffsetStore: TelegramUpdateOffsetStore { var value: Int64? diff --git a/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift b/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift index 81e5e58..67c4c29 100644 --- a/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift +++ b/Tests/OpenClawKitTests/TelegramChannelAdapterTests.swift @@ -5,7 +5,7 @@ import FoundationNetworking import Testing @testable import OpenClawKit -@Suite("Telegram channel adapter") +@Suite("Telegram channel adapter", .timeLimit(.minutes(1))) struct TelegramChannelAdapterTests { actor InboundCollector { private(set) var messages: [InboundMessage] = [] diff --git a/Tests/OpenClawKitTests/TestAsyncHelpers.swift b/Tests/OpenClawKitTests/TestAsyncHelpers.swift index ac74423..32cedcb 100644 --- a/Tests/OpenClawKitTests/TestAsyncHelpers.swift +++ b/Tests/OpenClawKitTests/TestAsyncHelpers.swift @@ -1,4 +1,4 @@ -import Foundation +import Testing struct AsyncWaitTimeoutError: Error, CustomStringConvertible { let label: String @@ -7,21 +7,22 @@ struct AsyncWaitTimeoutError: Error, CustomStringConvertible { } } +/// Polls `condition` until it holds. +/// +/// There is no wall-clock deadline: a saturated test pool must only slow the wait down. Every suite +/// that calls this carries a `.timeLimit`, whose cancellation ends the wait with +/// ``AsyncWaitTimeoutError``. func waitUntil( _ label: String, - // Polling returns as soon as the condition holds; the generous ceiling - // only matters under full-suite parallel load, where 3s flaked on CI. - timeoutSeconds: Double = 15.0, pollMs: UInt64 = 10, - now: @Sendable () -> Date = { Date() }, _ condition: @escaping @Sendable () async -> Bool) async throws { - let deadline = now().addingTimeInterval(timeoutSeconds) - while now() < deadline { + while !Task.isCancelled { if await condition() { return } - try await Task.sleep(nanoseconds: pollMs * 1_000_000) + try? await Task.sleep(nanoseconds: pollMs * 1_000_000) } - // Completion can arrive during the final suspension, before this waiter resumes. - if await condition() { return } - throw AsyncWaitTimeoutError(label: label) + // Swift Testing drops errors thrown after a time-limit cancellation, so record which wait hung. + let timeout = AsyncWaitTimeoutError(label: label) + Issue.record(timeout) + throw timeout } diff --git a/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift b/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift index bd4055f..0e164ab 100644 --- a/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift +++ b/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift @@ -48,6 +48,7 @@ private final class FakeVoiceNoteAudioCapture: VoiceNoteAudioCapture { } } +@Suite(.timeLimit(.minutes(1))) struct VoiceNoteRecorderTests { @MainActor @Test func startAndFinishProduceRecordingWithDuration() async throws {