refactor(tests): dedupe swift gateway and chat fixtures

This commit is contained in:
Peter Steinberger
2026-03-02 09:39:30 +00:00
parent 5f49a5da3c
commit fd7774a79e
14 changed files with 354 additions and 578 deletions

View File

@@ -9,30 +9,15 @@ import Testing
UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")!
}
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func makeExec(at path: URL) throws {
try FileManager().createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@Test func prefersOpenClawBinary() throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
try self.makeExec(at: openclawPath)
try makeExecutableForTests(at: openclawPath)
let cmd = CommandResolver.openclawCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
@@ -42,15 +27,15 @@ import Testing
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
try self.makeExec(at: nodePath)
try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try self.makeExec(at: scriptPath)
try makeExecutableForTests(at: scriptPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "rpc",
@@ -70,14 +55,14 @@ import Testing
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let binDir = tmp.appendingPathComponent("bin")
let openclawPath = binDir.appendingPathComponent("openclaw")
let pnpmPath = binDir.appendingPathComponent("pnpm")
try self.makeExec(at: openclawPath)
try self.makeExec(at: pnpmPath)
try makeExecutableForTests(at: openclawPath)
try makeExecutableForTests(at: pnpmPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "rpc",
@@ -92,12 +77,12 @@ import Testing
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let binDir = tmp.appendingPathComponent("bin")
let openclawPath = binDir.appendingPathComponent("openclaw")
try self.makeExec(at: openclawPath)
try makeExecutableForTests(at: openclawPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "gateway",
@@ -112,11 +97,11 @@ import Testing
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try self.makeExec(at: pnpmPath)
try makeExecutableForTests(at: pnpmPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "rpc",
@@ -131,11 +116,11 @@ import Testing
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
try self.makeExec(at: pnpmPath)
try makeExecutableForTests(at: pnpmPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "health",
@@ -149,7 +134,7 @@ import Testing
}
@Test func preferredPathsStartWithProjectNodeBins() throws {
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let first = CommandResolver.preferredPaths().first
@@ -198,11 +183,11 @@ import Testing
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
let tmp = try makeTempDir()
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
try self.makeExec(at: openclawPath)
try makeExecutableForTests(at: openclawPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "daemon",

View File

@@ -5,118 +5,27 @@ import Testing
@testable import OpenClaw
@Suite struct GatewayConnectionTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
private let helloDelayMs: Int
var state: URLSessionTask.State = .suspended
init(helloDelayMs: Int = 0) {
self.helloDelayMs = helloDelayMs
}
func snapshotCancelCount() -> Int {
self.cancelCount.withLock { $0 }
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
// First send is the connect handshake request. Subsequent sends are request frames.
if currentSendCount == 0 {
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
if self.helloDelayMs > 0 {
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
}
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
func emitIncoming(_ data: Data) {
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let makeCount = OSAllocatedUnfairLock(initialState: 0)
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
private let helloDelayMs: Int
init(helloDelayMs: Int = 0) {
self.helloDelayMs = helloDelayMs
}
func snapshotMakeCount() -> Int {
self.makeCount.withLock { $0 }
}
func snapshotCancelCount() -> Int {
self.tasks.withLock { tasks in
tasks.reduce(0) { $0 + $1.snapshotCancelCount() }
}
}
func latestTask() -> FakeWebSocketTask? {
self.tasks.withLock { $0.last }
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
self.makeCount.withLock { $0 += 1 }
let task = FakeWebSocketTask(helloDelayMs: self.helloDelayMs)
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
private func makeSession(helloDelayMs: Int = 0) -> GatewayTestWebSocketSession {
GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
task.emitReceiveSuccess(.data(response))
},
receiveHook: { task, receiveIndex in
if receiveIndex == 0 {
return .data(GatewayWebSocketTestSupport.connectChallengeData())
}
if helloDelayMs > 0 {
try await Task.sleep(nanoseconds: UInt64(helloDelayMs) * 1_000_000)
}
let id = task.snapshotConnectRequestID() ?? "connect"
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
})
})
}
private final class ConfigSource: @unchecked Sendable {
@@ -136,7 +45,7 @@ import Testing
}
@Test func requestReusesSingleWebSocketForSameConfig() async throws {
let session = FakeWebSocketSession()
let session = self.makeSession()
let url = try #require(URL(string: "ws://example.invalid"))
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
@@ -152,7 +61,7 @@ import Testing
}
@Test func requestReconfiguresAndCancelsOnTokenChange() async throws {
let session = FakeWebSocketSession()
let session = self.makeSession()
let url = try #require(URL(string: "ws://example.invalid"))
let cfg = ConfigSource(token: "a")
let conn = GatewayConnection(
@@ -169,7 +78,7 @@ import Testing
}
@Test func concurrentRequestsStillUseSingleWebSocket() async throws {
let session = FakeWebSocketSession(helloDelayMs: 150)
let session = self.makeSession(helloDelayMs: 150)
let url = try #require(URL(string: "ws://example.invalid"))
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
@@ -184,7 +93,7 @@ import Testing
}
@Test func subscribeReplaysLatestSnapshot() async throws {
let session = FakeWebSocketSession()
let session = self.makeSession()
let url = try #require(URL(string: "ws://example.invalid"))
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
@@ -205,7 +114,7 @@ import Testing
}
@Test func subscribeEmitsSeqGapBeforeEvent() async throws {
let session = FakeWebSocketSession()
let session = self.makeSession()
let url = try #require(URL(string: "ws://example.invalid"))
let cfg = ConfigSource(token: nil)
let conn = GatewayConnection(
@@ -222,7 +131,7 @@ import Testing
"""
{"type":"event","event":"presence","payload":{"presence":[]},"seq":1}
""".utf8)
session.latestTask()?.emitIncoming(evt1)
session.latestTask()?.emitReceiveSuccess(.data(evt1))
let firstEvent = await iterator.next()
guard case let .event(firstFrame) = firstEvent else {
@@ -235,7 +144,7 @@ import Testing
"""
{"type":"event","event":"presence","payload":{"presence":[]},"seq":3}
""".utf8)
session.latestTask()?.emitIncoming(evt3)
session.latestTask()?.emitReceiveSuccess(.data(evt3))
let gap = await iterator.next()
guard case let .seqGap(expected, received) = gap else {

View File

@@ -1,6 +1,5 @@
import Foundation
import OpenClawKit
import os
import Testing
@testable import OpenClaw
@@ -10,86 +9,33 @@ import Testing
case invalid(delayMs: Int)
}
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let response: FakeResponse
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(
initialState: nil)
var state: URLSessionTask.State = .suspended
init(response: FakeResponse) {
self.response = response
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let delayMs: Int
let msg: URLSessionWebSocketTask.Message
switch self.response {
case let .helloOk(ms):
delayMs = ms
let id = self.connectRequestID.withLock { $0 } ?? "connect"
msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
case let .invalid(ms):
delayMs = ms
msg = .string("not json")
}
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return msg
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
// The production channel sets up a continuous receive loop after hello.
// Tests only need the handshake receive; keep the loop idle.
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let response: FakeResponse
private let makeCount = OSAllocatedUnfairLock(initialState: 0)
init(response: FakeResponse) {
self.response = response
}
func snapshotMakeCount() -> Int {
self.makeCount.withLock { $0 }
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
self.makeCount.withLock { $0 += 1 }
let task = FakeWebSocketTask(response: self.response)
return WebSocketTaskBox(task: task)
}
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
receiveHook: { task, receiveIndex in
if receiveIndex == 0 {
return .data(GatewayWebSocketTestSupport.connectChallengeData())
}
let delayMs: Int
let message: URLSessionWebSocketTask.Message
switch response {
case let .helloOk(ms):
delayMs = ms
let id = task.snapshotConnectRequestID() ?? "connect"
message = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
case let .invalid(ms):
delayMs = ms
message = .string("not json")
}
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return message
})
})
}
@Test func concurrentConnectIsSingleFlightOnSuccess() async throws {
let session = FakeWebSocketSession(response: .helloOk(delayMs: 200))
let session = self.makeSession(response: .helloOk(delayMs: 200))
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
@@ -105,7 +51,7 @@ import Testing
}
@Test func concurrentConnectSharesFailure() async throws {
let session = FakeWebSocketSession(response: .invalid(delayMs: 200))
let session = self.makeSession(response: .invalid(delayMs: 200))
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,

View File

@@ -1,85 +1,23 @@
import Foundation
import OpenClawKit
import os
import Testing
@testable import OpenClaw
@Suite struct GatewayChannelRequestTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let requestSendDelayMs: Int
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
init(requestSendDelayMs: Int) {
self.requestSendDelayMs = requestSendDelayMs
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
_ = message
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
// First send is the connect handshake. Second send is the request frame.
if currentSendCount == 0 {
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
if currentSendCount == 1 {
try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000)
throw URLError(.cannotConnectToHost)
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let requestSendDelayMs: Int
init(requestSendDelayMs: Int) {
self.requestSendDelayMs = requestSendDelayMs
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask(requestSendDelayMs: self.requestSendDelayMs)
return WebSocketTaskBox(task: task)
}
private func makeSession(requestSendDelayMs: Int) -> GatewayTestWebSocketSession {
GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { _, _, sendIndex in
guard sendIndex == 1 else { return }
try await Task.sleep(nanoseconds: UInt64(requestSendDelayMs) * 1_000_000)
throw URLError(.cannotConnectToHost)
})
})
}
@Test func requestTimeoutThenSendFailureDoesNotDoubleResume() async throws {
let session = FakeWebSocketSession(requestSendDelayMs: 100)
let session = self.makeSession(requestSendDelayMs: 100)
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,

View File

@@ -1,84 +1,11 @@
import Foundation
import OpenClawKit
import os
import Testing
@testable import OpenClaw
@Suite struct GatewayChannelShutdownTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func snapshotCancelCount() -> Int {
self.cancelCount.withLock { $0 }
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
func triggerReceiveFailure() {
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let makeCount = OSAllocatedUnfairLock(initialState: 0)
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func snapshotMakeCount() -> Int {
self.makeCount.withLock { $0 }
}
func latestTask() -> FakeWebSocketTask? {
self.tasks.withLock { $0.last }
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
self.makeCount.withLock { $0 += 1 }
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws {
let session = FakeWebSocketSession()
let session = GatewayTestWebSocketSession()
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
@@ -89,7 +16,7 @@ import Testing
#expect(session.snapshotMakeCount() == 1)
// Simulate a socket receive failure, which would normally schedule a reconnect.
session.latestTask()?.triggerReceiveFailure()
session.latestTask()?.emitReceiveFailure()
// Shut down quickly, before backoff reconnect triggers.
await channel.shutdown()

View File

@@ -1,89 +1,21 @@
import Foundation
import OpenClawKit
import os
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
if currentSendCount == 0 {
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func clearsLastFailureWhenHealthSucceeds() async throws {
let session = FakeWebSocketSession()
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
})
})
let url = try #require(URL(string: "ws://example.invalid"))
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },

View File

@@ -9,6 +9,17 @@ extension WebSocketTasking {
}
enum GatewayWebSocketTestSupport {
static func connectChallengeData(nonce: String = "test-nonce") -> Data {
let json = """
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "\(nonce)" }
}
"""
return Data(json.utf8)
}
static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
let data: Data? = switch message {
case let .data(d): d
@@ -49,6 +60,22 @@ enum GatewayWebSocketTestSupport {
return Data(json.utf8)
}
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return nil }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
guard (obj["type"] as? String) == "req" else {
return nil
}
return obj["id"] as? String
}
static func okResponseData(id: String) -> Data {
let json = """
{
@@ -61,3 +88,138 @@ enum GatewayWebSocketTestSupport {
return Data(json.utf8)
}
}
private extension NSLock {
@inline(__always)
func withLock<T>(_ body: () throws -> T) rethrows -> T {
self.lock(); defer { self.unlock() }
return try body()
}
}
final class GatewayTestWebSocketTask: WebSocketTasking, @unchecked Sendable {
typealias SendHook = @Sendable (GatewayTestWebSocketTask, URLSessionWebSocketTask.Message, Int) async throws -> Void
typealias ReceiveHook = @Sendable (GatewayTestWebSocketTask, Int) async throws -> URLSessionWebSocketTask.Message
private let lock = NSLock()
private let sendHook: SendHook?
private let receiveHook: ReceiveHook?
private var _state: URLSessionTask.State = .suspended
private var connectRequestID: String?
private var sendCount = 0
private var receiveCount = 0
private var cancelCount = 0
private var pendingReceiveHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
init(sendHook: SendHook? = nil, receiveHook: ReceiveHook? = nil) {
self.sendHook = sendHook
self.receiveHook = receiveHook
}
var state: URLSessionTask.State {
get { self.lock.withLock { self._state } }
set { self.lock.withLock { self._state = newValue } }
}
func snapshotCancelCount() -> Int {
self.lock.withLock { self.cancelCount }
}
func snapshotConnectRequestID() -> String? {
self.lock.withLock { self.connectRequestID }
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
self._state = .canceling
self.cancelCount += 1
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let sendIndex = self.lock.withLock { () -> Int in
let current = self.sendCount
self.sendCount += 1
return current
}
if sendIndex == 0, let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.lock.withLock { self.connectRequestID = id }
}
try await self.sendHook?(self, message, sendIndex)
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let receiveIndex = self.lock.withLock { () -> Int in
let current = self.receiveCount
self.receiveCount += 1
return current
}
if let receiveHook = self.receiveHook {
return try await receiveHook(self, receiveIndex)
}
if receiveIndex == 0 {
return .data(GatewayWebSocketTestSupport.connectChallengeData())
}
let id = self.snapshotConnectRequestID() ?? "connect"
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.lock.withLock { self.pendingReceiveHandler = completionHandler }
}
func emitReceiveSuccess(_ message: URLSessionWebSocketTask.Message) {
let handler = self.lock.withLock { self.pendingReceiveHandler }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(message))
}
func emitReceiveFailure(_ error: Error = URLError(.networkConnectionLost)) {
let handler = self.lock.withLock { self.pendingReceiveHandler }
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(error))
}
}
final class GatewayTestWebSocketSession: WebSocketSessioning, @unchecked Sendable {
typealias TaskFactory = @Sendable () -> GatewayTestWebSocketTask
private let lock = NSLock()
private let taskFactory: TaskFactory
private var tasks: [GatewayTestWebSocketTask] = []
private var makeCount = 0
init(taskFactory: @escaping TaskFactory = { GatewayTestWebSocketTask() }) {
self.taskFactory = taskFactory
}
func snapshotMakeCount() -> Int {
self.lock.withLock { self.makeCount }
}
func snapshotCancelCount() -> Int {
self.lock.withLock { self.tasks.reduce(0) { $0 + $1.snapshotCancelCount() } }
}
func latestTask() -> GatewayTestWebSocketTask? {
self.lock.withLock { self.tasks.last }
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = self.taskFactory()
self.lock.withLock {
self.makeCount += 1
self.tasks.append(task)
}
return WebSocketTaskBox(task: task)
}
}

View File

@@ -3,30 +3,15 @@ import Testing
@testable import OpenClaw
@Suite struct NodeManagerPathsTests {
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func makeExec(at path: URL) throws {
try FileManager().createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
let home = try self.makeTempDir()
let home = try makeTempDirForTests()
let v20Bin = home
.appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node")
let v25Bin = home
.appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node")
try self.makeExec(at: v20Bin)
try self.makeExec(at: v25Bin)
try makeExecutableForTests(at: v20Bin)
try makeExecutableForTests(at: v25Bin)
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(bins.first == v25Bin.deletingLastPathComponent().path)
@@ -34,7 +19,7 @@ import Testing
}
@Test func ignoresEntriesWithoutNodeExecutable() throws {
let home = try self.makeTempDir()
let home = try makeTempDirForTests()
let missingNodeBin = home
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)

View File

@@ -0,0 +1,16 @@
import Foundation
func makeTempDirForTests() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
func makeExecutableForTests(at path: URL) throws {
try FileManager().createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}

View File

@@ -49,7 +49,7 @@ import Testing
@Test func gateRequiresGapBetweenTriggerAndCommand() {
let transcript = "hey openclaw do thing"
let segments = makeSegments(
let segments = makeWakeWordSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
@@ -63,7 +63,7 @@ import Testing
@Test func gateAcceptsGapAndExtractsCommand() {
let transcript = "hey openclaw do thing"
let segments = makeSegments(
let segments = makeWakeWordSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
@@ -75,17 +75,3 @@ import Testing
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -0,0 +1,16 @@
import Foundation
import SwabbleKit
func makeWakeWordSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var cursor = transcript.startIndex
return words.map { word, start, duration in
let range = transcript.range(of: word, range: cursor..<transcript.endIndex)
if let range {
cursor = range.upperBound
}
return WakeWordSegment(text: word, start: start, duration: duration, range: range)
}
}

View File

@@ -5,7 +5,7 @@ import Testing
struct VoiceWakeTesterTests {
@Test func matchRespectsGapRequirement() {
let transcript = "hey claude do thing"
let segments = makeSegments(
let segments = makeWakeWordSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
@@ -19,7 +19,7 @@ struct VoiceWakeTesterTests {
@Test func matchReturnsCommandAfterGap() {
let transcript = "hey claude do thing"
let segments = makeSegments(
let segments = makeWakeWordSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
@@ -31,17 +31,3 @@ struct VoiceWakeTesterTests {
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -24,6 +24,14 @@ private func waitUntil(
throw TimeoutError(label: label)
}
private func chatTextMessage(role: String, text: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [["type": "text", "text": text]],
"timestamp": timestamp,
])
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
@@ -148,11 +156,10 @@ extension TestChatTransportState {
sessionKey: "main",
sessionId: sessionId,
messages: [
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "final answer"]],
"timestamp": Date().timeIntervalSince1970 * 1000,
]),
chatTextMessage(
role: "assistant",
text: "final answer",
timestamp: Date().timeIntervalSince1970 * 1000),
],
thinkingLevel: "off")
@@ -225,11 +232,10 @@ extension TestChatTransportState {
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "from history"]],
"timestamp": Date().timeIntervalSince1970 * 1000,
]),
chatTextMessage(
role: "assistant",
text: "from history",
timestamp: Date().timeIntervalSince1970 * 1000),
],
thinkingLevel: "off")
@@ -267,27 +273,15 @@ extension TestChatTransportState {
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": "first"]],
"timestamp": now,
]),
chatTextMessage(role: "user", text: "first", timestamp: now),
],
thinkingLevel: "off")
let history2 = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": "first"]],
"timestamp": now,
]),
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "from external run"]],
"timestamp": now + 1,
]),
chatTextMessage(role: "user", text: "first", timestamp: now),
chatTextMessage(role: "assistant", text: "from external run", timestamp: now + 1),
],
thinkingLevel: "off")
@@ -317,27 +311,15 @@ extension TestChatTransportState {
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": "hello"]],
"timestamp": now,
]),
chatTextMessage(role: "user", text: "hello", timestamp: now),
],
thinkingLevel: "off")
let history2 = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "user",
"content": [["type": "text", "text": "hello"]],
"timestamp": now,
]),
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "world"]],
"timestamp": now + 1,
]),
chatTextMessage(role: "user", text: "hello", timestamp: now),
chatTextMessage(role: "assistant", text: "world", timestamp: now + 1),
],
thinkingLevel: "off")
@@ -427,11 +409,7 @@ extension TestChatTransportState {
sessionKey: "main",
sessionId: "sess-main",
messages: [
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "resynced after gap"]],
"timestamp": now,
]),
chatTextMessage(role: "assistant", text: "resynced after gap", timestamp: now),
],
thinkingLevel: "off")

View File

@@ -114,38 +114,48 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
private static func connectChallengeData(nonce: String) -> Data {
let json = """
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "\(nonce)" }
}
"""
return Data(json.utf8)
let frame: [String: Any] = [
"type": "event",
"event": "connect.challenge",
"payload": ["nonce": nonce],
]
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
let payload: [String: Any] = [
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
"server": [
"version": "test",
"connId": "test",
],
"features": [
"methods": [],
"events": [],
],
"snapshot": [
"presence": [["ts": 1]],
"health": [:],
"stateVersion": [
"presence": 0,
"health": 0,
],
"uptimeMs": 0,
],
"policy": [
"maxPayload": 1,
"maxBufferedBytes": 1,
"tickIntervalMs": 30_000,
],
]
let frame: [String: Any] = [
"type": "res",
"id": id,
"ok": true,
"payload": payload,
]
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
}