From 3935801c0ecb86cf415f7acf268cf67b6f9e8866 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 10:14:21 -0400 Subject: [PATCH 1/8] test: wait for events instead of a 5 s wall-clock deadline anotherAgentsRunBeforeTheAckNeverHijacksTheIntent and preAckEventsOfTheAckedRunAreReplayed failed on main (CI run 36644330107, xcode-27 runner) with "Caught error: CancellationError()" after ~8 s; the same tree passed on PR #20. waitForSend polled the requester every 5 ms and threw once a 5 s ContinuousClock deadline passed. With ~3,150 Swift Testing tests saturating the cooperative pool, the host.send task (and the poller itself) did not run for more than 5 s, so the deadline expired although nothing in GatewayOpenClawIntentHost.send is slow. Blocking every cooperative thread for 6 s reproduces the failure locally. - OpenClawAppIntentsRunMatchingTests: HeldChatSendRequester yields each chat.send's params to an AsyncStream the moment the request arrives, and waitForSend awaits that stream. Assertions are unchanged. - GatewayNetworkConnectionTransportTests: the loopback NWListener start waits on stateUpdateHandler (ready/failed/cancelled) instead of polling listener.state against the same 5 s deadline, which also had no final re-check after a late wake-up. - WatchNodeClientTests: eventually() drops its 5 s deadline. Its conditions read production client state that has no change hook, so it still polls, but slowness no longer fails it. Each suite gains .timeLimit(.minutes(1)) for hang protection. Every wait ends on cancellation, so a time-limit overrun reports "Time limit was exceeded" instead of hanging. Co-Authored-By: Claude Opus 5.5 --- ...tewayNetworkConnectionTransportTests.swift | 25 +++++++++++------ .../OpenClawAppIntentsRunMatchingTests.swift | 27 +++++++++++-------- .../WatchNodeClientTests.swift | 12 ++++----- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift b/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift index 2a9ad51..8d4fc82 100644 --- a/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift +++ b/Tests/OpenClawKitTests/GatewayNetworkConnectionTransportTests.swift @@ -87,19 +87,28 @@ private final class NetworkConnectionLoopbackGateway: @unchecked Sendable { listener.newConnectionHandler = { [weak gateway] connection in gateway?.accept(connection) } + // Wait on the listener's own state updates rather than polling against a wall-clock deadline, + // which a saturated test pool can overrun even though the listener is long ready. The suite's + // time limit bounds a real hang: cancellation ends the stream iteration below. + let (states, stateContinuation) = AsyncStream.makeStream(of: NWListener.State.self) + defer { stateContinuation.finish() } + listener.stateUpdateHandler = { stateContinuation.yield($0) } listener.start(queue: gateway.queue) - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while ContinuousClock.now < deadline { - if case .ready = listener.state, let port = listener.port, port.rawValue != 0 { + for await state in states { + switch state { + case .ready: return gateway - } - if case let .failed(error) = listener.state { + case let .failed(error): + listener.cancel() throw error + case .cancelled: + throw CancellationError() + default: + continue } - try await Task.sleep(for: .milliseconds(10)) } listener.cancel() - throw URLError(.timedOut) + throw CancellationError() } var url: URL { @@ -177,7 +186,7 @@ private final class NetworkConnectionLoopbackGateway: @unchecked Sendable { } } -@Suite("Network.framework gateway transport", .serialized, .gatewayTLSStoreIsolated) +@Suite("Network.framework gateway transport", .serialized, .gatewayTLSStoreIsolated, .timeLimit(.minutes(1))) struct GatewayNetworkConnectionTransportTests { @Test func handshakeAndRequestsRunOverNetworkConnection() async throws { diff --git a/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift b/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift index 01f4814..c64b29f 100644 --- a/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift +++ b/Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift @@ -5,14 +5,20 @@ import OpenClawKit /// Gateway transport whose `chat.send` stays pending until the test acknowledges it. private actor HeldChatSendRequester: OpenClawIntentGatewayRequesting { - private(set) var sendParams: [String: AnyCodable]? + /// Params of each `chat.send`, yielded the moment the request arrives. + nonisolated let sends: AsyncStream<[String: AnyCodable]> + private let sendsContinuation: AsyncStream<[String: AnyCodable]>.Continuation private(set) var abortParams: [[String: AnyCodable]] = [] private var pendingSend: CheckedContinuation? + init() { + (self.sends, self.sendsContinuation) = AsyncStream.makeStream() + } + func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double?) async throws -> Data { switch method { case "chat.send": - self.sendParams = params ?? [:] + self.sendsContinuation.yield(params ?? [:]) return try await withCheckedThrowingContinuation { self.pendingSend = $0 } case "chat.abort": self.abortParams.append(params ?? [:]) @@ -26,26 +32,25 @@ private actor HeldChatSendRequester: OpenClawIntentGatewayRequesting { self.pendingSend?.resume(returning: Data(json.utf8)) self.pendingSend = nil } - - var idempotencyKey: String? { - self.sendParams?["idempotencyKey"]?.stringValue - } } private func chatEvent(_ fields: [String: String]) -> EventFrame { EventFrame(type: "event", event: "chat", payload: AnyCodable(fields.mapValues { AnyCodable($0) })) } +/// Returns the idempotency key of the first `chat.send` once it reaches the requester. +/// +/// Event-driven rather than deadline-polled: under a saturated test pool the send can take +/// seconds to arrive, which must only slow the test down. The suite's time limit bounds a real hang. private func waitForSend(_ requester: HeldChatSendRequester) async throws -> String { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) - while ContinuousClock.now < deadline { - if let key = await requester.idempotencyKey { return key } - try await Task.sleep(for: .milliseconds(5)) + for await params in requester.sends { + return try #require(params["idempotencyKey"]?.stringValue) } + // The stream is never finished, so iteration only ends when the time limit cancels the test. throw CancellationError() } -@Suite("App Intents run matching") +@Suite("App Intents run matching", .timeLimit(.minutes(1))) struct OpenClawAppIntentsRunMatchingTests { @Test func anotherAgentsRunBeforeTheAckNeverHijacksTheIntent() async throws { diff --git a/Tests/OpenClawKitTests/WatchNodeClientTests.swift b/Tests/OpenClawKitTests/WatchNodeClientTests.swift index f97f3a3..0d8776d 100644 --- a/Tests/OpenClawKitTests/WatchNodeClientTests.swift +++ b/Tests/OpenClawKitTests/WatchNodeClientTests.swift @@ -196,7 +196,7 @@ private final class InMemoryWatchNodeConfigurationStore: OpenClawWatchNodeConfig } } -@Suite(.serialized) +@Suite(.serialized, .timeLimit(.minutes(1))) struct WatchNodeClientTests { private static let nowMs = Int64(1_800_000_000_000) @@ -223,12 +223,10 @@ struct WatchNodeClientTests { sentAtMs: sentAtMs) } - private static func eventually( - timeout: Duration = .seconds(5), - _ condition: @Sendable () async -> Bool) async -> Bool - { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { + /// Polls 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 static func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { + while !Task.isCancelled { if await condition() { return true } try? await Task.sleep(for: .milliseconds(5)) } From f8eb5fb9193150e61a5f0cedbf7b1e3367896285 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:09:15 -0400 Subject: [PATCH 2/8] test: bound lifecycle timeouts with a time limit, not a 5 s wall clock keepalivePingIsBoundedWhenNoPongArrives and handshakeTimeoutOptionBoundsTheWholeHandshake failed on PR #22's macOS job (Xcode 27) with `ContinuousClock.now - start < .seconds(5)` at ~5.7 s. A trivial test in the same window took 5.3 s, so the runner stalled; the timeouts under test were 50 ms and 100 ms. - keepalive ping: the socket never pongs, so the thrown URLError already proves the ping deadline ended the wait; a time limit catches a hang. - handshake option: the fallback budget is raised to an hour through _test_setConnectTimeoutSeconds, so a channel that ignored the 100 ms option would trip the time limit instead of finishing at the 30 s default. Checked with a scratch test that drops the option: it fails with "Time limit was exceeded" and does not hang. Both tests take .timeLimit(.minutes(1)) and pass while every cooperative thread is blocked for several seconds. Co-Authored-By: Claude Opus 5.5 --- .../GatewayChannelLifecycleTests.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift index ba546f2..b711fae 100644 --- a/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift +++ b/Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift @@ -496,15 +496,15 @@ struct GatewayChannelLifecycleTests { await channel.shutdown() } - @Test + /// The socket never pongs, so only the ping deadline can end the wait (with `URLError`); an unbounded + /// ping trips the time limit. No wall-clock bound: a saturated test pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) func keepalivePingIsBoundedWhenNoPongArrives() async throws { let socket = GatewayCoreFakeSocket(script: GatewayCoreSocketScript(pingBehavior: .never)) let box = WebSocketTaskBox(task: socket) - let start = ContinuousClock.now await #expect(throws: URLError.self) { try await box.sendPing(timeout: .milliseconds(50)) } - #expect(ContinuousClock.now - start < .seconds(5)) let duplicate = WebSocketTaskBox(task: GatewayCoreFakeSocket(script: GatewayCoreSocketScript( pingBehavior: .duplicateSuccess))) @@ -545,14 +545,17 @@ struct GatewayChannelLifecycleTests { == .drop(.missingRecipientProfile)) } - @Test + /// The gateway never answers `connect`. Only the 100 ms option can time the handshake out within the + /// time limit: the fallback budget is an hour, so a channel that ignored the option would trip it. No + /// wall-clock bound: a saturated test pool can stall the run for seconds. + @Test(.timeLimit(.minutes(1))) func handshakeTimeoutOptionBoundsTheWholeHandshake() async throws { let session = GatewayCoreFakeSession(fixedScript: GatewayCoreSocketScript( connectReply: { _ in .none })) var options = gatewayCoreOptions() options.handshakeTimeoutMs = 100 let channel = try makeChannel(session: session, options: options) - let start = ContinuousClock.now + await channel._test_setConnectTimeoutSeconds(3600) do { try await channel.connect() Issue.record("expected a handshake timeout") @@ -560,7 +563,6 @@ struct GatewayChannelLifecycleTests { #expect((error as NSError).domain == NSURLErrorDomain) #expect((error as NSError).code == URLError.timedOut.rawValue) } - #expect(ContinuousClock.now - start < .seconds(5)) #expect(await channel.currentHandshakePhase() == .connectSent) #expect(session.latestSocket?.state != .running) await channel.shutdown() From 79c50c2c37c5a01d54f06f166d6e4b8bd09f4aab Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:40:37 -0400 Subject: [PATCH 3/8] test: show the device-auth write leaves the actor free by ordering, not elapsed time issuedTokenPersistenceNeverBlocksTheChannelActor asserted the actor answered within 3 s while a token write waited on another connection's SQLite lock. A saturated test pool can stall the run for longer than that, and a time limit alone would miss the regression it guards (a write on the actor holds it for SQLite's 30 s busy timeout, less than the one-minute limit). The test now relies on ordering. It releases the lock only after the actor answers, so the token can reach disk only if the actor answered while the write was pending. A write on the actor fails with SQLITE_BUSY first, the token never lands, and the final wait trips the new suite time limit. The 200 ms sleep that let hello-ok reach the write is replaced by a DEBUG hook, _test_setDeviceTokenPersistenceStartedHandler, that fires on the actor just before the persistence hop. Once the hop starts, shutdown cannot stop it. Co-Authored-By: Claude Opus 5.5 --- .../OpenClawKit/GatewayChannel+Testing.swift | 5 +++++ Sources/OpenClawKit/GatewayChannel.swift | 4 ++++ .../GatewayDeviceAuthOffActorTests.swift | 19 +++++++++++++------ 3 files changed, 22 insertions(+), 6 deletions(-) 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/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 From bccaaacd1a0f209e862e9cd3abdb2b2441f5ba05 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:40:38 -0400 Subject: [PATCH 4/8] test: bound the FIFO, AsyncTimeout and link-preview tests with a time limit The three tests asserted wall-clock bounds (2 s, 5 s, 1 s) that a saturated test pool can overrun. Each now has a one-minute time limit instead, and each makes sure a regression ends on the limit's cancellation instead of hanging the run: - file fetch refuses a FIFO: a blocking open(2) never returns. The cancellation handler opens the FIFO's write end once to release it. - operationThatIgnoresCancellationStillTimesOut: the parked operation is a gate the test opens on cancellation (and afterwards), not a never-resumed continuation that a loser-joining race would wait on forever. - total deadline can fire before the session starts: URLSession's own timeouts move past the limit, so only the fetcher's zero-second deadline can end the fetch. The fetch is awaited through a no-deadline AsyncTimeout race, because a deadline lost before start would leave it unresumed even on cancellation. Co-Authored-By: Claude Opus 5.5 --- .../AsyncTimeoutRaceTests.swift | 57 +++++++++++++++---- .../ChatLinkPreviewTests.swift | 19 +++++-- .../FileTransferNodeCommandsTests.swift | 16 ++++-- 3 files changed, 72 insertions(+), 20 deletions(-) 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/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/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") } From ffeef5c3bf2a72529daa9af46a8bd80b83d21234 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 11:40:38 -0400 Subject: [PATCH 5/8] test: drop wall-clock deadlines from the gateway and session-action waits gatewayCoreWaitUntil gave up after 10 s (15 s at two call sites) and the ChatViewModelSessionActionTests helpers after 15 s. A pool stall adds to those waits, so they could time out even though the condition was about to hold. Both now wait until the condition holds or the test is cancelled. Every suite that uses them has a one-minute time limit. gatewayCoreWaitUntil records its GatewayCoreWaitTimeout as an issue before throwing, because Swift Testing drops errors thrown after a time-limit cancellation and the label says which wait hung. waitForForkStart awaits the gate's stream directly instead of racing it against a sleep. Co-Authored-By: Claude Opus 5.5 --- .../ChatGatewaySessionTransportTests.swift | 6 +- .../ChatViewModelSessionActionTests.swift | 84 +++++-------------- .../GatewayChannelLifecycleTests.swift | 2 +- .../GatewayConnectRecoveryTests.swift | 4 +- .../GatewayCoreTestSupport.swift | 18 ++-- .../GatewayNodeSessionRouteTests.swift | 2 +- .../GatewayRequestBudgetTests.swift | 2 +- .../GatewayStateReportingWiringTests.swift | 2 +- .../GatewayTLSPinRotationRecoveryTests.swift | 2 +- 9 files changed, 43 insertions(+), 79 deletions(-) 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/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/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/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 dbca099..33f2394 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 { From f5bee2adeb995569245984b2b2081cbd67da3377 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 14:23:59 -0400 Subject: [PATCH 6/8] test: drop the wall-clock deadline from waitUntil waitUntil gave up after 15 s (7, 10 or 30 s at five call sites). The macOS CI job runs about 3,150 tests in parallel and the cooperative pool stalls for 5 to 8 s at a time, so a wait could time out even though the condition was about to hold. waitUntil now polls until the condition holds or the test is cancelled, and the timeoutSeconds and now: parameters are gone (nothing injected a clock, and with no deadline there is nothing to measure). Every suite whose tests reach waitUntil, directly or through a file-local helper, has a one-minute time limit; ChatViewModelSessionActionTests already had one. On cancellation the helper records AsyncWaitTimeoutError as an issue before throwing it, because Swift Testing drops errors thrown after a time-limit cancellation and the label says which wait hung. No call site relied on the timeout: nothing expects AsyncWaitTimeoutError, no wait runs in a child task that the test cancels, and the one try? wait is cleanup in a catch block that rethrows. Co-Authored-By: Claude Opus 5.5 --- .../ChannelDeliveryHardeningTests.swift | 2 +- .../ChannelErrorRedactionTests.swift | 2 +- .../ChannelPairingStoreTests.swift | 2 +- .../ChatComposerParityTests.swift | 1 + .../ChatComposerShellTests.swift | 1 + .../ChatCoreCompatibilityTests.swift | 2 +- Tests/OpenClawKitTests/ChatHapticsTests.swift | 1 + .../ChatSessionSidebarPreviewsTests.swift | 1 + .../ChatStreamReplayTests.swift | 1 + .../ChatViewModelAgentNavigationTests.swift | 1 + .../ChatViewModelAttachmentTests.swift | 1 + .../ChatViewModelOutboxSettingsTests.swift | 1 + .../ChatViewModelOutboxTests.swift | 6 ++--- .../ChatViewModelSessionDeletionTests.swift | 1 + .../OpenClawKitTests/ChatViewModelTests.swift | 7 +++--- .../ChatViewModelTranscriptCacheTests.swift | 1 + .../ChatViewModelUnreadTests.swift | 2 +- .../DiscordChannelAdapterTests.swift | 2 +- .../DiscordGatewayAdapterTests.swift | 4 ++-- .../IMAPMailboxWatcherTests.swift | 2 +- .../IMsgRPCTransportTests.swift | 4 ++-- .../SMSChannelAdapterTests.swift | 2 +- .../SignalAdapterRefreshTests.swift | 2 +- .../SignalChannelAdapterTests.swift | 2 +- .../SlackAdapterRefreshTests.swift | 2 +- .../SlackChannelAdapterTests.swift | 2 +- .../TelegramAdapterRefreshTests.swift | 2 +- .../TelegramChannelAdapterTests.swift | 2 +- Tests/OpenClawKitTests/TestAsyncHelpers.swift | 23 ++++++++++--------- .../VoiceNoteRecorderTests.swift | 1 + 30 files changed, 48 insertions(+), 35 deletions(-) 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/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/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/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/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/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 { From d5620d18782ebdd48d4cdff335e50331bcaa88cb Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 14:23:59 -0400 Subject: [PATCH 7/8] test(e2e): wait without a wall-clock deadline in the channel and reconnect tests ChannelAdaptersE2ETests.waitFor gave up after 15 s and only recorded an unlabelled expectation failure. reconnectFailureSchedulesAnotherAttempt polled against a 10 s deadline. A pool stall can outlast either one. waitFor now takes a label, polls until the condition holds or the test is cancelled, and records a WaitTimeout issue before throwing it. The reconnect loop polls until cancelled. The suite and the reconnect test each have a one-minute time limit. Co-Authored-By: Claude Opus 5.5 --- .../ChannelAdaptersE2ETests.swift | 28 +++++++++++++------ .../GatewayTransportE2ETests.swift | 9 +++--- 2 files changed, 25 insertions(+), 12 deletions(-) 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() From 45849ebe33d9c7eacbfd52483e94e96f1c2f7315 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:14:51 -0400 Subject: [PATCH 8/8] test: drop the 1 s deadline from the chat UI view-model waits OpenClawChatUITests had its own waitUntil that gave up after 1 s and returned false. On the macOS CI job for this PR, a pool stall of about 10 s ran past it: both view-model tests failed after about 12 s, and the bootstrap expectations that followed failed with them. The tests now call the shared deadline-free waitUntil with a label for each wait, and the suite has a one-minute time limit. The private helper is removed. Co-Authored-By: Claude Opus 5.5 --- .../OpenClawChatUITests.swift | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) 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() } }