Skip to content
Open
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
6 changes: 5 additions & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ jobs:

# https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2
- run: swift build -Xswiftc -warnings-as-errors
- run: swift test -Xswiftc -warnings-as-errors
# The `UTS` target's tests are translations of the LiveObjects Universal Test Suite against an
# as-yet-unimplemented (skeleton) public API, so many trap via `notImplemented()` at runtime. A
# trap is a `fatalError`, which aborts the whole `swift test` process, so we skip the UTS target
# here (it's still built above); it'll be run once the API it targets is implemented.
- run: swift test --skip 'UTS\.' -Xswiftc -warnings-as-errors

build-release-configuration-spm:
name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/ably/ably-cocoa.git",
from: "1.2.59",
from: "1.2.62",
),
.package(
url: "https://github.com/ably/ably-cocoa-plugin-support",
Expand Down Expand Up @@ -77,6 +77,24 @@ let package = Package(
.copy("ably-common"),
],
),
.testTarget(
name: "UTS",
dependencies: [
"AblyLiveObjects",
.product(
name: "Ably",
package: "ably-cocoa",
),
.product(
name: "_AblyPluginSupportPrivate",
package: "ably-cocoa-plugin-support",
),
],
path: "Tests/UTS",
exclude: [
"deviations.md",
],
),
.executableTarget(
name: "BuildTool",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ public struct ObjectMessage: Sendable, Equatable {
self.siteCode = siteCode
self.extras = extras
}

/// Builds the user-facing `ObjectMessage` from an internal (protocol) object message, taking the
/// channel name from `channelName` (not from the source message). The operation is derived per
/// `PAOOP3`. Spec: `PAOM3`.
///
/// > Note: Not yet implemented in this target — traps via ``notImplemented()``.
internal static func fromInternalObjectMessage(_ source: ProtocolTypes.InboundObjectMessage, channelName: String) -> ObjectMessage {
_ = (source, channelName)
notImplemented()
}
}

// MARK: - ObjectOperation (PAOOP)
Expand Down Expand Up @@ -111,6 +121,16 @@ public struct ObjectOperation: Sendable, Equatable {
self.objectDelete = objectDelete
self.mapClear = mapClear
}

/// Builds the user-facing `ObjectOperation` from an internal (protocol) object operation. The
/// direct payloads are copied; `mapCreate` / `counterCreate` are resolved from either the direct
/// field or the `*WithObjectId` variant's retained `derivedFrom` value. Spec: `PAOOP3`.
///
/// > Note: Not yet implemented in this target — traps via ``notImplemented()``.
internal static func fromInternalObjectOperation(_ source: ProtocolTypes.ObjectOperation) -> ObjectOperation {
_ = source
notImplemented()
}
}

// MARK: - ObjectOperationAction (OOP2)
Expand Down
33 changes: 33 additions & 0 deletions Tests/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 Tests/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 Tests/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