Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions Tests/OpenClawKitTests/GatewayChannelLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -545,22 +545,24 @@ 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")
} catch {
#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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 16 additions & 11 deletions Tests/OpenClawKitTests/OpenClawAppIntentsRunMatchingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data, any Error>?

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 ?? [:])
Expand All @@ -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 {
Expand Down
12 changes: 5 additions & 7 deletions Tests/OpenClawKitTests/WatchNodeClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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))
}
Expand Down
Loading