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
558 changes: 558 additions & 0 deletions .claude/skills/uts-to-swift/SKILL.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/ably-cocoa.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UTS"
BuildableName = "UTS"
BlueprintName = "UTS"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
22 changes: 22 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ let package = Package(
.copy("ably-common")
]
),
// Universal Test Suite (UTS)
// A standalone Swift Testing suite (import Testing / @Suite) derived from the language-neutral
// specs in the `ably/specification` repo (uts/). Deliberately does not depend on Nimble or XCTest.
.testTarget(
name: "UTS",
dependencies: [
.byName(name: "Ably"),
.product(name: "_AblyPluginSupportPrivate", package: "ably-cocoa-plugin-support")
],
path: "Test/UTS",
exclude: [
"README.md",
"deviations.md"
],
swiftSettings: [
// Build the UTS suite in the Swift 6 language mode (strict concurrency checking) so the
// compiler catches data races in the harness/tests. The package manifest is still
// swift-tools-version 5.3, which predates `.swiftLanguageMode`, so this is applied via
// the compiler flag. Only affects this test target (not the shipped product).
.unsafeFlags(["-swift-version", "6"])
]
),
// A handful of tests written in Objective-C (they can't be part of AblyTests because SPM doesn't allow mixed-language targets).
.testTarget(
name: "AblyTestsObjC",
Expand Down
2 changes: 1 addition & 1 deletion Source/ARTRealtime.m
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ - (instancetype)initWithOptions:(ARTClientOptions *)options {
_channels = [[ARTRealtimeChannelsInternal alloc] initWithRealtime:self logger:self.logger];
_transport = nil;
_networkState = ARTNetworkStateIsUnknown;
_reachabilityClass = [ARTOSReachability class];
_reachabilityClass = options.testOptions.reachabilityClass;
_msgSerial = 0;
_queuedMessages = [NSMutableArray array];
_pendingMessages = [NSMutableArray array];
Expand Down
2 changes: 1 addition & 1 deletion Source/ARTRest.m
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ - (instancetype)initWithOptions:(ARTClientOptions *)options realtime:(ARTRealtim
#endif
_http = [[ARTHttp alloc] initWithQueue:_queue logger:_logger];
ARTLogVerbose(_logger, @"RS:%p %p alloc HTTP", self, _http);
_httpExecutor = _http;
_httpExecutor = options.testOptions.httpExecutor ?: _http;

id<ARTEncoder> jsonEncoder = [[ARTJsonLikeEncoder alloc] initWithRest:self delegate:[[ARTJsonEncoder alloc] init] logger:_logger];
id<ARTEncoder> msgPackEncoder = [[ARTJsonLikeEncoder alloc] initWithRest:self delegate:[[ARTMsgPackEncoder alloc] init] logger:_logger];
Expand Down
4 changes: 4 additions & 0 deletions Source/ARTTestClientOptions.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#import "ARTRealtimeTransportFactory.h"
#import "ARTJitterCoefficientGenerator.h"
#import "ARTSystemTimeProvider.h"
#import "ARTOSReachability.h"

@implementation ARTTestClientOptions

Expand All @@ -14,6 +15,7 @@ - (instancetype)init {
_transportFactory = [[ARTDefaultRealtimeTransportFactory alloc] init];
_jitterCoefficientGenerator = [[ARTDefaultJitterCoefficientGenerator alloc] init];
_timeProvider = [[ARTSystemTimeProvider alloc] init];
_reachabilityClass = [ARTOSReachability class];
}

return self;
Expand All @@ -30,6 +32,8 @@ - (nonnull id)copyWithZone:(nullable NSZone *)zone {
copied.logLocalDeviceStorageValues = self.logLocalDeviceStorageValues;
copied.disableLocalDevice = self.disableLocalDevice;
copied.timeProvider = self.timeProvider;
copied.reachabilityClass = self.reachabilityClass;
copied.httpExecutor = self.httpExecutor;

return copied;
}
Expand Down
11 changes: 11 additions & 0 deletions Source/PrivateHeaders/Ably/ARTTestClientOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@protocol ARTRealtimeTransportFactory;
@protocol ARTJitterCoefficientGenerator;
@protocol ARTTimeProvider;
@protocol ARTHTTPExecutor;

NS_ASSUME_NONNULL_BEGIN

Expand Down Expand Up @@ -49,6 +50,16 @@ NS_ASSUME_NONNULL_BEGIN
*/
@property (nonatomic) id<ARTTimeProvider> timeProvider;

/**
The class used to instantiate the `ARTReachability` implementation that `ARTRealtime` uses to monitor network state. Tests install a no-op or controllable implementation here. Initial value is `ARTOSReachability`.
*/
@property (nonatomic) Class reachabilityClass;

/**
An `ARTHTTPExecutor` that `ARTRestInternal` uses for all of its HTTP requests instead of creating its own. Tests install a mock here so that requests are intercepted rather than sent over the network. When `nil` (the initial value), the rest client creates its own `ARTHttp`.
*/
@property (nullable, nonatomic) id<ARTHTTPExecutor> httpExecutor;

/**
When `YES`, `ARTLocalDeviceStorage` log lines include the fetched or
written value itself. Off by default because persisted values include
Expand Down
7 changes: 7 additions & 0 deletions Test/Ably.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
"identifier" : "AblyTestsObjC",
"name" : "AblyTestsObjC"
}
},
{
"target" : {
"containerPath" : "container:",
"identifier" : "UTS",
"name" : "UTS"
}
}
],
"version" : 1
Expand Down
33 changes: 33 additions & 0 deletions Test/UTS/Harness/Captured.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

/// A thread-safe collector for the spec's "local `captured_*` array" pattern
/// (`uts/.../helpers/mock_http.md` & `mock_websocket.md`, "Common Mistakes").
///
/// The mock handler closures (`onConnectionAttempt`, `onRequest`) are invoked by the SDK on its own
/// queues, while the test reads what was captured on the test thread. A plain local `var array`
/// captured into those `@Sendable` closures is a data race the Swift 6 compiler rejects; this small
/// lock-guarded, `Sendable` reference type lets a test keep a *local* collector (not a property on
/// the mock) while staying race-free.
final class Captured<Element>: @unchecked Sendable {
private let lock = NSLock()
private var items: [Element] = []

init() {}

/// Records an element (called from a mock handler, on an SDK queue).
func append(_ element: Element) {
lock.lock()
items.append(element)
lock.unlock()
}

/// A snapshot of everything captured so far (read from the test thread, after the operation has settled).
var all: [Element] {
lock.lock(); defer { lock.unlock() }
return items
}

var count: Int { all.count }
var first: Element? { all.first }
subscript(_ index: Int) -> Element { all[index] }
}
34 changes: 34 additions & 0 deletions Test/UTS/Harness/CapturingLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation
import Ably

/// An `ARTLog` that records every message the SDK logs, for tests that assert on log output (e.g.
/// "an error is logged"). Install via `ARTClientOptions.logHandler`.
///
/// The SDK's internal logger forwards every message to the injected `logHandler` (only the level
/// filter lives in `ARTLog.log:withLevel:`), so overriding `log(_:with:)` *without* calling `super`
/// captures everything regardless of `logLevel` — and keeps the console quiet.
final class CapturingLog: ARTLog {
struct Entry {
let level: ARTLogLevel
let message: String
}

private let lock = NSLock()
private var storedEntries: [Entry] = []

var entries: [Entry] {
lock.lock(); defer { lock.unlock() }
return storedEntries
}

override func log(_ message: String, with level: ARTLogLevel) {
lock.lock()
storedEntries.append(Entry(level: level, message: message))
lock.unlock()
}

/// Whether any captured message at `level` contains `substring` (case-insensitive).
func contains(level: ARTLogLevel, message substring: String) -> Bool {
entries.contains { $0.level == level && $0.message.localizedCaseInsensitiveContains(substring) }
}
}
138 changes: 138 additions & 0 deletions Test/UTS/Harness/MockHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Foundation
import Ably
import Ably.Private

/// The UTS `MockHttpClient` — a fake `ARTHTTPExecutor` that intercepts the SDK's outgoing HTTP
/// requests so tests can observe them and inject responses, with no real network. Installed via
/// `rest.internal.httpExecutor` (the cocoa mapping of the spec's `install_mock`).
///
/// Mirrors `uts/rest/unit/helpers/mock_http.md`. The cocoa HTTP seam is **request-level**
/// (`executeRequest:completion:`), so each `execute(_:)` is a standalone attempt: `onConnectionAttempt`
/// is consulted first (its `respond_with_refused`/`timeout`/`dns_error` fail the request with the
/// corresponding `NSError`), and unless the connection failed, the request is delivered to `onRequest`.
final class MockHTTPClient: NSObject, ARTHTTPExecutor, Sendable {
/// Returns the error the connection should fail with, or `nil` if it succeeds.
typealias ConnectionHandler = @Sendable (PendingHTTPConnection) -> NSError?
typealias RequestHandler = @Sendable (PendingHTTPRequest) -> Void

private let onConnectionAttempt: ConnectionHandler?
private let onRequest: RequestHandler?

init(onConnectionAttempt: ConnectionHandler? = nil, onRequest: RequestHandler? = nil) {
self.onConnectionAttempt = onConnectionAttempt
self.onRequest = onRequest
super.init()
}

// MARK: ARTHTTPExecutor

func execute(_ request: URLRequest, completion callback: ((HTTPURLResponse?, Data?, Error?) -> Void)? = nil) -> (ARTCancellable & NSObjectProtocol)? {
// Connection phase — fail the request if the connection handler rejects it.
if let connectionError = onConnectionAttempt?(PendingHTTPConnection(request: request)) {
callback?(nil, nil, connectionError)
return NoopCancellable()
}

// Request phase — deliver to onRequest.
guard let onRequest else {
// A request reached the mock with no handler installed: a test set-up error.
fatalError("MockHTTPClient received a request but no onRequest handler is installed")
}
onRequest(PendingHTTPRequest(request: request, completion: callback))
return NoopCancellable()
}
}

/// A connection attempt (UTS `PendingConnection`). The cocoa HTTP seam doesn't expose real TCP, so
/// this is derived from the request's URL; the `onConnectionAttempt` handler inspects it and returns
/// the resulting error (or `nil`). Immutable — the result is the handler's return value.
struct PendingHTTPConnection {
let host: String
let port: Int
let tls: Bool

init(request: URLRequest) {
guard let url = request.url, let host = url.host else {
// The SDK should never make a request without a URL host; if it does, the test set-up
// (or the SDK) is broken, so fail fast rather than fabricate a connection.
fatalError("MockHTTPClient received a connection attempt for a request without a URL host")
}
self.tls = (url.scheme?.lowercased() == "https")
self.host = host
self.port = url.port ?? (tls ? 443 : 80)
}

/// Connection succeeds; requests proceed (UTS `respond_with_success`).
func respondWithSuccess() -> NSError? { nil }
/// TCP connection refused (UTS `respond_with_refused`).
func respondWithRefused() -> NSError? { Self.urlError(.cannotConnectToHost) }
/// Connection times out (UTS `respond_with_timeout`).
func respondWithTimeout() -> NSError? { Self.urlError(.timedOut) }
/// DNS resolution fails (UTS `respond_with_dns_error`).
func respondWithDNSError() -> NSError? { Self.urlError(.cannotFindHost) }

private static func urlError(_ code: URLError.Code) -> NSError {
NSError(domain: NSURLErrorDomain, code: code.rawValue, userInfo: nil)
}
}

/// A request the SDK made (UTS `PendingRequest`): inspectable, and respondable by the test. Holds the
/// completion callback rather than any mutable state.
struct PendingHTTPRequest {
let request: URLRequest
private let completion: ((HTTPURLResponse?, Data?, Error?) -> Void)?

init(request: URLRequest, completion: ((HTTPURLResponse?, Data?, Error?) -> Void)?) {
self.request = request
self.completion = completion
}

var url: URL {
guard let url = request.url else {
fatalError("MockHTTPClient received a request without a URL")
}
return url
}
var method: String { request.httpMethod ?? "GET" }
var headers: [String: String] { request.allHTTPHeaderFields ?? [:] }
var body: Data? { request.httpBody }

/// Query parameters parsed from the request URL (UTS `url.query_params`).
var queryParams: [String: String] {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let items = components.queryItems else { return [:] }
var result: [String: String] = [:]
for item in items where item.value != nil { result[item.name] = item.value }
return result
}

/// Sends an HTTP response (UTS `respond_with`). `body` may be `Data`, `String`, or a
/// JSON-serialisable value (dictionary/array); defaults to a JSON content type.
func respondWith(status: Int, body: Any, headers: [String: String] = [:]) {
var headerFields = headers
if headerFields["Content-Type"] == nil {
headerFields["Content-Type"] = "application/json"
}
let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headerFields)
completion?(response, Self.data(from: body), nil)
}

/// Simulates a request timeout after the connection was established (UTS `respond_with_timeout`).
func respondWithTimeout() {
completion?(nil, nil, NSError(domain: NSURLErrorDomain, code: URLError.timedOut.rawValue, userInfo: nil))
}

private static func data(from body: Any) -> Data {
switch body {
case let data as Data: return data
case let string as String: return Data(string.utf8)
default: return (try? JSONSerialization.data(withJSONObject: body)) ?? Data()
}
}
}

/// No-op cancellable returned by `MockHTTPClient.execute` (the response is delivered synchronously, so
/// there's nothing to cancel).
private final class NoopCancellable: NSObject, ARTCancellable {
func cancel() {}
}
Loading
Loading