diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
index b50e21fe..3c9dfdef 100644
--- a/.github/workflows/check.yaml
+++ b/.github/workflows/check.yaml
@@ -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 }})
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme
index 5c088be7..d36373d9 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme
@@ -62,6 +62,16 @@
ReferencedContainer = "container:">
+
+
+
+
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)
@@ -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)
diff --git a/Tests/UTS/Harness/Captured.swift b/Tests/UTS/Harness/Captured.swift
new file mode 100644
index 00000000..055c1707
--- /dev/null
+++ b/Tests/UTS/Harness/Captured.swift
@@ -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: @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] }
+}
diff --git a/Tests/UTS/Harness/CapturingLog.swift b/Tests/UTS/Harness/CapturingLog.swift
new file mode 100644
index 00000000..99530160
--- /dev/null
+++ b/Tests/UTS/Harness/CapturingLog.swift
@@ -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) }
+ }
+}
diff --git a/Tests/UTS/Harness/MockHTTPClient.swift b/Tests/UTS/Harness/MockHTTPClient.swift
new file mode 100644
index 00000000..2ee8b81c
--- /dev/null
+++ b/Tests/UTS/Harness/MockHTTPClient.swift
@@ -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() {}
+}
diff --git a/Tests/UTS/Harness/MockTimeProvider.swift b/Tests/UTS/Harness/MockTimeProvider.swift
new file mode 100644
index 00000000..bd74dc99
--- /dev/null
+++ b/Tests/UTS/Harness/MockTimeProvider.swift
@@ -0,0 +1,191 @@
+import Foundation
+import Ably
+import Ably.Private
+
+/// A deterministic `ARTTimeProvider` for UTS unit tests.
+///
+/// Both the wall clock and the continuous clock start at a fixed origin and only ever move when the test calls
+/// `advanceTime(byMilliseconds:)` — nothing fires on its own, so tests are fully in control
+/// (the UTS `enable_fake_timers()` / `ADVANCE_TIME(ms)` primitives).
+///
+/// Every block scheduled through the SDK's `scheduleAfter:queue:block:` is recorded here, which
+/// means this provider doubles as the timer-leak safety net the UTS derived-tests guide asks for:
+/// `cancelAllScheduled()` in teardown cancels every surviving timer, including ones the SDK may
+/// have orphaned and which are no longer reachable through its public API.
+final class MockTimeProvider: NSObject, TimeProvider, @unchecked Sendable {
+ /// A point on / duration of the continuous clock, in the same units as `clock_gettime_nsec_np`.
+ private typealias Nanoseconds = UInt64
+
+ private let lock = NSLock()
+
+ /// Wall-clock origin: a fixed, arbitrary point in time (ms since the Unix epoch).
+ private var wallClockMilliseconds: Double = 1_600_000_000_000
+
+ /// Continuous-clock origin.
+ private var continuousNanoseconds: Nanoseconds = 1_000_000_000
+
+ private final class ScheduledBlock: @unchecked Sendable {
+ let fireAtNanoseconds: Nanoseconds
+ let queue: DispatchQueue
+ let block: @Sendable () -> Void
+ var isCancelled = false
+
+ init(fireAtNanoseconds: Nanoseconds, queue: DispatchQueue, block: @escaping @Sendable () -> Void) {
+ self.fireAtNanoseconds = fireAtNanoseconds
+ self.queue = queue
+ self.block = block
+ }
+ }
+
+ /// A cancellable handle returned to the SDK for a scheduled block.
+ private final class Handle: NSObject, SchedulerHandle {
+ private let owner: MockTimeProvider
+ fileprivate let scheduled: ScheduledBlock
+
+ init(owner: MockTimeProvider, scheduled: ScheduledBlock) {
+ self.owner = owner
+ self.scheduled = scheduled
+ }
+
+ func cancel() {
+ owner.cancel(scheduled)
+ }
+ }
+
+ /// The fake clock's own continuous-clock instant. The system instant
+ /// (`ARTSystemContinuousClockInstant`) is private to `ARTSystemTimeProvider`, so the fake
+ /// provider supplies its own value type conforming to the boundary protocol.
+ private final class Instant: NSObject, ContinuousClockInstant, @unchecked Sendable {
+ let nanoseconds: UInt64
+ init(nanoseconds: UInt64) { self.nanoseconds = nanoseconds }
+
+ func isAfter(_ other: ContinuousClockInstant) -> Bool {
+ guard let other = other as? Instant else { return false }
+ return nanoseconds > other.nanoseconds
+ }
+
+ func addingDuration(_ duration: TimeInterval) -> ContinuousClockInstant {
+ Instant(nanoseconds: nanoseconds + UInt64(max(0, duration) * 1_000_000_000))
+ }
+ }
+
+ private var scheduledBlocks: [ScheduledBlock] = []
+
+ private func withLock(_ body: () -> T) -> T {
+ lock.lock()
+ defer { lock.unlock() }
+ return body()
+ }
+
+ /// Count of pending (not-yet-fired, not-cancelled) blocks. Caller must hold the lock.
+ private var pendingCount: Int {
+ scheduledBlocks.filter { !$0.isCancelled }.count
+ }
+
+ // MARK: ARTTimeProvider
+
+ func wallClockNow() -> Date {
+ withLock { Date(timeIntervalSince1970: wallClockMilliseconds / 1000.0) }
+ }
+
+ func continuousClockNow() -> ContinuousClockInstant {
+ withLock { Instant(nanoseconds: continuousNanoseconds) }
+ }
+
+ func schedule(after delay: TimeInterval, queue: DispatchQueue, block: @escaping @Sendable () -> Void) -> SchedulerHandle {
+ withLock {
+ rememberQueue(queue)
+ let fireAt = continuousNanoseconds + Nanoseconds(max(0, delay) * 1_000_000_000)
+ let scheduled = ScheduledBlock(fireAtNanoseconds: fireAt, queue: queue, block: block)
+ scheduledBlocks.append(scheduled)
+ return Handle(owner: self, scheduled: scheduled)
+ }
+ }
+
+ // MARK: Test control
+
+ /// Every distinct queue the SDK has scheduled a timer on. `advanceTime` barriers these to let
+ /// SDK work settle around firing timers. We remember them (rather than reading the queues of the
+ /// currently-pending blocks) because there are moments when no block is pending — at the leading
+ /// drain before a queued handler has scheduled its timer, and between firing a timer and its
+ /// cascade scheduling the next one — yet we still need to barrier the SDK's queue. The SDK only
+ /// schedules timers on its internal queue, so that queue is remembered the first time any timer
+ /// is scheduled (during connect for example); the callback queue isn't drained here, which is fine
+ /// since the tests observe state through the internal queue, not via listener callbacks.
+ private var knownQueues: [ObjectIdentifier: DispatchQueue] = [:]
+
+ /// Caller must hold `lock`. Keyed by identity, so re-registering a queue is a no-op.
+ private func rememberQueue(_ queue: DispatchQueue) {
+ knownQueues[ObjectIdentifier(queue)] = queue
+ }
+
+ /// Advances both clocks by `milliseconds` and runs the SDK to quiescence:
+ ///
+ /// 1. Drains first, so any already-queued work (e.g. an unexpected disconnect transitioning to
+ /// DISCONNECTED) finishes *registering its timer* before the clock moves past it.
+ /// 2. Advances both clocks.
+ /// 3. Fires every now-due block and drains the cascade it triggers, repeating while the cascade
+ /// schedules further blocks that are themselves already due (e.g. a zero-delay reschedule).
+ ///
+ /// After it returns, the SDK has fully reacted to the elapsed time: state has settled and the
+ /// next timer (if any) is registered. Chained retries that are scheduled *relative to the new
+ /// time* remain in the future and need a subsequent `advanceTime` — matching real elapsed time.
+ func advanceTime(byMilliseconds milliseconds: Double) {
+ drainAllQueues()
+
+ let target = withLock { () -> Nanoseconds in
+ continuousNanoseconds = continuousNanoseconds + Nanoseconds(max(0, milliseconds) * 1_000_000)
+ wallClockMilliseconds += milliseconds
+ return continuousNanoseconds
+ }
+
+ while fireBlocksDue(upTo: target) {
+ drainAllQueues()
+ }
+ }
+
+ /// Dispatches every not-yet-fired, not-cancelled block whose fire time is `<= target` onto its
+ /// target queue (in fire-time order). Returns `true` if it dispatched anything.
+ private func fireBlocksDue(upTo target: Nanoseconds) -> Bool {
+ let due = withLock { () -> [ScheduledBlock] in
+ let due = scheduledBlocks
+ .filter { !$0.isCancelled && $0.fireAtNanoseconds <= target }
+ .sorted { $0.fireAtNanoseconds < $1.fireAtNanoseconds }
+ scheduledBlocks.removeAll { scheduled in due.contains { $0 === scheduled } }
+ return due
+ }
+ for scheduled in due {
+ scheduled.queue.async(execute: scheduled.block)
+ }
+ return !due.isEmpty
+ }
+
+ /// Settles the SDK's queues until a pass no longer changes the number of scheduled timers —
+ /// i.e. the work has stopped scheduling (or cancelling) timers and has quiesced. Each pass takes
+ /// the lock once (snapshotting both the queues to barrier and the pending count together).
+ private func drainAllQueues(maxPasses: Int = 100) {
+ var previousCount = -1
+ for _ in 0.. Void
+ /// Handler invoked with each `ARTProtocolMessage` the SDK sends towards the server (UTS
+ /// `onMessageFromClient`). Used by the LiveObjects `setup_synced_channel` pattern to respond to
+ /// `ATTACH` with `ATTACHED`+`OBJECT_SYNC` and to auto-`ACK` `OBJECT` publishes.
+ typealias MessageFromClientHandler = @Sendable (ARTProtocolMessage) -> Void
+
+ private let onConnectionAttempt: ConnectionAttemptHandler?
+ private let onMessageFromClient: MessageFromClientHandler?
+ private let lock = NSLock()
+ private var latestConnection: MockWebSocket?
+
+ /// The most recent connection (UTS `active_connection`). Set on the internal queue, read from
+ /// the test thread, so guarded by a lock. Tests that need the full history of connection
+ /// attempts capture them into a local array inside `onConnectionAttempt` (the spec's pattern).
+ var activeConnection: MockWebSocket? {
+ lock.lock(); defer { lock.unlock() }
+ return latestConnection
+ }
+
+ init(onConnectionAttempt: ConnectionAttemptHandler? = nil,
+ onMessageFromClient: MessageFromClientHandler? = nil) {
+ self.onConnectionAttempt = onConnectionAttempt
+ self.onMessageFromClient = onMessageFromClient
+ }
+
+ fileprivate func register(_ socket: MockWebSocket) {
+ lock.lock()
+ latestConnection = socket
+ lock.unlock()
+ }
+
+ fileprivate func fireConnectionAttempt(_ socket: MockWebSocket) {
+ onConnectionAttempt?(socket)
+ }
+
+ fileprivate func fireMessageFromClient(_ message: ARTProtocolMessage) {
+ onMessageFromClient?(message)
+ }
+}
+
+/// A single simulated WebSocket connection. Conforms to `ARTWebSocket` so the real
+/// `ARTWebSocketTransport` can drive it, and exposes the UTS server-side API
+/// (`respondWithSuccess`, `sendToClient`, `simulateDisconnect`, …) for tests.
+final class MockWebSocket: NSObject, ARTWebSocket, @unchecked Sendable {
+
+ // MARK: ARTWebSocket
+
+ weak var delegate: ARTWebSocketDelegate?
+ var delegateDispatchQueue: DispatchQueue?
+ private(set) var readyState: ARTWebSocketReadyState = .connecting
+
+ // MARK: Inspection
+
+ /// The full request the SDK built for this connection (real production URL building).
+ let request: URLRequest
+ /// Convenience accessor for the connection URL.
+ var url: URL { request.url ?? URL(string: "wss://invalid")! }
+ /// Query parameters parsed from the connection URL (UTS `url.query_params`).
+ var queryParams: [String: String] {
+ guard let url = request.url,
+ 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
+ }
+
+ /// Protocol messages the SDK has sent towards the server, decoded, in order
+ /// (UTS client-to-server `ws_frame` events). Appended on the internal queue, read from the
+ /// test thread, so guarded by a lock.
+ var sentMessages: [ARTProtocolMessage] {
+ lock.lock(); defer { lock.unlock() }
+ return sent
+ }
+ private var sent: [ARTProtocolMessage] = []
+
+ /// The raw frames the SDK has sent towards the server, in order. Object-message tests inspect the
+ /// generated wire form here (the decoded `ARTProtocolMessage.state` drops the outbound-only
+ /// `*WithObjectId` fields), mirroring ably-js's `captured.state` wire inspection.
+ var sentFrames: [Data] {
+ lock.lock(); defer { lock.unlock() }
+ return frames
+ }
+ private var frames: [Data] = []
+ private let lock = NSLock()
+
+ private let decoder: ARTEncoder
+
+ /// The provider that created this socket, notified of each client-sent message (UTS
+ /// `onMessageFromClient`). Weak to avoid a retain cycle; the provider outlives the socket.
+ fileprivate weak var provider: MockWebSocketProvider?
+
+ init(request: URLRequest, decoder: ARTEncoder) {
+ self.request = request
+ self.decoder = decoder
+ super.init()
+ }
+
+ func setDelegateDispatchQueue(_ queue: DispatchQueue) {
+ delegateDispatchQueue = queue
+ }
+
+ func open() {
+ // No-op: opening is driven by the test via `onConnectionAttempt` → `respondWithSuccess()`,
+ // not by the real network. (The transport calls this on a private queue we don't control;
+ // keeping it a no-op means all simulated server activity is delivered via the delegate queue.)
+ }
+
+ func close(withCode code: Int, reason: String?) {
+ readyState = .closed
+ }
+
+ func send(_ message: Any?) {
+ guard let data = message as? Data else { return }
+ guard let decoded = try? decoder.decodeProtocolMessage(data) else { return }
+ lock.lock()
+ sent.append(decoded)
+ frames.append(data)
+ lock.unlock()
+ // UTS `onMessageFromClient`: let the test's simulated server react to what the client sent.
+ provider?.fireMessageFromClient(decoded)
+ }
+
+ // MARK: UTS server-side API
+
+ /// Accepts the connection at the transport level (UTS `respond_with_success()`).
+ func respondWithSuccess() {
+ readyState = .open
+ deliverToDelegate { [weak self] in
+ guard let self else { return }
+ self.delegate?.webSocketDidOpen?(self)
+ }
+ }
+
+ /// Delivers a protocol message to the client, leaving the connection open
+ /// (UTS `send_to_client`).
+ func sendToClient(_ message: ProtocolMessage) {
+ deliverToDelegate { [weak self] in
+ guard let self else { return }
+ guard self.readyState == .open else { return }
+ self.delegate?.webSocket?(self, didReceiveMessage: message.makeProtocolMessage())
+ }
+ }
+
+ /// Delivers a protocol message and then closes the connection (UTS `send_to_client_and_close`).
+ /// Used for connection-level `ERROR` (no channel) and `DISCONNECTED`.
+ func sendToClientAndClose(_ message: ProtocolMessage) {
+ deliverToDelegate { [weak self] in
+ guard let self else { return }
+ if self.readyState == .open {
+ self.delegate?.webSocket?(self, didReceiveMessage: message.makeProtocolMessage())
+ }
+ self.readyState = .closed
+ self.delegate?.webSocket?(self, didCloseWithCode: WSCloseCode.normal, reason: "Normal Closure", wasClean: true)
+ }
+ }
+
+ /// Refuses the connection at the network level (UTS `respond_with_refused()`); the SDK treats
+ /// this as a retryable connection failure (`realtimeTransportRefused:`).
+ func respondWithRefused() {
+ deliverToDelegate { [weak self] in
+ guard let self else { return }
+ self.readyState = .closed
+ self.delegate?.webSocket?(self, didCloseWithCode: WSCloseCode.refuse, reason: "Connection refused", wasClean: false)
+ }
+ }
+
+ /// Drops the connection without any server message (UTS `simulate_disconnect`).
+ func simulateDisconnect() {
+ deliverToDelegate { [weak self] in
+ guard let self else { return }
+ self.readyState = .closed
+ // GoingAway maps to `realtimeTransportDisconnected:` (an unexpected connectivity drop).
+ self.delegate?.webSocket?(self, didCloseWithCode: WSCloseCode.goingAway, reason: "Going Away", wasClean: false)
+ }
+ }
+
+ /// Delivers a delegate callback on the `delegateDispatchQueue` the transport set, per the
+ /// `ARTWebSocket` contract (the real `ARTSRWebSocket` calls its delegate on this queue). It is
+ /// always set before any server-side method runs: the transport sets it synchronously in
+ /// `setupWebSocket`, before `onConnectionAttempt` (and any later test call) fires.
+ private func deliverToDelegate(_ block: @escaping @Sendable () -> Void) {
+ guard let queue = delegateDispatchQueue else {
+ assertionFailure("MockWebSocket delegate callback before delegateDispatchQueue was set")
+ return
+ }
+ queue.async(execute: block)
+ }
+}
+
+/// WebSocket close codes interpreted by `ARTWebSocketTransport.webSocket:didCloseWithCode:…`.
+private enum WSCloseCode {
+ static let normal = 1000 // ARTWsCloseNormal -> realtimeTransportClosed:
+ static let goingAway = 1001 // ARTWsGoingAway -> realtimeTransportDisconnected:
+ static let refuse = 1003 // ARTWsRefuse -> realtimeTransportRefused:
+}
+
+/// Creates `MockWebSocket`s and notifies the `MockWebSocketProvider` of each connection attempt.
+final class MockWebSocketFactory: NSObject, WebSocketFactory {
+ private let workQueue: DispatchQueue
+ private let decoder: ARTEncoder
+ private let wsProvider: MockWebSocketProvider
+
+ init(workQueue: DispatchQueue, decoder: ARTEncoder, wsProvider: MockWebSocketProvider) {
+ self.workQueue = workQueue
+ self.decoder = decoder
+ self.wsProvider = wsProvider
+ super.init()
+ }
+
+ func createWebSocket(with request: URLRequest, logger: InternalLog?) -> ARTWebSocket {
+ let webSocket = MockWebSocket(request: request, decoder: decoder)
+ webSocket.provider = wsProvider
+ wsProvider.register(webSocket)
+ // Fire `onConnectionAttempt` on the work queue. This block is enqueued during the transport's
+ // `setupWebSocket` turn, so it runs *after* the transport has wired up `delegate` and
+ // `delegateDispatchQueue` (both set synchronously right after this method returns).
+ let wsProvider = self.wsProvider
+ workQueue.async {
+ wsProvider.fireConnectionAttempt(webSocket)
+ }
+ return webSocket
+ }
+}
+
+/// A `RealtimeTransportFactory` that builds a real `ARTWebSocketTransport` backed by a
+/// `MockWebSocketFactory`. Installed via `ARTClientOptions.testOptions.transportFactory`.
+final class MockWebSocketTransportFactory: NSObject, RealtimeTransportFactory {
+ private let wsProvider: MockWebSocketProvider
+
+ init(wsProvider: MockWebSocketProvider) {
+ self.wsProvider = wsProvider
+ super.init()
+ }
+
+ func transport(withRest rest: ARTRestInternal, options: ARTClientOptions, resumeKey: String?, logger: InternalLog) -> ARTRealtimeTransport {
+ let webSocketFactory = MockWebSocketFactory(workQueue: rest.queue, decoder: rest.defaultEncoder, wsProvider: wsProvider)
+ return ARTWebSocketTransport(rest: rest, options: options, resumeKey: resumeKey, logger: logger, webSocketFactory: webSocketFactory)
+ }
+}
diff --git a/Tests/UTS/Harness/NoOpReachability.swift b/Tests/UTS/Harness/NoOpReachability.swift
new file mode 100644
index 00000000..935acfcb
--- /dev/null
+++ b/Tests/UTS/Harness/NoOpReachability.swift
@@ -0,0 +1,22 @@
+import Foundation
+import Ably
+import Ably.Private
+
+/// A reachability implementation that never reports any network changes.
+///
+/// Unit tests use the `MockWebSocket` transport and must not touch the real network; installing
+/// this via `options.testOptions.reachabilityClass` stops the SDK from starting OS-level
+/// network monitoring during a test.
+final class NoOpReachability: NSObject, ARTReachability {
+ init(logger: InternalLog, queue: DispatchQueue) {
+ super.init()
+ }
+
+ func listen(forHost host: String, callback: @escaping (Bool) -> Void) {
+ // Intentionally empty: never deliver a reachability change.
+ }
+
+ func off() {
+ // Nothing to tear down.
+ }
+}
diff --git a/Tests/UTS/Harness/ProtocolMessage.swift b/Tests/UTS/Harness/ProtocolMessage.swift
new file mode 100644
index 00000000..d937b20a
--- /dev/null
+++ b/Tests/UTS/Harness/ProtocolMessage.swift
@@ -0,0 +1,162 @@
+import Foundation
+import Ably
+import Ably.Private
+import _AblyPluginSupportPrivate
+@testable import AblyLiveObjects
+
+/// A `Sendable` description of a server-to-client protocol message that a test injects via
+/// `MockWebSocket.sendToClient(_:)` / `sendToClientAndClose(_:)`.
+///
+/// The LiveObjects `OBJECT` / `OBJECT_SYNC` variants carry decoded ``ProtocolTypes/InboundObjectMessage``
+/// values (which are `Sendable`); the concrete `ARTProtocolMessage.state` — an array of the plugin's
+/// `ObjectMessageBox` boxes — is built at delivery time in `makeProtocolMessage()`, so no
+/// non-`Sendable` value crosses the queue hop. This mirrors what ably-cocoa's wire decoder produces
+/// (the `state` property holds already-decoded `APObjectMessageProtocol` boxes), so the mock can
+/// bypass wire encoding entirely.
+struct ProtocolMessage: Sendable {
+
+ private enum Kind: Sendable {
+ case connected(connectionId: String, connectionKey: String, maxIdleInterval: TimeInterval, connectionStateTtl: TimeInterval, siteCode: String?, objectsGCGracePeriod: Double?)
+ case attached(channel: String, channelSerial: String, hasObjects: Bool)
+ case error(code: Int, statusCode: Int, message: String)
+ case ack(msgSerial: Int, count: Int, serials: [String?]?)
+ case closed
+ case detached(channel: String)
+ case channelError(channel: String, code: Int, statusCode: Int, message: String)
+ case objectSync(channel: String, channelSerial: String?, state: [ProtocolTypes.InboundObjectMessage])
+ case object(channel: String, state: [ProtocolTypes.InboundObjectMessage])
+ }
+
+ private let kind: Kind
+
+ /// A `CONNECTED` message carrying connection details (UTS `ProtocolMessage(action: CONNECTED, ...)`).
+ static func connected(connectionId: String,
+ connectionKey: String,
+ maxIdleInterval: TimeInterval = 15,
+ connectionStateTtl: TimeInterval = 120,
+ siteCode: String? = nil,
+ objectsGCGracePeriod: Double? = nil) -> ProtocolMessage {
+ .init(kind: .connected(connectionId: connectionId, connectionKey: connectionKey, maxIdleInterval: maxIdleInterval, connectionStateTtl: connectionStateTtl, siteCode: siteCode, objectsGCGracePeriod: objectsGCGracePeriod))
+ }
+
+ /// An `ATTACHED` message for a channel (UTS `ProtocolMessage(action: ATTACHED, ...)`). Set
+ /// `hasObjects` to raise the `HAS_OBJECTS` flag (RTO4).
+ static func attached(channel: String, channelSerial: String, hasObjects: Bool = false) -> ProtocolMessage {
+ .init(kind: .attached(channel: channel, channelSerial: channelSerial, hasObjects: hasObjects))
+ }
+
+ /// An `ERROR` message (UTS `ProtocolMessage(action: ERROR, error: ErrorInfo(...))`).
+ static func error(code: Int, statusCode: Int, message: String) -> ProtocolMessage {
+ .init(kind: .error(code: code, statusCode: statusCode, message: message))
+ }
+
+ /// An `ACK` message (UTS `ProtocolMessage(action: ACK, msgSerial: ..., count: ...)`). Pass
+ /// `serials` to populate the `res` array (UTS `build_ack_message`, one `res` entry carrying all
+ /// the object serials).
+ static func ack(msgSerial: Int, count: Int, serials: [String?]? = nil) -> ProtocolMessage {
+ .init(kind: .ack(msgSerial: msgSerial, count: count, serials: serials))
+ }
+
+ /// A `CLOSED` message (UTS `ProtocolMessage(action: CLOSED)`).
+ static func closed() -> ProtocolMessage {
+ .init(kind: .closed)
+ }
+
+ /// A channel `DETACHED` message (UTS `ProtocolMessage(action: DETACHED, channel: ...)`).
+ static func detached(channel: String) -> ProtocolMessage {
+ .init(kind: .detached(channel: channel))
+ }
+
+ /// A channel-level `ERROR` message that transitions the channel to `FAILED`
+ /// (UTS `ProtocolMessage(action: ERROR, channel: ..., error: ...)`).
+ static func channelError(channel: String, code: Int, statusCode: Int, message: String) -> ProtocolMessage {
+ .init(kind: .channelError(channel: channel, code: code, statusCode: statusCode, message: message))
+ }
+
+ /// An `OBJECT_SYNC` message carrying object state (UTS `build_object_sync_message`).
+ static func objectSync(channel: String, channelSerial: String?, state: [ProtocolTypes.InboundObjectMessage]) -> ProtocolMessage {
+ .init(kind: .objectSync(channel: channel, channelSerial: channelSerial, state: state))
+ }
+
+ /// An `OBJECT` message carrying object operations (UTS `build_object_message`).
+ static func object(channel: String, state: [ProtocolTypes.InboundObjectMessage]) -> ProtocolMessage {
+ .init(kind: .object(channel: channel, state: state))
+ }
+
+ /// Builds the concrete `ARTProtocolMessage`. Call on the delegate queue, at delivery time.
+ func makeProtocolMessage() -> ARTProtocolMessage {
+ let message = ARTProtocolMessage()
+ switch kind {
+ case let .connected(connectionId, connectionKey, maxIdleInterval, connectionStateTtl, siteCode, objectsGCGracePeriod):
+ message.action = .connected
+ message.connectionId = connectionId
+ message.connectionKey = connectionKey
+ message.connectionDetails = ARTConnectionDetails(
+ clientId: nil,
+ connectionKey: connectionKey,
+ maxMessageSize: 0,
+ maxFrameSize: 0,
+ maxInboundRate: 0,
+ connectionStateTtl: connectionStateTtl,
+ serverId: "",
+ maxIdleInterval: maxIdleInterval,
+ objectsGCGracePeriod: objectsGCGracePeriod.map { NSNumber(value: $0) },
+ siteCode: siteCode
+ )
+ case let .attached(channel, channelSerial, hasObjects):
+ message.action = .attached
+ message.channel = channel
+ message.channelSerial = channelSerial
+ if hasObjects {
+ // ARTProtocolMessageFlagHasObjects = 1 << 7
+ message.flags |= (1 << 7)
+ }
+ case let .error(code, statusCode, text):
+ message.action = .error
+ message.error = ARTErrorInfo.create(withCode: code, status: statusCode, message: text)
+ case let .ack(msgSerial, count, serials):
+ message.action = .ack
+ message.msgSerial = NSNumber(value: msgSerial)
+ message.count = Int32(count)
+ if let serials {
+ message.res = [ARTPublishResult(serials: serials.map { ARTPublishResultSerial(value: $0) })]
+ }
+ case .closed:
+ message.action = .closed
+ case let .detached(channel):
+ message.action = .detached
+ message.channel = channel
+ case let .channelError(channel, code, statusCode, text):
+ message.action = .error
+ message.channel = channel
+ message.error = ARTErrorInfo.create(withCode: code, status: statusCode, message: text)
+ case let .objectSync(channel, channelSerial, state):
+ message.action = .objectSync
+ message.channel = channel
+ message.channelSerial = channelSerial
+ message.setState(state)
+ case let .object(channel, state):
+ message.action = .object
+ message.channel = channel
+ message.setState(state)
+ }
+ return message
+ }
+}
+
+private extension ARTProtocolMessage {
+ /// Sets the object `state` on the protocol message.
+ ///
+ /// `ARTProtocolMessage.state` is gated behind `#ifdef ABLY_SUPPORTS_PLUGINS` in ably-cocoa, a
+ /// define set only for ably-cocoa's own target — so the property is absent from the `Ably` Swift
+ /// interface for consumers. (Defining the macro for our import isn't viable: it unlocks other
+ /// gated headers, e.g. `ARTPluginAPI.h` → `ARTTypes.h`, that aren't on a consumer's header search
+ /// path, breaking the `Ably` module build.) The property exists in the compiled runtime, so we
+ /// set it via KVC. The boxes are the same `ObjectMessageProtocol` type the plugin emits and
+ /// receives, so ably-cocoa passes them straight back to the plugin's `handleObjectSync…` hook —
+ /// mirroring what the wire decoder would have produced, without needing wire encoding.
+ func setState(_ objectMessages: [ProtocolTypes.InboundObjectMessage]) {
+ let boxes = objectMessages.map { DefaultInternalPlugin.ObjectMessageBox(objectMessage: $0) }
+ setValue(boxes, forKey: "state")
+ }
+}
diff --git a/Tests/UTS/Harness/UTSTestCase.swift b/Tests/UTS/Harness/UTSTestCase.swift
new file mode 100644
index 00000000..5d413e63
--- /dev/null
+++ b/Tests/UTS/Harness/UTSTestCase.swift
@@ -0,0 +1,188 @@
+import Foundation
+import Testing
+import Ably
+import Ably.Private
+
+/// Base class for UTS-derived unit tests.
+///
+/// UTS suites are Swift Testing suites that subclass this (`@Suite(.serialized) final class FooTests: UTSTestCase`).
+/// Swift Testing creates a fresh instance per `@Test`, so each test gets its own clients/mocks and
+/// `deinit` tears them down.
+///
+/// Provides the cocoa mappings of the UTS harness primitives:
+/// - `installMock(_:)` — the spec's `install_mock`: registers the `MockWebSocketProvider` / `MockHTTPClient`
+/// so the next client built by `makeRealtime()` / `makeRest()` picks it up. The mock is never
+/// passed to the client constructor (a mistake per `uts/docs/writing-test-specs.md`).
+/// - `awaitConnectionState` / `awaitChannelState` — the spec's `AWAIT_STATE`: proceed immediately
+/// if the condition already holds, otherwise poll until it does or the timeout expires.
+/// - `advanceTime(byMilliseconds:)` — fake-timer advancement (`ADVANCE_TIME`).
+/// - protocol-message builders, and cleanup of clients and scheduled timers.
+class UTSTestCase {
+
+ /// Default `AWAIT_STATE` timeout. Generous: with a frozen `MockTimeProvider` the SDK settles in
+ /// microseconds, so this only bites when a state genuinely never arrives (a real failure).
+ static let defaultAwaitTimeout: TimeInterval = 2
+
+ /// The deterministic clock, installed into clients only after `enableFakeTimers()` is called.
+ var timeProvider: MockTimeProvider?
+
+ private var installedWebSocketProvider: MockWebSocketProvider?
+ private var installedMockHTTPClient: MockHTTPClient?
+ private var clients: [ARTRealtime] = []
+
+ // MARK: Enable fake timers
+
+ /// UTS `enable_fake_timers()`. Clients built *after* this call use the deterministic
+ /// `MockTimeProvider` (advanced via `advanceTime(byMilliseconds:)`); by default — and for clients
+ /// built before this call — they use the real `ARTSystemTimeProvider`. Mirrors the spec, where
+ /// most tests run on real timers and only those exercising timeouts/retries opt into fake ones.
+ func enableFakeTimers() {
+ timeProvider = MockTimeProvider()
+ }
+
+ // MARK: Install mock
+
+ /// Registers `wsProvider` to be used by the next `makeRealtime()` call (UTS `install_mock`).
+ func installMock(_ wsProvider: MockWebSocketProvider) {
+ installedWebSocketProvider = wsProvider
+ }
+
+ /// Registers `mockHTTP` to be used by the next `makeRest()` call (UTS `install_mock`).
+ func installMock(_ mockHTTP: MockHTTPClient) {
+ installedMockHTTPClient = mockHTTP
+ }
+
+ // MARK: Client construction
+
+ /// Builds an `ARTRealtime` wired to the currently installed `MockWebSocketProvider` and the
+ /// shared `MockTimeProvider`. A provider must be installed first via `installMock(_:)`. Like the
+ /// UTS specs, this leaves the SDK's `autoConnect` default (`true`) in place; tests that need to
+ /// control connection timing set `options.autoConnect = false` in the `configure` closure.
+ func makeRealtime(configure: (ARTClientOptions) -> Void = { _ in }, sourceLocation: SourceLocation = #_sourceLocation) -> ARTRealtime {
+ guard let wsProvider = installedWebSocketProvider else {
+ Issue.record("No MockWebSocketProvider installed — call installMock(_:) before makeRealtime()", sourceLocation: sourceLocation)
+ fatalError("No MockWebSocketProvider installed")
+ }
+
+ let options = ARTClientOptions()
+ options.useBinaryProtocol = false
+
+ let suffix = UUID().uuidString
+ options.internalDispatchQueue = DispatchQueue(label: "io.ably.uts.internal.\(suffix)")
+ options.dispatchQueue = DispatchQueue(label: "io.ably.uts.callback.\(suffix)")
+
+ if let timeProvider {
+ options.testOptions.timeProvider = timeProvider
+ }
+ options.testOptions.transportFactory = MockWebSocketTransportFactory(wsProvider: wsProvider)
+ options.testOptions.reachabilityClass = NoOpReachability.self
+ // Realtime specs also use `install_mock(mock_http)` (auth callbacks, connection failures,
+ // fallback hosts, …), so wire an installed MockHTTPClient into the client's REST layer too.
+ if let installedMockHTTPClient {
+ options.testOptions.httpExecutor = installedMockHTTPClient
+ }
+
+ configure(options)
+
+ let client = ARTRealtime(options: options)
+ clients.append(client)
+ return client
+ }
+
+ /// Builds an `ARTRest` whose HTTP layer is the currently installed `MockHTTPClient` (so requests are
+ /// intercepted, not sent over the network). A mock must be installed first via `installMock(_:)`.
+ func makeRest(configure: (ARTClientOptions) -> Void = { _ in }, sourceLocation: SourceLocation = #_sourceLocation) -> ARTRest {
+ guard let mockHTTP = installedMockHTTPClient else {
+ Issue.record("No MockHTTPClient installed — call installMock(_:) before makeRest()", sourceLocation: sourceLocation)
+ fatalError("No MockHTTPClient installed")
+ }
+
+ let options = ARTClientOptions()
+ options.useBinaryProtocol = false
+
+ let suffix = UUID().uuidString
+ options.internalDispatchQueue = DispatchQueue(label: "io.ably.uts.internal.\(suffix)")
+ options.dispatchQueue = DispatchQueue(label: "io.ably.uts.callback.\(suffix)")
+
+ if let timeProvider {
+ options.testOptions.timeProvider = timeProvider
+ }
+ options.testOptions.httpExecutor = mockHTTP
+
+ configure(options)
+
+ let rest = ARTRest(options: options)
+ return rest
+ }
+
+ // MARK: AWAIT_STATE
+
+ /// Waits until `client.connection.state == expected` (UTS `AWAIT_STATE`).
+ func awaitConnectionState(_ client: ARTRealtime,
+ _ expected: ARTRealtimeConnectionState,
+ timeout: TimeInterval = defaultAwaitTimeout,
+ sourceLocation: SourceLocation = #_sourceLocation) {
+ poll("connection.state == \(ARTRealtimeConnectionStateToStr(expected))",
+ timeout: timeout, sourceLocation: sourceLocation) {
+ client.connection.state == expected
+ }
+ }
+
+ /// Waits until `channel.state == expected` (UTS `AWAIT_STATE`).
+ func awaitChannelState(_ channel: ARTRealtimeChannel,
+ _ expected: ARTRealtimeChannelState,
+ timeout: TimeInterval = defaultAwaitTimeout,
+ sourceLocation: SourceLocation = #_sourceLocation) {
+ poll("channel '\(channel.name)'.state == \(ARTRealtimeChannelStateToStr(expected))",
+ timeout: timeout, sourceLocation: sourceLocation) {
+ channel.state == expected
+ }
+ }
+
+ /// Generic `AWAIT_STATE`: proceed immediately if `condition` already holds, otherwise poll until
+ /// it does or `timeout` elapses (then fail). Used for non-state conditions such as "a frame has
+ /// been sent". The small sleep just avoids a hot busy-loop.
+ @discardableResult
+ func poll(_ description: String,
+ timeout: TimeInterval = defaultAwaitTimeout,
+ sourceLocation: SourceLocation = #_sourceLocation,
+ until condition: () -> Bool) -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition() {
+ if Date() >= deadline {
+ Issue.record("Timed out after \(timeout)s awaiting: \(description)", sourceLocation: sourceLocation)
+ return false
+ }
+ Thread.sleep(forTimeInterval: 0.0005)
+ }
+ return true
+ }
+
+ // MARK: Fake timers
+
+ /// Advances the fake clock (UTS `ADVANCE_TIME`). Requires `enableFakeTimers()` to have been
+ /// called — otherwise clients are on the real clock and there is nothing to advance.
+ func advanceTime(byMilliseconds milliseconds: Double, sourceLocation: SourceLocation = #_sourceLocation) {
+ guard let timeProvider else {
+ Issue.record("advanceTime requires enableFakeTimers() before makeRealtime()", sourceLocation: sourceLocation)
+ return
+ }
+ timeProvider.advanceTime(byMilliseconds: milliseconds)
+ }
+
+ /// Closes a client (UTS `CLOSE_CLIENT`).
+ func closeClient(_ client: ARTRealtime) {
+ client.close()
+ }
+
+ // MARK: Cleanup
+
+ /// Per-test teardown. Swift Testing releases the suite instance after each `@Test`, so this runs
+ /// once per test: it closes any clients and cancels surviving fake timers (the leak safety net).
+ deinit {
+ for client in clients {
+ client.close()
+ }
+ timeProvider?.cancelAllScheduled()
+ }
+}
diff --git a/Tests/UTS/Helpers/Instance+Testing.swift b/Tests/UTS/Helpers/Instance+Testing.swift
new file mode 100644
index 00000000..1b0f32cd
--- /dev/null
+++ b/Tests/UTS/Helpers/Instance+Testing.swift
@@ -0,0 +1,57 @@
+import Ably
+import Foundation
+@testable import AblyLiveObjects
+
+/// Payload extraction for ``Instance`` — the Swift equivalent of the spec's loosely-typed `Instance`
+/// casts. Non-optional: use ``Instance/type`` to discriminate the case first (these trap on a
+/// mismatch, like a forced cast).
+extension Instance {
+ /// The `objectId` of the wrapped live object (RTINS3), or `nil` for a primitive.
+ func id() -> String? {
+ switch self {
+ case let .liveMap(map):
+ map.id
+ case let .liveCounter(counter):
+ counter.id
+ case .primitive:
+ nil
+ }
+ }
+
+ func asLiveMap() -> any LiveMapInstance {
+ guard case let .liveMap(map) = self else { preconditionFailure("Instance is not a LiveMap") }
+ return map
+ }
+
+ func asLiveCounter() -> any LiveCounterInstance {
+ guard case let .liveCounter(counter) = self else { preconditionFailure("Instance is not a LiveCounter") }
+ return counter
+ }
+
+ func asPrimitive() -> any PrimitiveInstance {
+ guard case let .primitive(primitive) = self else { preconditionFailure("Instance is not a Primitive") }
+ return primitive
+ }
+}
+
+/// Typed shortcuts through ``PrimitiveInstance/value`` so tests can write
+/// `asPrimitive().stringValue` instead of `asPrimitive().value.stringValue`.
+extension PrimitiveInstance {
+ var stringValue: String? { get throws(ARTErrorInfo) { try value.stringValue } }
+ var numberValue: Double? { get throws(ARTErrorInfo) { try value.numberValue } }
+ var boolValue: Bool? { get throws(ARTErrorInfo) { try value.boolValue } }
+ var dataValue: Data? { get throws(ARTErrorInfo) { try value.dataValue } }
+ var jsonArrayValue: [JSONValue]? { get throws(ARTErrorInfo) { try value.jsonArrayValue } }
+ var jsonObjectValue: [String: JSONValue]? { get throws(ARTErrorInfo) { try value.jsonObjectValue } }
+}
+
+/// Typed shortcuts through ``PrimitivePathObject/value()`` so tests can write
+/// `asPrimitive().stringValue` instead of `asPrimitive().value()?.stringValue`.
+extension PrimitivePathObject {
+ var stringValue: String? { get throws(ARTErrorInfo) { try value()?.stringValue } }
+ var numberValue: Double? { get throws(ARTErrorInfo) { try value()?.numberValue } }
+ var boolValue: Bool? { get throws(ARTErrorInfo) { try value()?.boolValue } }
+ var dataValue: Data? { get throws(ARTErrorInfo) { try value()?.dataValue } }
+ var jsonArrayValue: [JSONValue]? { get throws(ARTErrorInfo) { try value()?.jsonArrayValue } }
+ var jsonObjectValue: [String: JSONValue]? { get throws(ARTErrorInfo) { try value()?.jsonObjectValue } }
+}
diff --git a/Tests/UTS/Helpers/StandardTestPool.swift b/Tests/UTS/Helpers/StandardTestPool.swift
new file mode 100644
index 00000000..be66f8b7
--- /dev/null
+++ b/Tests/UTS/Helpers/StandardTestPool.swift
@@ -0,0 +1,217 @@
+import Ably
+import Foundation
+@testable import AblyLiveObjects
+
+/// Shared fixtures, protocol-message builders, and canonical constants for the LiveObjects UTS
+/// tests. Derived from `uts/objects/helpers/standard_test_pool.md`.
+///
+/// The pool objects are built directly as internal ``ProtocolTypes/InboundObjectMessage`` values
+/// (reachable via `@testable`) and delivered through the mock as an `OBJECT_SYNC` protocol message;
+/// this mirrors what ably-cocoa's wire decoder produces (see ``ProtocolMessage``).
+enum StandardTestPool {
+ // MARK: - Canonical Constants
+
+ /// The harness `ConnectionDetails.siteCode`.
+ static let siteCode = "test-site"
+
+ /// The timeserial every standard-pool entry and object is seeded with.
+ static let poolSerial = "t:0"
+
+ /// The serial the harness assigns to a locally-published operation applied on its ACK; sorts
+ /// after ``poolSerial``. `ackSerial(0, 0) == "t:1:0"`.
+ static func ackSerial(_ msgSerial: Int, _ index: Int) -> String {
+ "t:\(msgSerial + 1):\(index)"
+ }
+
+ /// A REMOTE inbound "winning" serial on an existing pool entry; sorts after ``poolSerial``.
+ /// `remoteSerial(0) == "t:1"`.
+ static func remoteSerial(_ index: Int) -> String {
+ "t:\(index + 1)"
+ }
+
+ /// A serial that is not an ack-serial (escapes RTO9a3 dedup) yet sorts below the first
+ /// ack-serial, while still after ``poolSerial``. `belowAckSerial(9) == "t:0:9"`.
+ static func belowAckSerial(_ index: Int) -> String {
+ "t:0:\(index)"
+ }
+
+ // MARK: - Standard Pool Objects
+
+ /// The fixed LiveObjects tree used across test files, as `OBJECT_SYNC` state. See the tree
+ /// diagram in `standard_test_pool.md`.
+ static var objects: [ProtocolTypes.InboundObjectMessage] {
+ [
+ objectStateMessage(
+ objectId: "root",
+ map: objectsMap(
+ semantics: .lww,
+ entries: mapEntries([
+ "name": data(string: "Alice"),
+ "age": data(number: 30),
+ "active": data(boolean: true),
+ "score": data(objectId: "counter:score@1000"),
+ "profile": data(objectId: "map:profile@1000"),
+ "data": data(json: ["tags": ["a", "b"]]),
+ "avatar": data(bytes: Data([1, 2, 3])),
+ ]),
+ ),
+ createOp: mapCreateOp(objectId: "root"),
+ ),
+ objectStateMessage(
+ objectId: "counter:score@1000",
+ // count = 0 post-create; createOp carries the initial 100 -> materialises to 100.
+ counter: WireObjectsCounter(count: NSNumber(value: 0)),
+ createOp: counterCreateOp(objectId: "counter:score@1000", count: 100),
+ ),
+ objectStateMessage(
+ objectId: "map:profile@1000",
+ map: objectsMap(
+ semantics: .lww,
+ entries: mapEntries([
+ "email": data(string: "alice@example.com"),
+ "nested_counter": data(objectId: "counter:nested@1000"),
+ "prefs": data(objectId: "map:prefs@1000"),
+ ]),
+ ),
+ createOp: mapCreateOp(objectId: "map:profile@1000"),
+ ),
+ objectStateMessage(
+ objectId: "counter:nested@1000",
+ counter: WireObjectsCounter(count: NSNumber(value: 0)),
+ createOp: counterCreateOp(objectId: "counter:nested@1000", count: 5),
+ ),
+ objectStateMessage(
+ objectId: "map:prefs@1000",
+ map: objectsMap(
+ semantics: .lww,
+ entries: mapEntries([
+ "theme": data(string: "dark"),
+ ]),
+ ),
+ createOp: mapCreateOp(objectId: "map:prefs@1000"),
+ ),
+ ]
+ }
+
+ // MARK: - ObjectData builders
+
+ static func data(objectId: String? = nil, boolean: Bool? = nil, bytes: Data? = nil, number: Double? = nil, string: String? = nil, json: JSONObjectOrArray? = nil) -> ProtocolTypes.ObjectData {
+ ProtocolTypes.ObjectData(
+ objectId: objectId,
+ boolean: boolean,
+ bytes: bytes,
+ number: number.map { NSNumber(value: $0) },
+ string: string,
+ json: json,
+ )
+ }
+
+ // MARK: - ObjectState builders
+
+ /// Builds a map of ``ProtocolTypes/ObjectsMapEntry`` from key -> data, all seeded with
+ /// ``poolSerial`` and `tombstone: false`.
+ static func mapEntries(_ entries: [String: ProtocolTypes.ObjectData]) -> [String: ProtocolTypes.ObjectsMapEntry] {
+ entries.mapValues { data in
+ ProtocolTypes.ObjectsMapEntry(tombstone: false, timeserial: poolSerial, data: data, serialTimestamp: nil)
+ }
+ }
+
+ /// Builds an ``ProtocolTypes/ObjectsMap`` — the `{ semantics, entries }` object of the spec's
+ /// `build_object_state`. `clearTimeserial` is omitted unless the spec shows one.
+ static func objectsMap(
+ semantics: ProtocolTypes.ObjectsMapSemantics,
+ entries: [String: ProtocolTypes.ObjectsMapEntry],
+ clearTimeserial: String? = nil,
+ ) -> ProtocolTypes.ObjectsMap {
+ ProtocolTypes.ObjectsMap(semantics: .known(semantics), entries: entries, clearTimeserial: clearTimeserial)
+ }
+
+ /// A MAP_CREATE createOp with empty entries (RTLM16 no-op merge; entries already present in map).
+ static func mapCreateOp(objectId: String) -> ProtocolTypes.ObjectOperation {
+ ProtocolTypes.ObjectOperation(
+ action: .known(.mapCreate),
+ objectId: objectId,
+ mapCreate: ProtocolTypes.MapCreate(semantics: .known(.lww), entries: [:]),
+ )
+ }
+
+ /// A COUNTER_CREATE createOp carrying the initial `count`.
+ static func counterCreateOp(objectId: String, count: Double) -> ProtocolTypes.ObjectOperation {
+ ProtocolTypes.ObjectOperation(
+ action: .known(.counterCreate),
+ objectId: objectId,
+ counterCreate: WireCounterCreate(count: NSNumber(value: count)),
+ )
+ }
+
+ /// Builds an inbound `ObjectMessage` carrying an ``ProtocolTypes/ObjectState`` (UTS
+ /// `build_object_state` composed with `build_object_message_with_state`).
+ static func objectStateMessage(
+ objectId: String,
+ siteTimeserials: [String: String] = ["aaa": poolSerial],
+ tombstone: Bool = false,
+ map: ProtocolTypes.ObjectsMap? = nil,
+ counter: WireObjectsCounter? = nil,
+ createOp: ProtocolTypes.ObjectOperation? = nil,
+ ) -> ProtocolTypes.InboundObjectMessage {
+ ProtocolTypes.InboundObjectMessage(object: ProtocolTypes.ObjectState(
+ objectId: objectId,
+ siteTimeserials: siteTimeserials,
+ tombstone: tombstone,
+ createOp: createOp,
+ map: map,
+ counter: counter,
+ ))
+ }
+
+ // MARK: - Operation ObjectMessage builders
+
+ static func counterInc(objectId: String, number: Double, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage {
+ operationMessage(serial: serial, siteCode: siteCode, operation: ProtocolTypes.ObjectOperation(
+ action: .known(.counterInc),
+ objectId: objectId,
+ counterInc: WireCounterInc(number: NSNumber(value: number)),
+ ))
+ }
+
+ static func mapSet(objectId: String, key: String, value: ProtocolTypes.ObjectData, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage {
+ operationMessage(serial: serial, siteCode: siteCode, operation: ProtocolTypes.ObjectOperation(
+ action: .known(.mapSet),
+ objectId: objectId,
+ mapSet: ProtocolTypes.MapSet(key: key, value: value),
+ ))
+ }
+
+ static func mapRemove(objectId: String, key: String, serial: String, siteCode: String, serialTimestamp: Date? = nil) -> ProtocolTypes.InboundObjectMessage {
+ operationMessage(serial: serial, siteCode: siteCode, serialTimestamp: serialTimestamp, operation: ProtocolTypes.ObjectOperation(
+ action: .known(.mapRemove),
+ objectId: objectId,
+ mapRemove: WireMapRemove(key: key),
+ ))
+ }
+
+ static func mapClear(objectId: String, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage {
+ operationMessage(serial: serial, siteCode: siteCode, operation: ProtocolTypes.ObjectOperation(
+ action: .known(.mapClear),
+ objectId: objectId,
+ mapClear: WireMapClear(),
+ ))
+ }
+
+ static func objectDelete(objectId: String, serial: String, siteCode: String, serialTimestamp: Date? = nil) -> ProtocolTypes.InboundObjectMessage {
+ operationMessage(serial: serial, siteCode: siteCode, serialTimestamp: serialTimestamp, operation: ProtocolTypes.ObjectOperation(
+ action: .known(.objectDelete),
+ objectId: objectId,
+ objectDelete: WireObjectDelete(),
+ ))
+ }
+
+ private static func operationMessage(serial: String, siteCode: String, serialTimestamp: Date? = nil, operation: ProtocolTypes.ObjectOperation) -> ProtocolTypes.InboundObjectMessage {
+ ProtocolTypes.InboundObjectMessage(
+ operation: operation,
+ serial: serial,
+ siteCode: siteCode,
+ serialTimestamp: serialTimestamp,
+ )
+ }
+}
diff --git a/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift b/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift
new file mode 100644
index 00000000..3b60ac7a
--- /dev/null
+++ b/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift
@@ -0,0 +1,130 @@
+import Ably
+import Ably.Private
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// LiveObjects additions to the UTS harness: the `setup_synced_channel` pattern from
+/// `helpers/standard_test_pool.md`.
+extension UTSTestCase {
+ /// Builds a connected client and an objects channel, but does **not** call `get()` — for tests
+ /// that drive `get()` themselves or exercise attach/sync timing. The simulated server responds to
+ /// `ATTACH` with `ATTACHED` (`HAS_OBJECTS`) and, when `sync` is `true`, an `OBJECT_SYNC` carrying
+ /// ``StandardTestPool/objects``; when `autoAck` is `true`, it auto-`ACK`s `OBJECT` publishes.
+ ///
+ /// > Note: the granted channel modes carried by `ATTACHED` aren't modelled (the mock always sends
+ /// > the standard `HAS_OBJECTS` attach); mode-enforcement tests still request the appropriate
+ /// > `modes` on the channel, and trap at the unimplemented API before the granted-mode check runs.
+ func objectsChannel(
+ _ channelName: String,
+ modes: ARTChannelMode = [.objectSubscribe, .objectPublish],
+ echoMessages: Bool = true,
+ siteCode: String? = StandardTestPool.siteCode,
+ gcGracePeriod: Double? = 86_400_000,
+ sync: Bool = true,
+ autoAck: Bool = true,
+ ) -> (client: ARTRealtime, channel: ARTRealtimeChannel, ws: MockWebSocketProvider) {
+ let connections = Captured()
+ let provider = MockWebSocketProvider(
+ onConnectionAttempt: { conn in
+ connections.append(conn)
+ conn.respondWithSuccess()
+ conn.sendToClient(.connected(
+ connectionId: "conn-1",
+ connectionKey: "conn-key-1",
+ siteCode: siteCode,
+ objectsGCGracePeriod: gcGracePeriod,
+ ))
+ },
+ onMessageFromClient: { msg in
+ guard let conn = connections.all.last else { return }
+ switch msg.action {
+ case .attach:
+ let channel = msg.channel ?? channelName
+ conn.sendToClient(.attached(channel: channel, channelSerial: "sync1:", hasObjects: true))
+ if sync {
+ conn.sendToClient(.objectSync(channel: channel, channelSerial: "sync1:", state: StandardTestPool.objects))
+ }
+ case .object:
+ guard autoAck else { break }
+ // `state` isn't surfaced to Swift (see ProtocolMessage), so read it via KVC.
+ let stateCount = (msg.value(forKey: "state") as? [Any])?.count ?? 0
+ let msgSerial = msg.msgSerial?.intValue ?? 0
+ let serials: [String?] = (0 ..< stateCount).map { StandardTestPool.ackSerial(msgSerial, $0) }
+ conn.sendToClient(.ack(msgSerial: msgSerial, count: 1, serials: serials))
+ default:
+ break
+ }
+ },
+ )
+ installMock(provider)
+
+ let client = makeRealtime { options in
+ options.key = "fake:key"
+ options.echoMessages = echoMessages
+ options.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]
+ }
+
+ let channelOptions = ARTRealtimeChannelOptions()
+ channelOptions.modes = modes
+ let channel = client.channels.get(channelName, options: channelOptions)
+ return (client: client, channel: channel, ws: provider)
+ }
+
+ /// Creates a connected client with a synced channel containing the standard test pool (UTS
+ /// `setup_synced_channel`), resolving `root` via `channel.object.get()`.
+ func setupSyncedChannel(_ channelName: String, autoAck: Bool = true, sourceLocation _: SourceLocation = #_sourceLocation) async throws -> (client: ARTRealtime, channel: ARTRealtimeChannel, root: any LiveMapPathObject, ws: MockWebSocketProvider) {
+ let objects = objectsChannel(channelName, autoAck: autoAck)
+ let root = try await objects.channel.object.get()
+ return (client: objects.client, channel: objects.channel, root: root, ws: objects.ws)
+ }
+
+ /// The `operation` of every `OBJECT` message the client has sent, decoded into the internal wire
+ /// type ``WireObjectOperation`` (UTS `captured.flatMap(c => c.state).map(op => op.operation)`).
+ ///
+ /// The captured frames are decoded into the *wire* representation rather than the
+ /// `ProtocolTypes` domain type on purpose: the outbound-only `counterCreateWithObjectId` /
+ /// `mapCreateWithObjectId` fields that the create tests assert on are retained by
+ /// ``WireObjectOperation`` but nil'd out by the `ProtocolTypes.ObjectOperation` inbound
+ /// conversion.
+ func sentObjectOperations(_ ws: MockWebSocketProvider) throws -> [WireObjectOperation] {
+ guard let connection = ws.activeConnection else { return [] }
+ var operations: [WireObjectOperation] = []
+ for frame in connection.sentFrames {
+ guard let root = try? JSONSerialization.jsonObject(with: frame) as? [String: Any],
+ (root["action"] as? Int) == WireAction.object,
+ let state = root["state"] as? [[String: Any]] else { continue }
+ for objectMessage in state {
+ guard let operationData = objectMessage["operation"] as? [String: Any] else { continue }
+ // Decode the operation directly into its wire type. We don't route through
+ // `InboundWireObjectMessage`: these are messages the client *sent*, so the inbound
+ // decoder (and its `DecodingContext`, which drives inbound synthetic-ID rules) would
+ // be the wrong direction. `WireObjectOperation` is `WireObjectCodable`, and its wire
+ // form is direction-agnostic and retains the outbound-only `*WithObjectId` fields.
+ let wireOperation = WireValue.objectFromPluginSupportData(operationData)
+ operations.append(try WireObjectOperation(wireObject: wireOperation))
+ }
+ }
+ return operations
+ }
+
+ /// Delivers an `OBJECT` message carrying `state` to the client through the mock (UTS
+ /// `mock_ws.send_to_client(build_object_message(channel, state))`). `state` is the decoded inbound
+ /// object-message form produced by the ``StandardTestPool`` builders. The channel defaults to
+ /// `"test"` (the name every UTS setup uses).
+ func sendToClient(_ ws: MockWebSocketProvider, channel: String = "test", _ state: [ProtocolTypes.InboundObjectMessage]) {
+ ws.activeConnection?.sendToClient(.object(channel: channel, state: state))
+ }
+}
+
+/// Wire-level `ProtocolMessage.action` codes (`ARTProtocolMessageAction`).
+enum WireAction {
+ static let object = 19
+ static let objectSync = 20
+}
+
+/// Wire-level map semantics code (`ObjectsMapSemantics`, spec OMP2). Used to assert on the
+/// `initialValue` JSON string, which encodes semantics as its raw integer.
+enum WireMapSemantics {
+ static let lww = 0
+}
diff --git a/Tests/UTS/Tests/Internal/PublicObjectMessageTests.swift b/Tests/UTS/Tests/Internal/PublicObjectMessageTests.swift
new file mode 100644
index 00000000..1c4b65e4
--- /dev/null
+++ b/Tests/UTS/Tests/Internal/PublicObjectMessageTests.swift
@@ -0,0 +1,311 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// Construction of the user-facing `ObjectMessage` / `ObjectOperation` from their internal
+/// (`ProtocolTypes`) counterparts (`PAOM3`, `PAOOP3`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/public_object_message.md
+///
+/// The spec's `PublicObjectMessage.fromObjectMessage(source, channel)` /
+/// `PublicObjectOperation.fromObjectOperation(op)` map to
+/// ``ObjectMessage/fromInternalObjectMessage(_:channelName:)`` / ``ObjectOperation/fromInternalObjectOperation(_:)``
+/// (the SDK's public value types are named `ObjectMessage` / `ObjectOperation`; the `channel` object
+/// is represented by its name). The conversion is a skeleton in this target, so every case traps via
+/// `notImplemented()` at runtime; the goal here is a faithful translation that compiles and will pass
+/// once the conversion is implemented.
+@Suite(.serialized)
+struct PublicObjectMessageTests {
+ // MARK: - PAOM3 — ObjectMessage construction
+
+ // UTS: objects/unit/PAOM3/construction-all-fields-0
+ @Test
+ func test_PAOM3_construction_copies_all_fields() {
+ let source = ProtocolTypes.InboundObjectMessage(
+ id: "msg-id-1",
+ clientId: "client-1",
+ connectionId: "conn-1",
+ extras: ["key": "value"],
+ timestamp: Date(timeIntervalSince1970: 1_700_000_000),
+ operation: ProtocolTypes.ObjectOperation(
+ action: .known(.mapSet),
+ objectId: "map:abc@1000",
+ mapSet: ProtocolTypes.MapSet(key: "name", value: StandardTestPool.data(string: "Alice")),
+ ),
+ serial: "01",
+ siteCode: "site1",
+ serialTimestamp: Date(timeIntervalSince1970: 1_700_000_001),
+ )
+
+ let publicMsg = ObjectMessage.fromInternalObjectMessage(source, channelName: "test-channel")
+
+ #expect(publicMsg.id == "msg-id-1")
+ #expect(publicMsg.clientId == "client-1")
+ #expect(publicMsg.connectionId == "conn-1")
+ #expect(publicMsg.timestamp == Date(timeIntervalSince1970: 1_700_000_000))
+ #expect(publicMsg.channel == "test-channel")
+ #expect(publicMsg.serial == "01")
+ #expect(publicMsg.serialTimestamp == Date(timeIntervalSince1970: 1_700_000_001))
+ #expect(publicMsg.siteCode == "site1")
+ #expect(publicMsg.extras == ["key": "value"])
+ #expect(publicMsg.operation.action == .mapSet)
+ #expect(publicMsg.operation.objectId == "map:abc@1000")
+ #expect(publicMsg.operation.mapSet?.key == "name")
+ }
+
+ // UTS: objects/unit/PAOM3/construction-optional-fields-missing-0
+ @Test
+ func test_PAOM3_construction_with_optional_fields_missing() {
+ let source = ProtocolTypes.InboundObjectMessage(
+ operation: ProtocolTypes.ObjectOperation(
+ action: .known(.counterInc),
+ objectId: "counter:abc@1000",
+ counterInc: WireCounterInc(number: NSNumber(value: 5)),
+ ),
+ )
+
+ let publicMsg = ObjectMessage.fromInternalObjectMessage(source, channelName: "my-channel")
+
+ #expect(publicMsg.id == nil)
+ #expect(publicMsg.clientId == nil)
+ #expect(publicMsg.connectionId == nil)
+ #expect(publicMsg.timestamp == nil)
+ #expect(publicMsg.channel == "my-channel")
+ #expect(publicMsg.serial == nil)
+ #expect(publicMsg.serialTimestamp == nil)
+ #expect(publicMsg.siteCode == nil)
+ #expect(publicMsg.extras == nil)
+ #expect(publicMsg.operation.action == .counterInc)
+ }
+
+ // UTS: objects/unit/PAOM3/channel-from-channel-name-0
+ @Test
+ func test_PAOM3_channel_is_set_from_channel_name() {
+ let source = ProtocolTypes.InboundObjectMessage(
+ operation: ProtocolTypes.ObjectOperation(action: .known(.objectDelete), objectId: "counter:abc@1000", objectDelete: WireObjectDelete()),
+ )
+
+ let publicMsg = ObjectMessage.fromInternalObjectMessage(source, channelName: "different-channel-name")
+
+ #expect(publicMsg.channel == "different-channel-name")
+ }
+
+ // MARK: - PAOOP3 — ObjectOperation construction (direct copies)
+
+ // UTS: objects/unit/PAOOP3/map-set-copies-fields-0
+ @Test
+ func test_PAOOP3_map_set_copies_fields_omits_others() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.mapSet),
+ objectId: "map:abc@1000",
+ mapSet: ProtocolTypes.MapSet(key: "color", value: StandardTestPool.data(string: "blue")),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .mapSet)
+ #expect(publicOp.objectId == "map:abc@1000")
+ #expect(publicOp.mapSet?.key == "color")
+ #expect(publicOp.mapSet?.value.string == "blue")
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.mapRemove == nil)
+ #expect(publicOp.counterCreate == nil)
+ #expect(publicOp.counterInc == nil)
+ #expect(publicOp.objectDelete == nil)
+ #expect(publicOp.mapClear == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/map-remove-copies-fields-0
+ @Test
+ func test_PAOOP3_map_remove_copies_fields_omits_others() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.mapRemove),
+ objectId: "map:abc@1000",
+ mapRemove: WireMapRemove(key: "old-key"),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .mapRemove)
+ #expect(publicOp.objectId == "map:abc@1000")
+ #expect(publicOp.mapRemove?.key == "old-key")
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.mapSet == nil)
+ #expect(publicOp.counterCreate == nil)
+ #expect(publicOp.counterInc == nil)
+ #expect(publicOp.objectDelete == nil)
+ #expect(publicOp.mapClear == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/counter-inc-copies-fields-0
+ @Test
+ func test_PAOOP3_counter_inc_copies_fields_omits_others() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.counterInc),
+ objectId: "counter:abc@1000",
+ counterInc: WireCounterInc(number: NSNumber(value: 42)),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .counterInc)
+ #expect(publicOp.objectId == "counter:abc@1000")
+ #expect(publicOp.counterInc?.number == 42)
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.mapSet == nil)
+ #expect(publicOp.mapRemove == nil)
+ #expect(publicOp.counterCreate == nil)
+ #expect(publicOp.objectDelete == nil)
+ #expect(publicOp.mapClear == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/object-delete-copies-fields-0
+ @Test
+ func test_PAOOP3_object_delete_copies_fields_omits_others() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.objectDelete),
+ objectId: "counter:abc@1000",
+ objectDelete: WireObjectDelete(),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .objectDelete)
+ #expect(publicOp.objectId == "counter:abc@1000")
+ #expect(publicOp.objectDelete != nil)
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.mapSet == nil)
+ #expect(publicOp.mapRemove == nil)
+ #expect(publicOp.counterCreate == nil)
+ #expect(publicOp.counterInc == nil)
+ #expect(publicOp.mapClear == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/map-clear-copies-fields-0
+ @Test
+ func test_PAOOP3_map_clear_copies_fields_omits_others() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.mapClear),
+ objectId: "map:abc@1000",
+ mapClear: WireMapClear(),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .mapClear)
+ #expect(publicOp.objectId == "map:abc@1000")
+ #expect(publicOp.mapClear != nil)
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.mapSet == nil)
+ #expect(publicOp.mapRemove == nil)
+ #expect(publicOp.counterCreate == nil)
+ #expect(publicOp.counterInc == nil)
+ #expect(publicOp.objectDelete == nil)
+ }
+
+ // MARK: - PAOOP3b / PAOOP3c — create-payload resolution
+
+ // UTS: objects/unit/PAOOP3/map-create-direct-0
+ @Test
+ func test_PAOOP3b1_map_create_direct() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.mapCreate),
+ objectId: "map:new@2000",
+ mapCreate: ProtocolTypes.MapCreate(
+ semantics: .known(.lww),
+ entries: ["key1": ProtocolTypes.ObjectsMapEntry(data: StandardTestPool.data(string: "val1"))],
+ ),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .mapCreate)
+ #expect(publicOp.objectId == "map:new@2000")
+ #expect(publicOp.mapCreate?.semantics == .lww)
+ #expect(publicOp.mapCreate?.entries["key1"]?.data?.string == "val1")
+ #expect(publicOp.counterCreate == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/map-create-from-with-object-id-0
+ @Test
+ func test_PAOOP3b2_map_create_resolved_from_withObjectId() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.mapCreate),
+ objectId: "map:derived@3000",
+ mapCreateWithObjectId: ProtocolTypes.MapCreateWithObjectId(
+ initialValue: #"{"map":{"semantics":0,"entries":{}}}"#,
+ nonce: "nonce-1",
+ derivedFrom: ProtocolTypes.MapCreate(
+ semantics: .known(.lww),
+ entries: ["x": ProtocolTypes.ObjectsMapEntry(data: StandardTestPool.data(number: 10))],
+ ),
+ ),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .mapCreate)
+ #expect(publicOp.objectId == "map:derived@3000")
+ #expect(publicOp.mapCreate?.semantics == .lww)
+ #expect(publicOp.mapCreate?.entries["x"]?.data?.number == 10)
+ #expect(publicOp.counterCreate == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/counter-create-from-with-object-id-0
+ @Test
+ func test_PAOOP3c2_counter_create_resolved_from_withObjectId() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.counterCreate),
+ objectId: "counter:derived@3000",
+ counterCreateWithObjectId: ProtocolTypes.CounterCreateWithObjectId(
+ initialValue: #"{"counter":{"count":100}}"#,
+ nonce: "nonce-2",
+ derivedFrom: WireCounterCreate(count: NSNumber(value: 100)),
+ ),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .counterCreate)
+ #expect(publicOp.objectId == "counter:derived@3000")
+ #expect(publicOp.counterCreate?.count == 100)
+ #expect(publicOp.mapCreate == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/create-payloads-omitted-0
+ @Test
+ func test_PAOOP3b3_c3_create_payloads_omitted_when_absent() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.mapSet),
+ objectId: "map:abc@1000",
+ mapSet: ProtocolTypes.MapSet(key: "k", value: StandardTestPool.data(string: "v")),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.counterCreate == nil)
+ }
+
+ // UTS: objects/unit/PAOOP3/only-relevant-field-per-action-0
+ @Test
+ func test_PAOOP3_only_relevant_field_present_per_action() {
+ let source = ProtocolTypes.ObjectOperation(
+ action: .known(.counterCreate),
+ objectId: "counter:new@2000",
+ counterCreate: WireCounterCreate(count: NSNumber(value: 50)),
+ )
+
+ let publicOp = ObjectOperation.fromInternalObjectOperation(source)
+
+ #expect(publicOp.action == .counterCreate)
+ #expect(publicOp.objectId == "counter:new@2000")
+ #expect(publicOp.counterCreate?.count == 50)
+ #expect(publicOp.mapCreate == nil)
+ #expect(publicOp.mapSet == nil)
+ #expect(publicOp.mapRemove == nil)
+ #expect(publicOp.counterInc == nil)
+ #expect(publicOp.objectDelete == nil)
+ #expect(publicOp.mapClear == nil)
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/InstanceTests.swift b/Tests/UTS/Tests/Path-Based/InstanceTests.swift
new file mode 100644
index 00000000..4f8d9a47
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/InstanceTests.swift
@@ -0,0 +1,267 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// Instance — identity-bound references (`RTINS1`–`RTINS16`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/instance.md
+///
+/// Swift models `Instance` as an enum (`.liveMap`/`.liveCounter`/`.primitive`) whose payloads carry
+/// the type-specific members (`id`, `value`, `get`, `set`, `subscribe`, …). The spec's loosely-typed
+/// `instance.value()` / `instance.get()` therefore map to extracting the payload via the
+/// `asLiveMap()` / `asLiveCounter()` / `asPrimitive()`.
+@Suite(.serialized)
+final class InstanceTests: UTSTestCase {
+ // MARK: - RTINS3 — id
+
+ // UTS: objects/unit/RTINS3/id-returns-objectid-0
+ @Test
+ func test_RTINS3_id_returns_objectId() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ #expect(try root.get(key: "score").instance()?.id() == "counter:score@1000") // RTINS3a
+ #expect(try root.get(key: "profile").instance()?.id() == "map:profile@1000")
+ }
+
+ // MARK: - RTINS4 — value
+
+ // UTS: objects/unit/RTINS4/value-counter-0
+ @Test
+ func test_RTINS4_value_returns_counter_number_or_null_for_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ #expect(try root.get(key: "score").instance()?.asLiveCounter().value == 100) // RTINS4b
+ // RTINS4d: a map has no numeric value. Swift exposes no `value` on `LiveMapInstance`, so
+ // "value == null" is represented as the instance being a map.
+ #expect(try root.instance()?.type == .liveMap)
+ }
+
+ // MARK: - RTINS5 — get
+
+ // UTS: objects/unit/RTINS5/get-wraps-entry-0
+ @Test
+ func test_RTINS5_get_returns_Instance_wrapping_entry_value() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ let rootInstance = try root.instance()?.asLiveMap()
+
+ let nameInstance = try rootInstance?.get(key: "name")
+ #expect(try nameInstance?.asPrimitive().stringValue == "Alice") // RTINS5c
+
+ let scoreInstance = try rootInstance?.get(key: "score")
+ #expect(scoreInstance?.id() == "counter:score@1000")
+
+ #expect(try rootInstance?.get(key: "nonexistent") == nil)
+ }
+
+ // MARK: - RTINS6 — entries
+
+ // UTS: objects/unit/RTINS6/entries-yields-instances-0
+ @Test
+ func test_RTINS6_entries_returns_array_of_key_instance_pairs() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ let rootInstance = try root.instance()?.asLiveMap()
+
+ var entries: [String: Instance] = [:]
+ for (key, instance) in try #require(try rootInstance?.entries()) {
+ entries[key] = instance
+ }
+ #expect(entries.count == 7) // RTINS6b
+ #expect(try entries["name"]?.asPrimitive().stringValue == "Alice")
+ }
+
+ // MARK: - RTINS9 — size
+
+ // UTS: objects/unit/RTINS9/size-0
+ @Test
+ func test_RTINS9_size_returns_non_tombstoned_count() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ #expect(try root.instance()?.asLiveMap().size == 7) // RTINS9b
+ // RTINS9c: a counter has no size. Swift exposes no `size` on `LiveCounterInstance`, so
+ // "size == null" is represented as the instance being a counter.
+ #expect(try root.get(key: "score").instance()?.type == .liveCounter)
+ }
+
+ // MARK: - RTINS10 — compact
+
+ // UTS: objects/unit/RTINS10/compact-0
+ @Test
+ func test_RTINS10_compact_recursively_compacts() async throws {
+ // DEVIATION (RTINS10): only `compactJson()` is exposed publicly (not `compact()`), so this
+ // asserts on the JSON form. See deviations.md.
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let result = try root.instance()?.compactJson().objectValue
+ #expect(result?["name"]?.stringValue == "Alice")
+ #expect(result?["score"]?.numberValue == 100)
+ #expect(result?["profile"]?.objectValue?["email"]?.stringValue == "alice@example.com")
+ }
+
+ // MARK: - RTINS12 — set
+
+ // UTS: objects/unit/RTINS12/set-delegates-0
+ @Test
+ func test_RTINS12_set_delegates_to_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.instance()?.asLiveMap().set(key: "name", value: "Bob")
+ #expect(try root.get(key: "name").asPrimitive().stringValue == "Bob")
+ }
+
+ // UTS: objects/unit/RTINS12d/set-non-map-throws-0
+ @Test
+ func test_RTINS12d_set_on_non_map_throws_92007() throws {
+ // DEVIATION (RTINS12d): `set` exists only on `LiveMapInstance`, so it cannot be *called* on a
+ // counter payload — the "throws 92007" path is compile-time-unreachable. See deviations.md.
+ }
+
+ // MARK: - RTINS13 — remove
+
+ // UTS: objects/unit/RTINS13/remove-delegates-0
+ @Test
+ func test_RTINS13_remove_delegates_to_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.instance()?.asLiveMap().remove(key: "name")
+ #expect(try root.get(key: "name").asPrimitive().value() == nil)
+ }
+
+ // MARK: - RTINS14 — increment
+
+ // UTS: objects/unit/RTINS14/increment-delegates-0
+ @Test
+ func test_RTINS14_increment_delegates_to_counter() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").instance()?.asLiveCounter().increment(amount: 25)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 125)
+ }
+
+ // UTS: objects/unit/RTINS14a/increment-default-0
+ @Test
+ func test_RTINS14a_increment_defaults_to_1() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").instance()?.asLiveCounter().increment()
+ #expect(try root.get(key: "score").asLiveCounter().value() == 101)
+ }
+
+ // UTS: objects/unit/RTINS14d/increment-non-counter-throws-0
+ @Test
+ func test_RTINS14d_increment_on_non_counter_throws_92007() throws {
+ // DEVIATION (RTINS14d): `increment` exists only on `LiveCounterInstance`, so it cannot be
+ // called on a map payload — the "throws 92007" path is compile-time-unreachable. See deviations.md.
+ }
+
+ // MARK: - RTINS15 — decrement
+
+ // UTS: objects/unit/RTINS15/decrement-delegates-0
+ @Test
+ func test_RTINS15_decrement_delegates_to_counter() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").instance()?.asLiveCounter().decrement(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 90)
+ }
+
+ // UTS: objects/unit/RTINS15a/decrement-default-0
+ @Test
+ func test_RTINS15a_decrement_defaults_to_1() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").instance()?.asLiveCounter().decrement()
+ #expect(try root.get(key: "score").asLiveCounter().value() == 99)
+ }
+
+ // UTS: objects/unit/RTINS15d/decrement-non-counter-throws-0 — covered by the RTINS14d deviation
+ // (decrement, like increment, exists only on `LiveCounterInstance`). See deviations.md.
+
+ // MARK: - RTINS16 — subscribe
+
+ // UTS: objects/unit/RTINS16/subscribe-receives-events-0
+ @Test
+ func test_RTINS16_subscribe_receives_events() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let counter = try root.get(key: "score").instance()?.asLiveCounter()
+ let events = Captured()
+ let sub = try counter?.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ _ = sub // RTINS16f
+ #expect(events.count == 1)
+ #expect(events.first?.object.id() == "counter:score@1000") // RTINS16e1
+ }
+
+ // UTS: objects/unit/RTINS16c/subscribe-primitive-throws-0
+ @Test
+ func test_RTINS16c_subscribe_on_primitive_throws_92007() throws {
+ // DEVIATION (RTINS16c): `subscribe` exists only on the `LiveMapInstance` / `LiveCounterInstance`
+ // payloads, not `PrimitiveInstance`, so it cannot be called on a primitive — the "throws 92007"
+ // path is compile-time-unreachable. See deviations.md.
+ }
+
+ // UTS: objects/unit/RTINS16e2/subscription-event-message-0
+ @Test
+ func test_RTINS16e2_event_contains_public_object_message() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let rootInstance = try root.instance()?.asLiveMap()
+ let events = Captured()
+ _ = try rootInstance?.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ let event = try #require(events.first)
+ #expect(event.object.id() == "root") // RTINS16e1
+ let message = try #require(event.message) // RTINS16e2
+ #expect(message.channel == "test")
+ #expect(message.operation.action == .mapSet)
+ #expect(message.operation.objectId == "root")
+ #expect(message.operation.mapSet?.key == "name")
+ }
+
+ // UTS: objects/unit/RTINS16f/subscribe-returns-subscription-0
+ @Test
+ func test_RTINS16f_unsubscribe_deregisters() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let counter = try root.get(key: "score").instance()?.asLiveCounter()
+ let events = Captured()
+ let sub = try counter?.subscribe(listener: { events.append($0) })
+ sub?.unsubscribe()
+
+ // Quiescence control: a second, still-subscribed listener on the same counter instance.
+ let control = Captured()
+ _ = try counter?.subscribe(listener: { control.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("control fired") { control.count >= 1 }
+
+ #expect(events.count == 0)
+ }
+
+ // UTS: objects/unit/RTINS16g/subscription-follows-identity-0
+ @Test
+ func test_RTINS16g_subscription_follows_identity_not_path() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let counter = try root.get(key: "score").instance()?.asLiveCounter()
+ let events = Captured()
+ _ = try counter?.subscribe(listener: { events.append($0) })
+
+ // Repoint "score" to a new object, then mutate the ORIGINAL counter:score@1000.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: "100", siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ #expect(events.count >= 1)
+ // Identity-based: the event carries the original object regardless of the "score" repoint.
+ #expect(events.first?.object.id() == "counter:score@1000")
+ }
+
+ // UTS: objects/unit/RTINS16h/subscribe-no-side-effects-0
+ @Test
+ func test_RTINS16h_subscribe_has_no_side_effects() async throws {
+ let (_, channel, root, _) = try await setupSyncedChannel("test")
+ let counter = try root.get(key: "score").instance()?.asLiveCounter()
+ let stateBefore = channel.state
+
+ _ = try counter?.subscribe(listener: { _ in })
+
+ #expect(channel.state == stateBefore)
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/LiveObjectSubscribeTests.swift b/Tests/UTS/Tests/Path-Based/LiveObjectSubscribeTests.swift
new file mode 100644
index 00000000..84f897be
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/LiveObjectSubscribeTests.swift
@@ -0,0 +1,221 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// LiveObject subscribe via `Instance#subscribe` (`RTLO4b`, `RTINS16`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/live_object_subscribe.md
+///
+/// The spec's `root.get("score").instance()` maps to `root.get(key: "score").instance()`, whose
+/// payload is extracted with `asLiveCounter()` / `asLiveMap()` (see ``Instance`` and
+/// `Instance+Testing`). Listeners are captured with the thread-safe ``Captured`` and deliveries are
+/// awaited with `poll(...)`; inbound operations are injected as `OBJECT` messages through the mock.
+/// The "negative-assertion quiescence" pattern from `standard_test_pool.md` is preserved.
+@Suite(.serialized)
+final class LiveObjectSubscribeTests: UTSTestCase {
+ // MARK: - RTLO4b — subscribe registers listener
+
+ // UTS: objects/unit/RTLO4b/subscribe-receives-updates-0
+ @Test
+ func test_RTLO4b_subscribe_registers_listener_for_data_updates() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let instance = try root.get(key: "score").instance()?.asLiveCounter()
+ let sub = try #require(instance).subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+
+ _ = sub // RTLO4b7: returns a Subscription
+ #expect(updates.count == 1)
+ }
+
+ // MARK: - RTLO4b7 — Subscription
+
+ // UTS: objects/unit/RTLO4b7/subscribe-returns-subscription-0
+ @Test
+ func test_RTLO4b7_subscribe_returns_Subscription_with_unsubscribe() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ let instance = try root.get(key: "score").instance()?.asLiveCounter()
+
+ let sub = try #require(instance).subscribe(listener: { _ in })
+
+ // RTLO4b7: `sub` conforms to `Subscription` (has `unsubscribe()`).
+ _ = (sub as any Subscription).unsubscribe
+ }
+
+ // UTS: objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0
+ @Test
+ func test_RTLO4b7_unsubscribe_stops_delivery() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let control = Captured()
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ let sub = try instance.subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "01", siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+
+ sub.unsubscribe()
+
+ // Quiescence: a still-registered control listener that WILL fire on the same dispatch.
+ _ = try instance.subscribe(listener: { control.append($0) })
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: "02", siteCode: "remote")])
+ poll("control fired") { control.count >= 1 }
+
+ #expect(updates.count == 1)
+ }
+
+ // UTS: objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0
+ @Test
+ func test_RTLO4b7_unsubscribe_is_idempotent() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ let sub = try instance.subscribe(listener: { _ in })
+
+ sub.unsubscribe()
+ sub.unsubscribe() // SUB2b: calling more than once is a no-op (must not throw)
+ }
+
+ // MARK: - RTLO4b4c1 — noop update
+
+ // UTS: objects/unit/RTLO4b4c1/noop-no-trigger-0
+ @Test
+ func test_RTLO4b4c1_noop_update_does_not_trigger_listener() async throws {
+ // DEVIATION (RTLO4b4c1 / RTLC9h): the noop stimulus is a COUNTER_INC whose `counterInc` has no
+ // `number` field. Swift's `WireCounterInc.number` is a non-optional `NSNumber`, so an empty
+ // `counterInc: {}` isn't constructible — the RTLC9h noop branch can't be exercised through the
+ // typed builder. The test keeps the spec point and asserts that two real increments produce
+ // exactly two updates (the noop, were it sendable, would sit between them and add nothing). See
+ // deviations.md.
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ try instance.subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "01", siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 3, serial: "03", siteCode: "remote")])
+ poll("updates.count >= 2") { updates.count >= 2 }
+
+ #expect(updates.count == 2)
+ }
+
+ // MARK: - RTLO4b6 — no side effects
+
+ // UTS: objects/unit/RTLO4b6/subscribe-no-side-effects-0
+ @Test
+ func test_RTLO4b6_subscribe_has_no_side_effects() async throws {
+ let (_, channel, root, _) = try await setupSyncedChannel("test")
+ let stateBefore = channel.state
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+
+ try instance.subscribe(listener: { _ in })
+
+ #expect(channel.state == stateBefore)
+ }
+
+ // MARK: - RTLO4b — map update
+
+ // UTS: objects/unit/RTLO4b/subscribe-map-update-0
+ @Test
+ func test_RTLO4b_subscribe_on_map_receives_LiveMapUpdate() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let instance = try #require(try root.instance()?.asLiveMap())
+ try instance.subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+
+ #expect(updates.count == 1)
+ }
+
+ // MARK: - RTLO4b4c3c — tombstone deregisters listeners
+
+ // UTS: objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0
+ @Test
+ func test_RTLO4b4c3c_tombstone_update_deregisters_all_listeners() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updatesA = Captured()
+ let updatesB = Captured()
+ let control = Captured()
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ try instance.subscribe(listener: { updatesA.append($0) })
+ try instance.subscribe(listener: { updatesB.append($0) })
+
+ // OBJECT_DELETE → tombstone update; both listeners receive it before deregistration.
+ sendToClient(ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "50", siteCode: "remote")])
+ poll("updatesA fired") { updatesA.count >= 1 }
+ poll("updatesB fired") { updatesB.count >= 1 }
+
+ #expect(updatesA.count == 1)
+ #expect(updatesA.first?.message?.operation.action == .objectDelete)
+ #expect(updatesB.count == 1)
+ #expect(updatesB.first?.message?.operation.action == .objectDelete)
+
+ // A tombstoned object ignores further ops (RTLC7e), so the control listener must be on a
+ // SEPARATE live object (map:profile@1000) to serve as the quiescence barrier.
+ let controlInstance = try #require(try root.get(key: "profile").instance()?.asLiveMap())
+ try controlInstance.subscribe(listener: { control.append($0) })
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 3, serial: "51", siteCode: "remote")])
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:profile@1000", key: "quiescence_probe", value: StandardTestPool.data(string: "x"), serial: "52", siteCode: "remote")])
+ poll("control fired") { control.count >= 1 }
+
+ #expect(updatesA.count == 1)
+ #expect(updatesB.count == 1)
+ }
+
+ // MARK: - RTLO4b4d — event.message populated
+
+ // UTS: objects/unit/RTLO4b4d/update-has-object-message-0
+ @Test
+ func test_RTLO4b4d_event_message_populated_from_source_ObjectMessage() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ try instance.subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+
+ #expect(updates.count == 1)
+ let message = try #require(updates.first?.message) // RTINS16e
+ #expect(message.serial == "99")
+ #expect(message.siteCode == "remote")
+ #expect(message.operation.action == .counterInc)
+ #expect(message.operation.objectId == "counter:score@1000")
+ }
+
+ // MARK: - RTLO4b4e — tombstone identified by OBJECT_DELETE
+
+ // UTS: objects/unit/RTLO4b4e/tombstone-flag-true-0
+ @Test
+ func test_RTLO4b4e_tombstone_update_carries_OBJECT_DELETE_action() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ try instance.subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "50", siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+
+ #expect(updates.count == 1)
+ #expect(updates.first?.message?.operation.action == .objectDelete)
+ }
+
+ // UTS: objects/unit/RTLO4b4e/tombstone-flag-false-0
+ @Test
+ func test_RTLO4b4e_normal_update_carries_non_tombstone_action() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let updates = Captured()
+ let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter())
+ try instance.subscribe(listener: { updates.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("updates.count >= 1") { updates.count >= 1 }
+
+ #expect(updates.count == 1)
+ #expect(updates.first?.message?.operation.action == .counterInc)
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/PathObjectMutationsTests.swift b/Tests/UTS/Tests/Path-Based/PathObjectMutationsTests.swift
new file mode 100644
index 00000000..86a00a78
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/PathObjectMutationsTests.swift
@@ -0,0 +1,160 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// PathObject write operations (`RTPO15`–`RTPO18`, `RTPO3c2`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/path_object_mutations.md
+///
+/// Same binding conventions as `PathObjectTests` (chained navigation via `asLiveMap()`, typed
+/// `value()` accessors). Mutations live on the typed refinements: `set`/`remove` on
+/// ``LiveMapPathObject``, `increment`/`decrement` on ``LiveCounterPathObject``.
+@Suite(.serialized)
+final class PathObjectMutationsTests: UTSTestCase {
+ // MARK: - RTPO15 — set
+
+ // UTS: objects/unit/RTPO15/set-delegates-to-map-0
+ @Test
+ func test_RTPO15_set_delegates_to_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.set(key: "name", value: "Bob")
+ #expect(try root.get(key: "name").asPrimitive().stringValue == "Bob")
+ }
+
+ // UTS: objects/unit/RTPO15/set-nested-path-0
+ @Test
+ func test_RTPO15_set_on_nested_path() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.get(key: "profile").asLiveMap().set(key: "email", value: "bob@example.com")
+ #expect(try root.get(key: "profile").asLiveMap().get(key: "email").asPrimitive().stringValue == "bob@example.com")
+ }
+
+ // UTS: objects/unit/RTPO15d/set-non-map-throws-0
+ @Test
+ func test_RTPO15d_set_on_non_map_throws_92007() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ // "score" is a counter, not a map (RTPO15e).
+ await expectError(code: 92007) {
+ try await root.get(key: "score").asLiveMap().set(key: "key", value: "value")
+ }
+ }
+
+ // MARK: - RTPO16 — remove
+
+ // UTS: objects/unit/RTPO16/remove-delegates-to-map-0
+ @Test
+ func test_RTPO16_remove_delegates_to_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.remove(key: "name")
+ #expect(try root.get(key: "name").asPrimitive().value() == nil)
+ }
+
+ // UTS: objects/unit/RTPO16d/remove-non-map-throws-0
+ @Test
+ func test_RTPO16d_remove_on_non_map_throws_92007() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ await expectError(code: 92007) {
+ try await root.get(key: "score").asLiveMap().remove(key: "key")
+ }
+ }
+
+ // MARK: - RTPO17 — increment
+
+ // UTS: objects/unit/RTPO17/increment-delegates-to-counter-0
+ @Test
+ func test_RTPO17_increment_delegates_to_counter() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.get(key: "score").asLiveCounter().increment(amount: 25)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 125)
+ }
+
+ // UTS: objects/unit/RTPO17/increment-default-amount-0
+ @Test
+ func test_RTPO17_increment_defaults_to_1() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.get(key: "score").asLiveCounter().increment() // RTPO17a1: defaults to 1
+ #expect(try root.get(key: "score").asLiveCounter().value() == 101)
+ }
+
+ // UTS: objects/unit/RTPO17d/increment-non-counter-throws-0
+ @Test
+ func test_RTPO17d_increment_on_non_counter_throws_92007() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ // root is a map, not a counter (RTPO17e).
+ await expectError(code: 92007) {
+ try await root.asLiveCounter().increment(amount: 5)
+ }
+ }
+
+ // MARK: - RTPO18 — decrement
+
+ // UTS: objects/unit/RTPO18/decrement-delegates-to-counter-0
+ @Test
+ func test_RTPO18_decrement_delegates_to_counter() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.get(key: "score").asLiveCounter().decrement(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 90)
+ }
+
+ // UTS: objects/unit/RTPO18/decrement-default-amount-0
+ @Test
+ func test_RTPO18_decrement_defaults_to_1() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ try await root.get(key: "score").asLiveCounter().decrement() // RTPO18a1: defaults to 1
+ #expect(try root.get(key: "score").asLiveCounter().value() == 99)
+ }
+
+ // UTS: objects/unit/RTPO18d/decrement-non-counter-throws-0
+ @Test
+ func test_RTPO18d_decrement_on_non_counter_throws_92007() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ await expectError(code: 92007) {
+ try await root.asLiveCounter().decrement(amount: 5)
+ }
+ }
+
+ // MARK: - RTPO3c2 — write on unresolvable path
+
+ // UTS: objects/unit/RTPO3c2/set-unresolvable-throws-0
+ @Test
+ func test_RTPO3c2_set_on_unresolvable_path_throws_92005() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ await expectError(code: 92005, statusCode: 400) {
+ try await root.get(key: "nonexistent").asLiveMap().get(key: "deep").asLiveMap().set(key: "key", value: "value")
+ }
+ }
+
+ // UTS: objects/unit/RTPO3c2/increment-unresolvable-throws-0
+ @Test
+ func test_RTPO3c2_increment_on_unresolvable_path_throws_92005() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ await expectError(code: 92005, statusCode: 400) {
+ try await root.get(key: "nonexistent").asLiveCounter().increment(amount: 5)
+ }
+ }
+}
+
+private extension PathObjectMutationsTests {
+ /// Asserts `operation` throws an `ARTErrorInfo` with the given `code` (and optional `statusCode`)
+ /// — the UTS `AWAIT op FAILS WITH error` / `ASSERT error.code == …` pattern.
+ func expectError(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () async throws -> Void) async {
+ do {
+ try await operation()
+ Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation)
+ } catch let error as ARTErrorInfo {
+ #expect(error.code == code, sourceLocation: sourceLocation)
+ if let statusCode {
+ #expect(error.statusCode == statusCode, sourceLocation: sourceLocation)
+ }
+ } catch {
+ Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation)
+ }
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/PathObjectSubscribeTests.swift b/Tests/UTS/Tests/Path-Based/PathObjectSubscribeTests.swift
new file mode 100644
index 00000000..d4523145
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/PathObjectSubscribeTests.swift
@@ -0,0 +1,426 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// PathObject subscriptions (`RTPO19`, `RTO24`, `RTO25`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/path_object_subscribe.md
+///
+/// Listeners are captured with the thread-safe ``Captured`` (the callback is `@Sendable`), and
+/// deliveries are awaited with `poll(...)`. Inbound operations are injected as `OBJECT` messages
+/// through the mock. The "negative-assertion quiescence" pattern from `standard_test_pool.md` is
+/// preserved: a control listener that *does* fire is awaited before asserting a count is unchanged.
+@Suite(.serialized)
+final class PathObjectSubscribeTests: UTSTestCase {
+ // MARK: - RTPO19 — basic subscribe / event delivery
+
+ // UTS: objects/unit/RTPO19/subscribe-receives-events-0
+ @Test
+ func test_RTPO19_subscribe_returns_Subscription_and_receives_events() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ let sub = try root.get(key: "score").subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ _ = sub // RTPO19d: returns a Subscription
+ #expect(events.count == 1)
+ let event = try #require(events.first)
+ #expect(event.object.path == "score") // RTPO19e1
+ let message = try #require(event.message) // RTPO19e2
+ #expect(message.serial == "99")
+ #expect(message.siteCode == "remote")
+ #expect(message.operation.action == .counterInc)
+ #expect(message.channel == "test")
+ }
+
+ // UTS: objects/unit/RTPO19b/subscribe-precondition-detached-0
+ @Test
+ func test_RTPO19b_subscribe_precondition_detached_throws_90001() async throws {
+ let (_, channel, root, _) = try await setupSyncedChannel("test")
+
+ // RTO25b: on a DETACHED (or FAILED) channel the access API throws 90001. (The mock doesn't
+ // model DETACH responses; this documents the precondition — the setup traps at get() first.)
+ channel.detach()
+ awaitChannelState(channel, .detached)
+
+ expectError(code: 90001, statusCode: 400) {
+ _ = try root.subscribe(listener: { _ in })
+ }
+ }
+
+ // UTS: objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0
+ @Test
+ func test_RTPO19c1a_subscribe_non_positive_depth_throws_40003() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ expectError(code: 40003) {
+ _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 0), listener: { _ in })
+ }
+ }
+
+ // UTS: objects/unit/RTPO19c1a/subscribe-negative-depth-throws-0
+ @Test
+ func test_RTPO19c1a_subscribe_negative_depth_throws_40003() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ expectError(code: 40003) {
+ _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: -1), listener: { _ in })
+ }
+ }
+
+ // MARK: - RTPO19c1 — depth filtering
+
+ // UTS: objects/unit/RTPO19c1/subscribe-depth-1-self-only-0
+ @Test
+ func test_RTPO19c1_depth_1_receives_self_only() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 1), listener: { events.append($0) })
+ // Quiescence control: unlimited-depth root listener that covers the out-of-scope child path.
+ let control = Captured()
+ _ = try root.subscribe(listener: { control.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ let controlBefore = control.count
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")])
+ poll("control fired") { control.count > controlBefore }
+
+ #expect(events.count == 1) // depth 1: the out-of-scope child update did not fire
+ }
+
+ // UTS: objects/unit/RTPO19c1/subscribe-depth-2-children-0
+ @Test
+ func test_RTPO19c1_depth_2_receives_self_and_children() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 2), listener: { events.append($0) })
+ let control = Captured()
+ _ = try root.subscribe(listener: { control.append($0) })
+
+ // Self event (root map update) — covered at depth 2.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ // Child event (root["score"]) — relativeDepth 2 <= 2, covered.
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")])
+ poll("events.count >= 2") { events.count >= 2 }
+
+ // Grandchild event (root["profile"]["nested_counter"]) — relativeDepth 3 > 2, NOT covered.
+ let controlBefore = control.count
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:nested@1000", number: 1, serial: "101", siteCode: "remote")])
+ poll("control fired") { control.count > controlBefore }
+
+ #expect(events.count == 2)
+ }
+
+ // UTS: objects/unit/RTPO19c1/subscribe-unlimited-depth-0
+ @Test
+ func test_RTPO19c1_no_depth_receives_all_descendants() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")])
+ poll("events.count >= 2") { events.count >= 2 }
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:prefs@1000", key: "theme", value: StandardTestPool.data(string: "light"), serial: StandardTestPool.remoteSerial(1), siteCode: "remote")])
+ poll("events.count >= 3") { events.count >= 3 }
+
+ #expect(events.count >= 3)
+ }
+
+ // MARK: - RTPO19d — unsubscribe
+
+ // UTS: objects/unit/RTPO19d/subscribe-returns-subscription-0
+ @Test
+ func test_RTPO19d_unsubscribe_deregisters_listener() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ let sub = try root.get(key: "score").subscribe(listener: { events.append($0) })
+ // Quiescence control: a separate, still-subscribed listener that WILL fire.
+ let control = Captured()
+ _ = try root.get(key: "score").subscribe(listener: { control.append($0) })
+
+ sub.unsubscribe()
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("control fired") { control.count >= 1 }
+
+ #expect(events.count == 0)
+ }
+
+ // MARK: - RTPO19e — event contents
+
+ // UTS: objects/unit/RTPO19e1/event-path-object-correct-0
+ @Test
+ func test_RTPO19e1_event_provides_correct_PathObject() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ let event = try #require(events.first)
+ #expect(event.object.path == "score")
+ #expect(try event.object.asLiveCounter().value() == 107)
+ }
+
+ // UTS: objects/unit/RTPO19e2/event-message-delivery-0
+ @Test
+ func test_RTPO19e2_event_delivers_public_object_message() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.get(key: "score").subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 42, serial: "serial-1", siteCode: "site-a")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ let message = try #require(events.first?.message)
+ #expect(message.channel == "test")
+ #expect(message.serial == "serial-1")
+ #expect(message.siteCode == "site-a")
+ #expect(message.operation.action == .counterInc)
+ #expect(message.operation.objectId == "counter:score@1000")
+ #expect(message.operation.counterInc?.number == 42)
+ }
+
+ // UTS: objects/unit/RTPO19e2/event-message-omitted-no-operation-0
+ @Test
+ func test_RTPO19e2_event_omits_message_when_no_operation() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(listener: { events.append($0) })
+
+ // A sync-triggered update (replaceData, RTLC6) has no `operation`, so its event has no message.
+ ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: [
+ StandardTestPool.objectStateMessage(
+ objectId: "counter:score@1000",
+ siteTimeserials: ["aaa": "t:1"],
+ counter: WireObjectsCounter(count: NSNumber(value: 0)),
+ createOp: StandardTestPool.counterCreateOp(objectId: "counter:score@1000", count: 200),
+ ),
+ ]))
+ poll("events.count >= 1") { events.count >= 1 }
+
+ for event in events.all {
+ #expect(event.message == nil)
+ }
+ }
+
+ // MARK: - RTPO19f — follows path not identity
+
+ // UTS: objects/unit/RTPO19f/subscribe-follows-path-0
+ @Test
+ func test_RTPO19f_subscribe_follows_path_not_identity() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.get(key: "score").subscribe(listener: { events.append($0) })
+
+ // Replace the counter at "score" with a new object, then mutate the new one.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:new@2000", number: 10, serial: "100", siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ // Subscription follows the path, so it delivers events for the new object at "score".
+ #expect(events.all.contains { $0.object.path == "score" })
+ }
+
+ // MARK: - RTPO19g — no side effects
+
+ // UTS: objects/unit/RTPO19g/subscribe-no-side-effects-0
+ @Test
+ func test_RTPO19g_subscribe_has_no_side_effects() async throws {
+ let (_, channel, root, _) = try await setupSyncedChannel("test")
+ let stateBefore = channel.state
+
+ _ = try root.get(key: "score").subscribe(listener: { _ in })
+
+ #expect(channel.state == stateBefore)
+ }
+
+ // MARK: - RTPO19 — primitive path, MAP_CLEAR, bubbling
+
+ // UTS: objects/unit/RTPO19/subscribe-primitive-path-0
+ @Test
+ func test_RTPO19_subscribe_on_primitive_path_receives_change_events() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.get(key: "name").subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ #expect(events.count == 1)
+ #expect(events.first?.object.path == "name")
+ }
+
+ // UTS: objects/unit/RTPO19/map-clear-triggers-child-events-0
+ @Test
+ func test_RTPO19_map_clear_triggers_child_events() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapClear(objectId: "root", serial: "99", siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ #expect(events.count >= 1)
+ }
+
+ // UTS: objects/unit/RTPO19/child-events-bubble-0
+ @Test
+ func test_RTPO19_child_events_bubble_up_to_parent() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.get(key: "profile").subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:profile@1000", key: "email", value: StandardTestPool.data(string: "bob@example.com"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:nested@1000", number: 3, serial: "100", siteCode: "remote")])
+ poll("events.count >= 2") { events.count >= 2 }
+
+ #expect(events.count >= 2)
+ }
+
+ // MARK: - RTO24 — dispatch rules
+
+ // UTS: objects/unit/RTO24c1/depth-filtering-formula-0
+ @Test
+ func test_RTO24c1_depth_filtering_formula() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+
+ // Seed a grandchild under profile.prefs so the grandchild stimulus can be a single-candidate
+ // COUNTER_INC (RTO6 zero-value-creates counter:deep@3000). Sent before subscribing.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:prefs@1000", key: "deep", value: StandardTestPool.data(objectId: "counter:deep@3000"), serial: "50", siteCode: "remote")])
+
+ let events = Captured()
+ _ = try root.get(key: "profile").subscribe(options: PathObjectSubscriptionOptions(depth: 2), listener: { events.append($0) })
+ let control = Captured()
+ _ = try root.subscribe(listener: { control.append($0) })
+
+ // Self (["profile"], relativeDepth 1) — covered.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:profile@1000", key: "email", value: StandardTestPool.data(string: "bob@example.com"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+ // Child (["profile","nested_counter"], relativeDepth 2) — covered.
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:nested@1000", number: 3, serial: "100", siteCode: "remote")])
+ poll("events.count >= 2") { events.count >= 2 }
+ // Grandchild (["profile","prefs","deep"], relativeDepth 3) — NOT covered.
+ let controlBefore = control.count
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:deep@3000", number: 1, serial: "101", siteCode: "remote")])
+ poll("control fired") { control.count > controlBefore }
+
+ #expect(events.count == 2)
+ }
+
+ // UTS: objects/unit/RTO24c1/prefix-mismatch-0
+ @Test
+ func test_RTO24c1_prefix_mismatch_does_not_trigger() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let profileEvents = Captured()
+ _ = try root.get(key: "profile").subscribe(listener: { profileEvents.append($0) })
+ let control = Captured()
+ _ = try root.subscribe(listener: { control.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")])
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("control fired for both") { control.count >= 2 }
+
+ #expect(profileEvents.count == 0) // "profile" is not a prefix of "score"/"name"
+ }
+
+ // UTS: objects/unit/RTO24b2a/candidate-paths-map-keys-0
+ @Test
+ func test_RTO24b2a_candidate_paths_include_map_update_keys() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let scoreEvents = Captured()
+ let rootEvents = Captured()
+ _ = try root.get(key: "score").subscribe(listener: { scoreEvents.append($0) })
+ _ = try root.subscribe(listener: { rootEvents.append($0) })
+
+ // MAP_SET on root key "score" -> candidates [] and ["score"]; both subscriptions fire.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("scoreEvents fired") { scoreEvents.count >= 1 }
+ poll("rootEvents fired") { rootEvents.count >= 1 }
+
+ #expect(scoreEvents.count == 1)
+ #expect(scoreEvents.first?.object.path == "score")
+ #expect(rootEvents.count == 1)
+ }
+
+ // UTS: objects/unit/RTO24b2c/listener-exception-caught-0
+ @Test
+ func test_RTO24b2c_listener_exception_does_not_affect_others() async throws {
+ // DEVIATION (RTO24b2c): the spec's first listener THROWS; Swift's `PathObjectSubscriptionCallback`
+ // is non-throwing (`-> Void`), so a throwing listener isn't expressible. We keep a no-op first
+ // listener and assert the second still fires (the observable intent). See deviations.md.
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.subscribe(listener: { _ in })
+ _ = try root.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+
+ #expect(events.count == 1)
+ }
+
+ // UTS: objects/unit/RTO24b1/multi-path-dispatch-0
+ @Test
+ func test_RTO24b1_dispatch_via_getFullPaths_for_multi_path_objects() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let eventsScore = Captured()
+ let eventsAlias = Captured()
+
+ // Add a second reference "alias" -> counter:score@1000, so it has two paths.
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "alias", value: StandardTestPool.data(objectId: "counter:score@1000"), serial: "98", siteCode: "remote")])
+ _ = try root.get(key: "score").subscribe(listener: { eventsScore.append($0) })
+ _ = try root.get(key: "alias").subscribe(listener: { eventsAlias.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "99", siteCode: "remote")])
+ poll("score fired") { eventsScore.count >= 1 }
+ poll("alias fired") { eventsAlias.count >= 1 }
+
+ #expect(eventsScore.count == 1)
+ #expect(eventsScore.first?.object.path == "score")
+ #expect(eventsAlias.count == 1)
+ #expect(eventsAlias.first?.object.path == "alias")
+ }
+
+ // UTS: objects/unit/RTO24b2b/fires-once-per-dispatch-0
+ @Test
+ func test_RTO24b2b_subscription_fires_exactly_once_per_dispatch() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ // Root (unlimited depth) covers both [] and ["score"], but must fire once per dispatch.
+ _ = try root.subscribe(listener: { events.append($0) })
+
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("events.count >= 1") { events.count >= 1 }
+ // Control: a second single-candidate dispatch; awaiting it flushes any spurious extra callback.
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:new@2000", number: 1, serial: "100", siteCode: "remote")])
+ poll("events.count >= 2") { events.count >= 2 }
+
+ #expect(events.count == 2) // exactly one per dispatch
+ }
+}
+
+private extension PathObjectSubscribeTests {
+
+ /// Asserts `operation` throws an `ARTErrorInfo` with the given `code` (and optional `statusCode`).
+ func expectError(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () throws -> Void) {
+ do {
+ try operation()
+ Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation)
+ } catch let error as ARTErrorInfo {
+ #expect(error.code == code, sourceLocation: sourceLocation)
+ if let statusCode {
+ #expect(error.statusCode == statusCode, sourceLocation: sourceLocation)
+ }
+ } catch {
+ Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation)
+ }
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/PathObjectTests.swift b/Tests/UTS/Tests/Path-Based/PathObjectTests.swift
new file mode 100644
index 00000000..7113405a
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/PathObjectTests.swift
@@ -0,0 +1,319 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// PathObject read operations (`RTPO1`–`RTPO14`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/path_object.md
+///
+/// Binding conventions (the spec's loosely-typed API maps onto Swift's type-refined one):
+/// - Chained navigation: the spec's `root.get("a").get("b")` becomes
+/// `root.get(key: "a").asLiveMap().get(key: "b")` (`get`/`at` live on `LiveMapPathObject`, and
+/// return the loosely-typed `any PathObject`).
+/// - `value()`: the spec's single `value()` maps to the type-refined accessor —
+/// `asLiveCounter().value()` (`Double?`) for counters and `asPrimitive().value()` (`Primitive?`)
+/// for primitives; a map / unresolvable path yields `nil` from `asPrimitive().value()`.
+/// - `path` is a property (per review), not `path()`.
+@Suite(.serialized)
+final class PathObjectTests: UTSTestCase {
+ // MARK: - RTPO4 — path
+
+ // UTS: objects/unit/RTPO4/path-string-representation-0
+ @Test
+ func test_RTPO4_path_returns_dot_delimited_string() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ #expect(root.path == "") // RTPO4c: empty path (root)
+ #expect(root.get(key: "profile").path == "profile")
+ #expect(root.get(key: "profile").asLiveMap().get(key: "email").path == "profile.email")
+ }
+
+ // UTS: objects/unit/RTPO4b/path-escapes-dots-0
+ @Test
+ func test_RTPO4b_path_escapes_dots_in_segments() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ // Dots within a segment are escaped with a backslash.
+ let po = root.get(key: "a.b").asLiveMap().get(key: "c")
+ #expect(po.path == #"a\.b.c"#)
+ }
+
+ // MARK: - RTPO5 — get
+
+ // UTS: objects/unit/RTPO5/get-appends-key-0
+ @Test
+ func test_RTPO5_get_returns_new_PathObject_with_appended_key() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let child = root.get(key: "profile")
+ let grandchild = child.asLiveMap().get(key: "email")
+ #expect(child.path == "profile")
+ #expect(grandchild.path == "profile.email")
+ // RTPO5d: purely navigational (child is a distinct object from root).
+ }
+
+ // UTS: objects/unit/RTPO5b/get-non-string-throws-0
+ @Test
+ func test_RTPO5b_get_throws_on_non_string_key() throws {
+ // DEVIATION (RTPO5b): spec passes a non-String key expecting 40003. Swift's `get(key: String)`
+ // rejects a non-String at compile time. Not expressible. See deviations.md.
+ }
+
+ // MARK: - RTPO6 — at
+
+ // UTS: objects/unit/RTPO6/at-parses-path-0
+ @Test
+ func test_RTPO6_at_parses_dot_delimited_path() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let po = root.at(path: "profile.email")
+ #expect(po.path == "profile.email")
+ #expect(try po.asPrimitive().stringValue == "alice@example.com")
+ }
+
+ // UTS: objects/unit/RTPO6/at-escaped-dots-0
+ @Test
+ func test_RTPO6_at_respects_escaped_dots() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let po = root.at(path: #"a\.b.c"#)
+ #expect(po.path == #"a\.b.c"#)
+ }
+
+ // UTS: objects/unit/RTPO6b/at-non-string-throws-0
+ @Test
+ func test_RTPO6b_at_throws_for_non_string_input() throws {
+ // DEVIATION (RTPO6b): spec passes a non-String path expecting 40003. Swift's `at(path: String)`
+ // rejects a non-String at compile time. Not expressible. See deviations.md.
+ }
+
+ // MARK: - RTPO7 — value
+
+ // UTS: objects/unit/RTPO7/value-counter-0
+ @Test
+ func test_RTPO7_value_returns_counter_numeric_value() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "score").asLiveCounter().value() == 100) // RTPO7c
+ }
+
+ // UTS: objects/unit/RTPO7/value-primitive-0
+ @Test
+ func test_RTPO7_value_returns_primitive_value() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "name").asPrimitive().stringValue == "Alice") // RTPO7d
+ #expect(try root.get(key: "age").asPrimitive().numberValue == 30)
+ #expect(try root.get(key: "active").asPrimitive().boolValue == true)
+ }
+
+ // UTS: objects/unit/RTPO7/value-bytes-0
+ @Test
+ func test_RTPO7_value_returns_bytes_for_binary_entry() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "avatar").asPrimitive().dataValue == Data([1, 2, 3])) // RTPO7d (binary)
+ }
+
+ // UTS: objects/unit/RTPO7d/value-livemap-null-0
+ @Test
+ func test_RTPO7d_value_returns_null_for_InternalLiveMap() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "profile").asPrimitive().value() == nil) // RTPO7e
+ }
+
+ // UTS: objects/unit/RTPO7e/value-unresolvable-null-0
+ @Test
+ func test_RTPO7e_value_returns_null_on_resolution_failure() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "nonexistent").asLiveMap().get(key: "deep").asPrimitive().value() == nil) // RTPO7f / RTPO3c1
+ }
+
+ // MARK: - RTPO8 — instance
+
+ // UTS: objects/unit/RTPO8/instance-live-object-0
+ @Test
+ func test_RTPO8_instance_returns_Instance_for_LiveObject() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let counterInstance = try root.get(key: "score").instance()
+ #expect(counterInstance?.id() == "counter:score@1000") // RTPO8c
+
+ let mapInstance = try root.get(key: "profile").instance()
+ #expect(mapInstance?.id() == "map:profile@1000")
+ }
+
+ // UTS: objects/unit/RTPO8c/instance-primitive-null-0
+ @Test
+ func test_RTPO8c_instance_returns_null_for_primitive() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "name").instance() == nil) // RTPO8d
+ }
+
+ // MARK: - RTPO9 — entries
+
+ // UTS: objects/unit/RTPO9/entries-yields-pairs-0
+ @Test
+ func test_RTPO9_entries_returns_array_of_key_pathObject_pairs() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ var entries: [String: String] = [:]
+ for (key, pathObject) in try root.entries() {
+ entries[key] = pathObject.path
+ }
+ #expect(entries["name"] == "name")
+ #expect(entries["profile"] == "profile")
+ #expect(entries.count == 7)
+ }
+
+ // UTS: objects/unit/RTPO9d/entries-non-map-empty-0
+ @Test
+ func test_RTPO9d_entries_returns_empty_array_for_non_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "score").asLiveMap().entries().isEmpty)
+ }
+
+ // MARK: - RTPO10 — keys
+
+ // UTS: objects/unit/RTPO10/keys-returns-array-0
+ @Test
+ func test_RTPO10_keys_returns_array_of_key_strings() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let keys = try root.keys()
+ #expect(keys.count == 7)
+ #expect(keys.contains("name"))
+ #expect(keys.contains("profile"))
+ #expect(keys.contains("score"))
+ }
+
+ // UTS: objects/unit/RTPO10d/keys-non-map-empty-0
+ @Test
+ func test_RTPO10d_keys_returns_empty_array_for_non_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "score").asLiveMap().keys().isEmpty)
+ }
+
+ // MARK: - RTPO11 — values
+
+ // UTS: objects/unit/RTPO11/values-returns-array-0
+ @Test
+ func test_RTPO11_values_returns_array_of_pathObjects() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let values = try root.values()
+ #expect(values.count == 7)
+ let paths = Set(values.map(\.path))
+ #expect(paths.contains("name"))
+ #expect(paths.contains("profile"))
+ #expect(paths.contains("score"))
+ }
+
+ // UTS: objects/unit/RTPO11d/values-non-map-empty-0
+ @Test
+ func test_RTPO11d_values_returns_empty_array_for_non_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "score").asLiveMap().values().isEmpty)
+ }
+
+ // MARK: - RTPO12 — size
+
+ // UTS: objects/unit/RTPO12/size-count-0
+ @Test
+ func test_RTPO12_size_returns_non_tombstoned_count() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.size() == 7)
+ #expect(try root.get(key: "profile").asLiveMap().size() == 3)
+ }
+
+ // UTS: objects/unit/RTPO12c/size-non-map-null-0
+ @Test
+ func test_RTPO12c_size_returns_null_for_non_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "score").asLiveMap().size() == nil)
+ #expect(try root.get(key: "name").asLiveMap().size() == nil)
+ }
+
+ // MARK: - RTPO13 — compact (DEVIATION: asserted via compactJson)
+
+ // UTS: objects/unit/RTPO13/compact-recursive-0
+ @Test
+ func test_RTPO13_compact_recursively_compacts_map_tree() async throws {
+ // DEVIATION (RTPO13): only `compactJson()` is exposed publicly, so this asserts on the JSON
+ // form (binary appears as base64, not raw bytes). See deviations.md.
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ let result = try root.compactJson()?.objectValue
+ #expect(result?["name"]?.stringValue == "Alice") // RTPO13c4
+ #expect(result?["age"]?.numberValue == 30)
+ #expect(result?["active"]?.boolValue == true)
+ #expect(result?["score"]?.numberValue == 100) // RTPO13c3 (counter -> number)
+ #expect(result?["profile"]?.objectValue?["email"]?.stringValue == "alice@example.com") // RTPO13c2 (nested map)
+ #expect(result?["profile"]?.objectValue?["nested_counter"]?.numberValue == 5)
+ #expect(result?["profile"]?.objectValue?["prefs"]?.objectValue?["theme"]?.stringValue == "dark")
+ }
+
+ // UTS: objects/unit/RTPO13c/compact-counter-0
+ @Test
+ func test_RTPO13c_compact_returns_number_for_counter() async throws {
+ // DEVIATION (RTPO13): asserted via compactJson(). See deviations.md.
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "score").compactJson()?.numberValue == 100) // RTPO13d
+ }
+
+ // UTS: objects/unit/RTPO14/compact-json-bytes-0
+ @Test
+ func test_RTPO14_compactJson_encodes_bytes_as_base64_string() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ let result = try root.compactJson()?.objectValue
+ #expect(result?["avatar"]?.stringValue == "AQID") // RTPO14b1 (base64 of [1,2,3])
+ }
+
+ // UTS: objects/unit/RTPO14/compact-json-0
+ @Test
+ func test_RTPO14_compactJson_encodes_cycles_as_objectId() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+
+ // Introduce a cycle: prefs.back_ref -> profile.
+ ws.activeConnection?.sendToClient(.object(channel: "test", state: [
+ StandardTestPool.mapSet(
+ objectId: "map:prefs@1000",
+ key: "back_ref",
+ value: StandardTestPool.data(objectId: "map:profile@1000"),
+ serial: StandardTestPool.remoteSerial(0),
+ siteCode: "remote",
+ ),
+ ]))
+
+ let result = try root.get(key: "profile").compactJson()?.objectValue
+ // RTPO14b2: cycles are encoded as { "objectId": ... }.
+ let backRef = result?["prefs"]?.objectValue?["back_ref"]?.objectValue
+ #expect(backRef?["objectId"]?.stringValue == "map:profile@1000")
+ }
+
+ // MARK: - RTPO3 — path resolution
+
+ // UTS: objects/unit/RTPO3/path-resolution-walk-0
+ @Test
+ func test_RTPO3_path_resolution_walks_through_maps() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+
+ #expect(try root.asPrimitive().value() == nil) // RTPO3b: empty path resolves to root (a map)
+ #expect(try root.get(key: "profile").asLiveMap().get(key: "prefs").asLiveMap().get(key: "theme").asPrimitive().stringValue == "dark")
+ }
+
+ // UTS: objects/unit/RTPO3a1/intermediate-not-map-0
+ @Test
+ func test_RTPO3a1_resolution_fails_if_intermediate_not_map() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ // "score" is a counter, so navigating through it fails to resolve -> null.
+ #expect(try root.get(key: "score").asLiveMap().get(key: "something").asPrimitive().value() == nil)
+ }
+
+ // UTS: objects/unit/RTPO3c1/read-null-on-failure-0
+ @Test
+ func test_RTPO3c1_read_operation_returns_null_on_resolution_failure() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(try root.get(key: "nonexistent").asPrimitive().value() == nil)
+ #expect(try root.get(key: "nonexistent").instance() == nil)
+ #expect(try root.get(key: "nonexistent").asLiveMap().size() == nil)
+ #expect(try root.get(key: "nonexistent").compactJson() == nil)
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/RealtimeObjectTests.swift b/Tests/UTS/Tests/Path-Based/RealtimeObjectTests.swift
new file mode 100644
index 00000000..aec53387
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/RealtimeObjectTests.swift
@@ -0,0 +1,499 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// RealtimeObject orchestration (`RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/realtime_object.md
+///
+/// Uses `setupSyncedChannel` / `objectsChannel` from the harness. Publish/apply behaviour is exercised
+/// through the public path-object mutations (`increment`/`set`), which internally publishAndApply, and
+/// sync events through `channel.object.on(...)`.
+@Suite(.serialized)
+final class RealtimeObjectTests: UTSTestCase {
+ // MARK: - RTO23 — get()
+
+ // UTS: objects/unit/RTO23/get-returns-path-object-0
+ @Test
+ func test_RTO23_get_returns_path_object_wrapping_root() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ #expect(root.path == "") // RTO23d: root path is the empty path
+ }
+
+ // UTS: objects/unit/RTO23/get-implicit-attach-0
+ @Test
+ func test_RTO23_get_implicitly_attaches_channel() async throws {
+ let objects = objectsChannel("test")
+ #expect(objects.channel.state == .initialized)
+ _ = try await objects.channel.object.get()
+ #expect(objects.channel.state == .attached)
+ }
+
+ // UTS: objects/unit/RTO23d/get-resolves-immediately-synced-0
+ @Test
+ func test_RTO23d_get_resolves_immediately_when_already_synced() async throws {
+ let (_, channel, _, _) = try await setupSyncedChannel("test")
+ let root2 = try await channel.object.get()
+ #expect(root2.path == "")
+ }
+
+ // UTS: objects/unit/RTO23c/get-waits-for-synced-0
+ @Test
+ func test_RTO23c_get_waits_for_synced_state() async throws {
+ // ATTACHED arrives but the OBJECT_SYNC is deferred; get() must wait for it.
+ let objects = objectsChannel("test", sync: false)
+ async let pendingRoot = objects.channel.object.get()
+ // Deliver the sync so get() can resolve.
+ objects.ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync1:", state: StandardTestPool.objects))
+ let root = try await pendingRoot
+ #expect(root.path == "")
+ }
+
+ // UTS: objects/unit/RTO23a/get-requires-subscribe-mode-0
+ // UTS: objects/unit/RTO25a/access-requires-subscribe-mode-0
+ @Test
+ func test_RTO23a_get_requires_OBJECT_SUBSCRIBE_mode() async throws {
+ // Without OBJECT_SUBSCRIBE, get()/access throws 40024 (RTO2a2 / RTO25a).
+ let objects = objectsChannel("test", modes: [.objectPublish], sync: false)
+ await expectError(code: 40024, statusCode: 400) {
+ _ = try await objects.channel.object.get()
+ }
+ }
+
+ // UTS: objects/unit/RTO23e/get-reattaches-detached-0
+ @Test
+ func test_RTO23e_get_reattaches_detached_channel() async throws {
+ let (_, channel, _, ws) = try await setupSyncedChannel("test")
+
+ channel.detach()
+ ws.activeConnection?.sendToClient(.detached(channel: "test"))
+ awaitChannelState(channel, .detached)
+
+ // get() on a DETACHED channel runs ensure-active-channel (RTL33b) -> implicit re-attach.
+ let root = try await channel.object.get()
+ #expect(root.path == "")
+ #expect(channel.state == .attached)
+ }
+
+ // UTS: objects/unit/RTO23e/get-rejects-failed-0
+ @Test
+ func test_RTO23e_get_on_failed_channel_rejects_90001() async throws {
+ let objects = objectsChannel("test", modes: [.objectSubscribe], sync: false)
+ objects.channel.attach()
+ objects.ws.activeConnection?.sendToClient(.channelError(channel: "test", code: 90000, statusCode: 400, message: "Channel error"))
+ awaitChannelState(objects.channel, .failed)
+
+ await expectError(code: 90001, statusCode: 400) {
+ _ = try await objects.channel.object.get()
+ }
+ }
+
+ // MARK: - RTO15 — publish
+
+ // UTS: objects/unit/RTO15/publish-sends-object-pm-0
+ @Test
+ func test_RTO15_publish_sends_object_protocol_message() throws {
+ // DEVIATION (RTO15): `publish` (and `PublishResult`) is an internal RealtimeObject method, not
+ // part of the public `RealtimeObject` protocol (which exposes only `get()` / `on(...)`). The
+ // publish path is exercised indirectly through the path-object mutations (see the RTO20 tests).
+ // See deviations.md.
+ }
+
+ // MARK: - RTO20 — publishAndApply (apply-on-ACK)
+
+ // UTS: objects/unit/RTO20/publish-and-apply-local-0
+ @Test
+ func test_RTO20_publish_and_apply_local_on_ack() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+ }
+
+ // UTS: objects/unit/RTO20c/missing-site-code-0
+ @Test
+ func test_RTO20c_publish_and_apply_without_site_code_does_not_apply() async throws {
+ // No siteCode in ConnectionDetails -> the synthetic message can't be built, so no local apply.
+ let objects = objectsChannel("test", siteCode: nil)
+ let root = try await objects.channel.object.get()
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 100)
+ }
+
+ // UTS: objects/unit/RTO20d1/null-serial-skipped-0
+ @Test
+ func test_RTO20d1_null_serial_in_publish_result_is_skipped() async throws {
+ // ACK with a null serial -> that synthetic message is skipped, so no local apply.
+ let objects = objectsChannel("test", autoAck: false)
+ let root = try await objects.channel.object.get()
+ // Respond to the OBJECT publish with a single null serial.
+ // (autoAck is off; a bespoke ACK is not wired here since the API traps first — documents intent.)
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ objects.ws.activeConnection?.sendToClient(.ack(msgSerial: 0, count: 1, serials: [nil]))
+ #expect(try root.get(key: "score").asLiveCounter().value() == 100)
+ }
+
+ // UTS: objects/unit/RTO20e/waits-for-synced-0
+ @Test
+ func test_RTO20e_publish_and_apply_waits_for_synced_during_syncing() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+
+ // Begin a new sync (SYNCING) without completing it.
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true))
+
+ async let pending: Void = root.get(key: "score").asLiveCounter().increment(amount: 10)
+ // While SYNCING the increment must not have applied yet.
+ #expect(try root.get(key: "score").asLiveCounter().value() == 100)
+
+ // Complete the sync; the write then applies.
+ ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects))
+ try await pending
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+ }
+
+ // UTS: objects/unit/RTO20e1/fails-on-channel-detached-0
+ @Test
+ func test_RTO20e1_publish_and_apply_fails_when_channel_detached_during_sync_wait() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true))
+
+ await expectError(code: 92008) {
+ async let pending: Void = root.get(key: "score").asLiveCounter().increment(amount: 10)
+ ws.activeConnection?.sendToClient(.detached(channel: "test"))
+ try await pending
+ }
+ }
+
+ // UTS: objects/unit/RTO20/echo-dedup-0
+ @Test
+ func test_RTO20_echo_deduplication_via_appliedOnAckSerials() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+
+ // Echo with the same serial as the apply-on-ACK -> deduplicated (RTO9a3).
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.ackSerial(0, 0), siteCode: StandardTestPool.siteCode)])
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+ }
+
+ // UTS: objects/unit/RTO20f/ack-no-site-timeserials-update-0
+ @Test
+ func test_RTO20f_apply_on_ack_does_not_update_site_timeserials() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+
+ // Inbound from SITE_CODE with a serial that is NOT the ACK serial but sorts below it. It
+ // applies only if the LOCAL apply-on-ACK left siteTimeserials[SITE_CODE] untouched (RTLC7c).
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.belowAckSerial(9), siteCode: StandardTestPool.siteCode)])
+ poll("value == 120") { (try? root.get(key: "score").asLiveCounter().value()) == 120 }
+ #expect(try root.get(key: "score").asLiveCounter().value() == 120)
+ }
+
+ // UTS: objects/unit/RTO20/ack-after-echo-no-double-apply-0
+ @Test
+ func test_RTO20_ack_after_echo_does_not_double_apply() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test", autoAck: false)
+
+ async let pending: Void = root.get(key: "score").asLiveCounter().increment(amount: 10)
+ // Echo arrives BEFORE the ACK.
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.ackSerial(0, 0), siteCode: StandardTestPool.siteCode)])
+ // Then the ACK.
+ ws.activeConnection?.sendToClient(.ack(msgSerial: 0, count: 1, serials: [StandardTestPool.ackSerial(0, 0)]))
+ try await pending
+
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+ }
+
+ // UTS: objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0
+ @Test
+ func test_RTO5c9_applied_on_ack_serials_cleared_on_resync() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+
+ // Re-sync resets the counter to its pool value and clears appliedOnAckSerials (RTO5c9).
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true))
+ ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects))
+ #expect(try root.get(key: "score").asLiveCounter().value() == 100)
+
+ // The previously-applied serial now applies normally (not deduped).
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.ackSerial(0, 0), siteCode: StandardTestPool.siteCode)])
+ poll("value == 110") { (try? root.get(key: "score").asLiveCounter().value()) == 110 }
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+ }
+
+ // UTS: objects/unit/RTO20/subscription-fires-on-ack-apply-0
+ @Test
+ func test_RTO20_subscription_fires_on_apply_on_ack() async throws {
+ let (_, _, root, _) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = try root.get(key: "score").subscribe(listener: { events.append($0) })
+
+ try await root.get(key: "score").asLiveCounter().increment(amount: 10)
+ #expect(events.count >= 1)
+ #expect(try root.get(key: "score").asLiveCounter().value() == 110)
+ }
+
+ // MARK: - RTO17 / RTO18 / RTO19 — sync events
+
+ // UTS: objects/unit/RTO17/sync-state-events-0
+ @Test
+ func test_RTO17_sync_state_events_in_order() async throws {
+ let objects = objectsChannel("test", sync: false)
+ let events = Captured()
+ _ = objects.channel.object.on(event: .syncing) { events.append("SYNCING") }
+ _ = objects.channel.object.on(event: .synced) { events.append("SYNCED") }
+
+ async let pendingRoot = objects.channel.object.get()
+ poll("events.count >= 1") { events.count >= 1 }
+ objects.ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync1:", state: StandardTestPool.objects))
+ _ = try await pendingRoot
+
+ #expect(events.all == ["SYNCING", "SYNCED"])
+ }
+
+ // UTS: objects/unit/RTO18d/duplicate-listener-0
+ @Test
+ func test_RTO18d_duplicate_listener_fires_twice() async throws {
+ let (_, channel, _, ws) = try await setupSyncedChannel("test")
+ let calls = Captured()
+ let listener: @Sendable () -> Void = { calls.append(()) }
+ _ = channel.object.on(event: .synced, callback: listener)
+ _ = channel.object.on(event: .synced, callback: listener)
+
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true))
+ ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects))
+ poll("calls.count >= 2") { calls.count >= 2 }
+
+ #expect(calls.count == 2)
+ }
+
+ // UTS: objects/unit/RTO19/off-deregisters-0
+ @Test
+ func test_RTO19_off_deregisters_listener() async throws {
+ let (_, channel, _, ws) = try await setupSyncedChannel("test")
+ let calls = Captured()
+ let sub = channel.object.on(event: .synced) { calls.append(()) }
+ sub.off()
+
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true))
+ ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects))
+
+ #expect(calls.count == 0)
+ }
+
+ // MARK: - RTO2 / RTO25 / RTO26 — mode & state preconditions
+
+ // UTS: objects/unit/RTO2/mode-enforcement-0
+ // UTS: objects/unit/RTO26a/write-requires-publish-mode-0
+ @Test
+ func test_RTO26a_write_requires_OBJECT_PUBLISH_mode() async throws {
+ // Channel granted only OBJECT_SUBSCRIBE -> a write throws 40024.
+ let objects = objectsChannel("test", modes: [.objectSubscribe])
+ let root = try await objects.channel.object.get()
+ await expectError(code: 40024, statusCode: 400) {
+ try await root.set(key: "name", value: "Bob")
+ }
+ }
+
+ // UTS: objects/unit/RTO25b/access-throws-detached-0
+ @Test
+ func test_RTO25b_access_throws_on_detached_channel() async throws {
+ let (_, channel, root, ws) = try await setupSyncedChannel("test")
+ ws.activeConnection?.sendToClient(.detached(channel: "test"))
+ awaitChannelState(channel, .detached)
+
+ expectErrorSync(code: 90001, statusCode: 400) {
+ _ = try root.keys()
+ }
+ }
+
+ // UTS: objects/unit/RTO25b/access-throws-failed-0
+ @Test
+ func test_RTO25b_access_throws_on_failed_channel() async throws {
+ let (_, channel, root, ws) = try await setupSyncedChannel("test")
+ ws.activeConnection?.sendToClient(.channelError(channel: "test", code: 90000, statusCode: 400, message: "Channel error"))
+ awaitChannelState(channel, .failed)
+
+ expectErrorSync(code: 90001, statusCode: 400) {
+ _ = try root.keys()
+ }
+ }
+
+ // UTS: objects/unit/RTO26b/write-throws-detached-0
+ @Test
+ func test_RTO26b_write_throws_on_detached_channel() async throws {
+ let (_, channel, root, ws) = try await setupSyncedChannel("test")
+ ws.activeConnection?.sendToClient(.detached(channel: "test"))
+ awaitChannelState(channel, .detached)
+
+ await expectError(code: 90001, statusCode: 400) {
+ try await root.set(key: "name", value: "Bob")
+ }
+ }
+
+ // UTS: objects/unit/RTO26b/write-throws-failed-0
+ @Test
+ func test_RTO26b_write_throws_on_failed_channel() async throws {
+ let (_, channel, root, ws) = try await setupSyncedChannel("test")
+ ws.activeConnection?.sendToClient(.channelError(channel: "test", code: 90000, statusCode: 400, message: "Channel error"))
+ awaitChannelState(channel, .failed)
+
+ await expectError(code: 90001, statusCode: 400) {
+ try await root.set(key: "name", value: "Bob")
+ }
+ }
+
+ // UTS: objects/unit/RTO26c/write-throws-echo-disabled-0
+ @Test
+ func test_RTO26c_write_throws_when_echo_disabled() async throws {
+ let objects = objectsChannel("test", echoMessages: false)
+ let root = try await objects.channel.object.get()
+ await expectError(code: 40000, statusCode: 400) {
+ try await root.set(key: "name", value: "Bob")
+ }
+ }
+
+ // MARK: - RTO24 — dispatch register
+
+ // UTS: objects/unit/RTO24a/single-register-instance-0
+ @Test
+ func test_RTO24a_single_subscription_register_per_channel() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let rootEvents = Captured()
+ let scoreEvents = Captured()
+ _ = try root.subscribe(listener: { rootEvents.append($0) })
+ _ = try root.get(key: "score").subscribe(listener: { scoreEvents.append($0) })
+
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "t:1", siteCode: "remote")])
+ poll("score fired") { scoreEvents.count >= 1 }
+
+ #expect(rootEvents.count >= 1)
+ #expect(scoreEvents.count >= 1)
+ }
+
+ // UTS: objects/unit/RTO24c1/coverage-prefix-depth-0
+ @Test
+ func test_RTO24c1_coverage_prefix_with_depth_constraint() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+ let shallowEvents = Captured()
+ let deepEvents = Captured()
+ _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 1), listener: { shallowEvents.append($0) })
+ _ = try root.subscribe(listener: { deepEvents.append($0) })
+
+ // Root self-update ([], covered by depth 1).
+ sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")])
+ poll("deep >= 1") { deepEvents.count >= 1 }
+ // Child update (["score"], relativeDepth 2 > 1) — only deep covers it.
+ sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "t:2", siteCode: "remote")])
+ poll("deep >= 2") { deepEvents.count >= 2 }
+ poll("shallow >= 1") { shallowEvents.count >= 1 }
+
+ #expect(shallowEvents.count == 1)
+ #expect(deepEvents.count >= 2)
+ }
+
+ // MARK: - RTO10 — garbage collection
+
+ // UTS: objects/unit/RTO10/gc-tombstoned-objects-0
+ @Test
+ func test_RTO10_gc_removes_tombstoned_objects_past_grace_period() async throws {
+ enableFakeTimers()
+ let (_, _, root, ws) = try await setupSyncedChannel("test")
+
+ sendToClient(ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "99", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1))])
+
+ advanceTime(byMilliseconds: 86_400_000 + 300_000)
+
+ #expect(try root.get(key: "score").asLiveCounter().value() == nil)
+ }
+
+ // UTS: objects/unit/RTO10b1/gc-grace-period-source-0
+ @Test
+ func test_RTO10b1_gc_grace_period_from_connection_details() async throws {
+ enableFakeTimers()
+ // Short grace period (5000ms) from ConnectionDetails.
+ let objects = objectsChannel("test", gcGracePeriod: 5000)
+ let root = try await objects.channel.object.get()
+
+ sendToClient(objects.ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "99", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1))])
+ advanceTime(byMilliseconds: 5000 + 1000)
+
+ #expect(try root.get(key: "score").asLiveCounter().value() == nil)
+ }
+
+ // MARK: - RTO17 / RTO18 — sync event sequences
+
+ // UTS: objects/unit/RTO17-RTO18/sync-event-sequences-0
+ @Test
+ func test_RTO17_RTO18_sync_event_sequence_initial_attach() async throws {
+ // A genuine first attach: register listeners on a fresh (non-synced) channel BEFORE attach.
+ let objects = objectsChannel("test", sync: false)
+ let events = Captured()
+ _ = objects.channel.object.on(event: .syncing) { events.append("SYNCING") }
+ _ = objects.channel.object.on(event: .synced) { events.append("SYNCED") }
+
+ objects.channel.attach()
+ objects.ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync1:", state: StandardTestPool.objects))
+ poll("events.count >= 2") { events.count >= 2 }
+
+ #expect(events.all == ["SYNCING", "SYNCED"])
+ }
+
+ // UTS: objects/unit/RTO17-RTO18/sync-event-sequences-0 (re-sync on new ATTACHED)
+ @Test
+ func test_RTO17_RTO18_sync_event_sequence_resync_on_new_attached() async throws {
+ let (_, channel, _, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = channel.object.on(event: .syncing) { events.append("SYNCING") }
+ _ = channel.object.on(event: .synced) { events.append("SYNCED") }
+
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync3:cursor", hasObjects: true))
+ ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync3:", state: StandardTestPool.objects))
+ poll("events.count >= 2") { events.count >= 2 }
+
+ #expect(events.all == ["SYNCING", "SYNCED"])
+ }
+
+ // UTS: objects/unit/RTO17-RTO18/sync-event-sequences-0 (ATTACHED without HAS_OBJECTS)
+ @Test
+ func test_RTO17_RTO18_sync_event_sequence_attached_without_has_objects() async throws {
+ let (_, channel, _, ws) = try await setupSyncedChannel("test")
+ let events = Captured()
+ _ = channel.object.on(event: .syncing) { events.append("SYNCING") }
+ _ = channel.object.on(event: .synced) { events.append("SYNCED") }
+
+ // ATTACHED without HAS_OBJECTS: RTO4c -> SYNCING, then RTO4b4 completes immediately -> SYNCED.
+ ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync4:", hasObjects: false))
+ poll("events.count >= 2") { events.count >= 2 }
+
+ #expect(events.all == ["SYNCING", "SYNCED"])
+ }
+}
+
+private extension RealtimeObjectTests {
+
+ func expectError(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () async throws -> Void) async {
+ do {
+ try await operation()
+ Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation)
+ } catch let error as ARTErrorInfo {
+ #expect(error.code == code, sourceLocation: sourceLocation)
+ if let statusCode { #expect(error.statusCode == statusCode, sourceLocation: sourceLocation) }
+ } catch {
+ Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation)
+ }
+ }
+
+ func expectErrorSync(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () throws -> Void) {
+ do {
+ try operation()
+ Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation)
+ } catch let error as ARTErrorInfo {
+ #expect(error.code == code, sourceLocation: sourceLocation)
+ if let statusCode { #expect(error.statusCode == statusCode, sourceLocation: sourceLocation) }
+ } catch {
+ Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation)
+ }
+ }
+}
diff --git a/Tests/UTS/Tests/Path-Based/ValueTypesTests.swift b/Tests/UTS/Tests/Path-Based/ValueTypesTests.swift
new file mode 100644
index 00000000..9fcf07af
--- /dev/null
+++ b/Tests/UTS/Tests/Path-Based/ValueTypesTests.swift
@@ -0,0 +1,317 @@
+import Ably
+import Foundation
+import Testing
+@testable import AblyLiveObjects
+
+/// Value Types (`RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4`).
+/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/value_types.md
+///
+/// `LiveCounter` / `LiveMap` are immutable blueprints from the static `create()` factories. The
+/// spec's `evaluate(vt)` has no public counterpart; consumption happens inside `set()`, so — as in
+/// the ably-js translation — evaluation is exercised by publishing via `set()` and inspecting the
+/// generated `OBJECT` wire operations (decoded into the internal ``WireObjectOperation``, which
+/// retains the outbound-only `*WithObjectId` fields). The `create()`/`count`/`entries` assertions
+/// use `@testable` internal access.
+@Suite(.serialized)
+final class ValueTypesTests: UTSTestCase {
+ // MARK: - RTLCV3 — LiveCounter.create
+
+ // UTS: objects/unit/RTLCV3/create-with-count-0
+ @Test
+ func test_RTLCV3_LiveCounter_create_with_initial_count() throws {
+ let vt = LiveCounter.create(initialCount: 42)
+
+ // RTLCV3b: returns a LiveCounter with the internal count.
+ #expect(vt.count == 42)
+ // RTLCV3d: the returned value is immutable (a Swift value type — immutable by construction).
+ }
+
+ // UTS: objects/unit/RTLCV3/create-default-zero-0
+ @Test
+ func test_RTLCV3_LiveCounter_create_defaults_to_0() throws {
+ // If initialCount omitted, defaults to 0.
+ let vt = LiveCounter.create()
+ #expect(vt.count == 0)
+ }
+
+ // UTS: objects/unit/RTLCV3c/no-validation-at-create-0
+ @Test
+ func test_RTLCV3c_no_validation_at_creation_time() throws {
+ // DEVIATION (RTLCV3c): spec passes a non-number (`LiveCounter.create("not_a_number")`) to show
+ // creation performs no validation. Swift's `create(initialCount: Double)` rejects a String at
+ // compile time, so the non-number input is not expressible. Only the (valid) construction is
+ // exercised. See deviations.md.
+ let vt = LiveCounter.create(initialCount: 0)
+ #expect(vt.count == 0)
+ }
+
+ // MARK: - RTLCV4 — Consumption generates COUNTER_CREATE ObjectMessage
+
+ // UTS: objects/unit/RTLCV4/evaluate-generates-message-0
+ @Test
+ func test_RTLCV4_consumption_generates_COUNTER_CREATE_ObjectMessage() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLCV4-consume")
+
+ try await root.asLiveMap().set(key: "name", value: .liveCounter(LiveCounter.create(initialCount: 42)))
+
+ let operations = try sentObjectOperations(ws)
+ let counterCreateOps = operations.filter { $0.action == .known(.counterCreate) }
+ #expect(counterCreateOps.count == 1)
+
+ let operation = try #require(counterCreateOps.first)
+ #expect(operation.action == .known(.counterCreate))
+ // objectId starts with "counter:" and contains "@" (RTLCV4f, RTO14).
+ #expect(operation.objectId.hasPrefix("counter:"))
+ #expect(operation.objectId.contains("@"))
+ // counterCreateWithObjectId is set with a nonce (16+ chars) and an initialValue (RTLCV4g3/g4/d).
+ let withObjectId = try #require(operation.counterCreateWithObjectId)
+ #expect(withObjectId.nonce.count >= 16)
+ #expect(!withObjectId.initialValue.isEmpty)
+ }
+
+ // UTS: objects/unit/RTLCV4g5/retains-local-counter-create-0
+ @Test
+ func test_RTLCV4g5_consumption_retains_local_CounterCreate() async throws {
+ // DEVIATION (RTLCV4g5): the local CounterCreate (`derivedFrom`) is stripped from the wire
+ // message, so — as in ably-js — we verify the retained count via the `initialValue` JSON
+ // string in counterCreateWithObjectId, which encodes the CounterCreate payload. See deviations.md.
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLCV4g5")
+
+ try await root.asLiveMap().set(key: "name", value: .liveCounter(LiveCounter.create(initialCount: 42)))
+
+ let operations = try sentObjectOperations(ws)
+ let operation = try #require(operations.first { $0.action == .known(.counterCreate) })
+ let initialValue = try initialValueJSON(try #require(operation.counterCreateWithObjectId).initialValue)
+ #expect((initialValue["count"] as? Double) == 42)
+ }
+
+ // UTS: objects/unit/RTLCV4a/evaluate-validates-count-0
+ @Test
+ func test_RTLCV4a_consumption_validates_count_type() throws {
+ // DEVIATION (RTLCV4a): spec passes a non-number and expects evaluation to fail with 40003.
+ // Swift's `create(initialCount: Double)` rejects non-numbers at compile time, so the runtime
+ // 40003 assertion is not expressible. See deviations.md.
+ }
+
+ // UTS: objects/unit/RTLCV4/evaluate-zero-count-0
+ @Test
+ func test_RTLCV4_consumption_with_count_0_is_valid() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLCV4-zero")
+
+ try await root.asLiveMap().set(key: "name", value: .liveCounter(LiveCounter.create(initialCount: 0)))
+
+ let operations = try sentObjectOperations(ws)
+ let operation = try #require(operations.first { $0.action == .known(.counterCreate) })
+ let initialValue = try initialValueJSON(try #require(operation.counterCreateWithObjectId).initialValue)
+ #expect((initialValue["count"] as? Double) == 0)
+ }
+
+ // MARK: - RTLMV3 — LiveMap.create
+
+ // UTS: objects/unit/RTLMV3/create-with-entries-0
+ @Test
+ func test_RTLMV3_LiveMap_create_with_entries() throws {
+ let vt = LiveMap.create(entries: [
+ "name": "Alice",
+ "age": 30,
+ ])
+
+ // RTLMV3b: returns a LiveMap with internal entries.
+ #expect(vt.entries?["name"]?.stringValue == "Alice")
+ #expect(vt.entries?["age"]?.numberValue == 30)
+ // RTLMV3d: the returned value is immutable (a Swift value type — immutable by construction).
+ }
+
+ // UTS: objects/unit/RTLMV3/create-no-entries-0
+ @Test
+ func test_RTLMV3_LiveMap_create_with_no_entries() throws {
+ // If entries omitted, internal entries is nil.
+ let vt = LiveMap.create()
+ #expect(vt.entries == nil)
+ }
+
+ // MARK: - RTLMV4 — Consumption generates MAP_CREATE ObjectMessage
+
+ // UTS: objects/unit/RTLMV4/evaluate-generates-message-0
+ @Test
+ func test_RTLMV4_consumption_generates_MAP_CREATE_ObjectMessage() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4-consume")
+
+ try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create(entries: ["name": "Alice"])))
+
+ let operations = try sentObjectOperations(ws)
+ let mapCreateOps = operations.filter { $0.action == .known(.mapCreate) }
+ #expect(mapCreateOps.count == 1)
+
+ let operation = try #require(mapCreateOps.first)
+ #expect(operation.action == .known(.mapCreate))
+ #expect(operation.objectId.hasPrefix("map:"))
+ let withObjectId = try #require(operation.mapCreateWithObjectId)
+ #expect(withObjectId.nonce.count >= 16)
+ #expect(!withObjectId.initialValue.isEmpty)
+ }
+
+ // UTS: objects/unit/RTLMV4j5/retains-local-map-create-0
+ @Test
+ func test_RTLMV4j5_consumption_retains_local_MapCreate() async throws {
+ // DEVIATION (RTLMV4j5): the local MapCreate is stripped from the wire message, so — as in
+ // ably-js — we verify it via the `initialValue` JSON string in mapCreateWithObjectId. See deviations.md.
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4j5")
+
+ try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create(entries: ["name": "Alice"])))
+
+ let operations = try sentObjectOperations(ws)
+ let operation = try #require(operations.first { $0.action == .known(.mapCreate) })
+ let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue)
+ #expect((initialValue["semantics"] as? Int) == WireMapSemantics.lww)
+ let entries = try #require(initialValue["entries"] as? [String: Any])
+ let nameData = try #require((entries["name"] as? [String: Any])?["data"] as? [String: Any])
+ #expect((nameData["string"] as? String) == "Alice")
+ }
+
+ // UTS: objects/unit/RTLMV4d/entry-value-types-0
+ @Test
+ func test_RTLMV4d_entry_value_type_mapping() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4d")
+
+ try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create(entries: [
+ "str": "hello",
+ "num": 42,
+ "bool": true,
+ "json_arr": [1, 2, 3],
+ "json_obj": ["key": "value"],
+ ])))
+
+ let operations = try sentObjectOperations(ws)
+ let operation = try #require(operations.first { $0.action == .known(.mapCreate) })
+ let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue)
+ let entries = try #require(initialValue["entries"] as? [String: Any])
+
+ func data(_ key: String) throws -> [String: Any] {
+ try #require((entries[key] as? [String: Any])?["data"] as? [String: Any])
+ }
+ #expect((try data("str")["string"] as? String) == "hello") // RTLMV4d4
+ #expect((try data("num")["number"] as? Double) == 42) // RTLMV4d5
+ #expect((try data("bool")["boolean"] as? Bool) == true) // RTLMV4d6
+ // JSON values on the wire are JSON-stringified strings (RTLMV4d3).
+ let jsonArr = try JSONSerialization.jsonObject(with: Data((try data("json_arr")["json"] as? String ?? "").utf8)) as? [Any]
+ #expect(jsonArr?.count == 3)
+ let jsonObj = try JSONSerialization.jsonObject(with: Data((try data("json_obj")["json"] as? String ?? "").utf8)) as? [String: Any]
+ #expect((jsonObj?["key"] as? String) == "value")
+ }
+
+ // UTS: objects/unit/RTLMV4d1/nested-value-types-0
+ @Test
+ func test_RTLMV4d1_nested_value_types_produce_depth_first_ObjectMessages() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4d1")
+
+ let innerCounter = LiveCounter.create(initialCount: 10)
+ let innerMap = LiveMap.create(entries: ["nested_count": .liveCounter(innerCounter)])
+ let outer = LiveMap.create(entries: ["child": .liveMap(innerMap)])
+
+ try await root.asLiveMap().set(key: "name", value: .liveMap(outer))
+
+ let operations = try sentObjectOperations(ws)
+ // Only the CREATE operations (there is also a MAP_SET linking `name`).
+ let createOps = operations.filter { $0.action == .known(.counterCreate) || $0.action == .known(.mapCreate) }
+ // Depth-first: inner counter, inner map, outer map (RTLMV4k).
+ #expect(createOps.count == 3)
+ #expect(createOps[0].action == .known(.counterCreate))
+ #expect(createOps[0].objectId.hasPrefix("counter:"))
+ #expect(createOps[1].action == .known(.mapCreate))
+ #expect(createOps[1].objectId.hasPrefix("map:"))
+ #expect(createOps[2].action == .known(.mapCreate))
+ #expect(createOps[2].objectId.hasPrefix("map:"))
+
+ let innerCounterId = createOps[0].objectId
+ let innerMapId = createOps[1].objectId
+
+ // Inner map's entries reference the inner counter; outer map's entries reference the inner map.
+ func initialValueEntries(_ operation: WireObjectOperation) throws -> [String: Any] {
+ let json = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue)
+ return try #require(json["entries"] as? [String: Any])
+ }
+ let innerMapEntries = try initialValueEntries(createOps[1])
+ let nestedCountData = try #require((innerMapEntries["nested_count"] as? [String: Any])?["data"] as? [String: Any])
+ #expect((nestedCountData["objectId"] as? String) == innerCounterId)
+
+ let outerMapEntries = try initialValueEntries(createOps[2])
+ let childData = try #require((outerMapEntries["child"] as? [String: Any])?["data"] as? [String: Any])
+ #expect((childData["objectId"] as? String) == innerMapId)
+ }
+
+ // UTS: objects/unit/RTLMV4a/evaluate-validates-entries-0
+ @Test
+ func test_RTLMV4a_consumption_validates_entries_type() throws {
+ // DEVIATION (RTLMV4a): spec passes `LiveMap.create(null)` expecting 40003. Swift's
+ // `create(entries: [String: LiveMapValue])` rejects null at compile time. Not expressible. See deviations.md.
+ }
+
+ // UTS: objects/unit/RTLMV4b/evaluate-validates-keys-0
+ @Test
+ func test_RTLMV4b_consumption_validates_key_types() throws {
+ // DEVIATION (RTLMV4b): spec passes a non-String key (`{ 123: "value" }`) expecting 40003.
+ // Swift dictionary keys are typed `String`; a non-String key cannot be constructed. Not
+ // expressible. See deviations.md.
+ }
+
+ // UTS: objects/unit/RTLMV4c/evaluate-validates-values-0
+ @Test
+ func test_RTLMV4c_consumption_validates_value_types() throws {
+ // DEVIATION (RTLMV4c): spec passes an unsupported value (a function) expecting 40013. Swift's
+ // `LiveMapValue` union only constructs from supported types, so an unsupported value is
+ // rejected at compile time. Not expressible. See deviations.md.
+ }
+
+ // UTS: objects/unit/RTLMV4e2/empty-entries-0
+ @Test
+ func test_RTLMV4e2_empty_entries_produces_MapCreate_with_empty_entries() async throws {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4e2")
+
+ try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create()))
+
+ let operations = try sentObjectOperations(ws)
+ let operation = try #require(operations.first { $0.action == .known(.mapCreate) })
+ let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue)
+ let entries = try #require(initialValue["entries"] as? [String: Any])
+ #expect(entries.isEmpty)
+ }
+
+ // UTS: objects/unit/RTLMV4d/map-set-all-types-table-0
+ @Test
+ func test_RTLMV4d_table_driven_value_type_mapping() async throws {
+ // Table-driven (like ably-js's `scenarios` loop): every supported value type maps to the
+ // correct `data.*` field on the generated MapCreate entry.
+ let scenarios: [(input: LiveMapValue, field: String)] = [
+ ("hello", "string"),
+ (42, "number"),
+ (3.14, "number"),
+ (0, "number"),
+ (-1, "number"),
+ (true, "boolean"),
+ (false, "boolean"),
+ ([1, "a"], "json"),
+ (["k": "v"], "json"),
+ (.primitive(.data(Data([1, 2, 3]))), "bytes"),
+ ]
+
+ for (index, scenario) in scenarios.enumerated() {
+ let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4d-table-\(index)")
+ try await root.asLiveMap().set(key: "test_key", value: .liveMap(LiveMap.create(entries: ["test_key": scenario.input])))
+
+ let operations = try sentObjectOperations(ws)
+ let operation = try #require(operations.first { $0.action == .known(.mapCreate) })
+ let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue)
+ let entries = try #require(initialValue["entries"] as? [String: Any])
+ let data = try #require((entries["test_key"] as? [String: Any])?["data"] as? [String: Any])
+ #expect(data[scenario.field] != nil, "expected \(scenario.field) field for scenario \(index)")
+ }
+ }
+}
+
+private extension ValueTypesTests {
+ /// Parses the `initialValue` JSON string (the wire encoding of a `CounterCreate` / `MapCreate`).
+ func initialValueJSON(_ initialValue: String, sourceLocation: SourceLocation = #_sourceLocation) throws -> [String: Any] {
+ try #require(try JSONSerialization.jsonObject(with: Data(initialValue.utf8)) as? [String: Any], sourceLocation: sourceLocation)
+ }
+}
diff --git a/Tests/UTS/deviations.md b/Tests/UTS/deviations.md
new file mode 100644
index 00000000..74dd3196
--- /dev/null
+++ b/Tests/UTS/deviations.md
@@ -0,0 +1,37 @@
+# UTS Deviations (Path-Based LiveObjects)
+
+Records where the Swift translations diverge from the UTS pseudocode. Per the `uts-to-swift`
+workflow, this file is only for **SDK non-compliance** and **language / mock-capability gaps** — not
+for harness-driving differences (those are explained in code comments).
+
+## Adapted Tests (typed-language gaps)
+
+These spec cases pass an intentionally wrong-typed argument and expect a runtime error. Swift's typed
+`create(...)` signatures and the `LiveMapValue` union reject those inputs **at compile time**, so the
+runtime error assertion cannot be written. The test bodies keep the spec point and exercise the valid
+construction only, with an inline `// DEVIATION` note. (Same resolution as ably-java.)
+
+| Spec point | Spec says | SDK behaviour | Affected test |
+|---|---|---|---|
+| `RTLCV3c` | `LiveCounter.create("not_a_number")` succeeds (no validation at create) | `create(initialCount: Double)` rejects a `String` at compile time | `ValueTypesTests.test_RTLCV3c_no_validation_at_creation_time` |
+| `RTLCV4a` | evaluating `LiveCounter.create("not_a_number")` throws `40003` | non-number rejected at compile time | `ValueTypesTests.test_RTLCV4a_consumption_validates_count_type` |
+| `RTLMV4a` | `LiveMap.create(null)` → evaluation throws `40003` | `create(entries: [String: LiveMapValue])` rejects `null` at compile time | `ValueTypesTests.test_RTLMV4a_consumption_validates_entries_type` |
+| `RTLMV4b` | non-String key `{ 123: "value" }` → `40003` | Swift dictionary keys are typed `String`; non-String key not constructible | `ValueTypesTests.test_RTLMV4b_consumption_validates_key_types` |
+| `RTLMV4c` | unsupported value (a function) → `40013` | `LiveMapValue` only constructs from supported types; unsupported value rejected at compile time | `ValueTypesTests.test_RTLMV4c_consumption_validates_value_types` |
+
+## Adapted Assertions (wire format)
+
+| Spec point | Spec says | SDK / wire behaviour | Affected test |
+|---|---|---|---|
+| `RTLCV4g5` | assert `operation.counterCreate.count == 42` on the generated message | the local `CounterCreate` (`derivedFrom`) is stripped from the wire message; the count is verified via the `counterCreateWithObjectId.initialValue` JSON string instead (same as ably-js) | `ValueTypesTests.test_RTLCV4g5_consumption_retains_local_CounterCreate` |
+| `RTLMV4j5` | assert `operation.mapCreate.semantics/entries` on the generated message | local `MapCreate` stripped from wire; verified via `mapCreateWithObjectId.initialValue` JSON string | `ValueTypesTests.test_RTLMV4j5_consumption_retains_local_MapCreate` |
+| `RTLMV4d` | assert on generated `mapCreate.entries[k].data.` | verified via the `mapCreateWithObjectId.initialValue` JSON string (the retained `mapCreate` is stripped from wire) | `ValueTypesTests.test_RTLMV4d_*` |
+| `RTO24b2c` | a subscription listener that **throws** must not affect other listeners | Swift's `PathObjectSubscriptionCallback` is `@Sendable (…) -> Void` (non-throwing), so a throwing listener isn't expressible; the test keeps a no-op first listener and asserts the second still fires | `PathObjectSubscribeTests.test_RTO24b2c_listener_exception_does_not_affect_others` |
+| `RTINS12d` / `RTINS14d` / `RTINS15d` / `RTINS16c` | calling a wrong-type operation on an `Instance` (e.g. `set` on a counter, `increment`/`subscribe` on a map/primitive) throws 92007 | Swift models `Instance` as an enum; `set`/`increment`/`decrement`/`subscribe` exist only on the relevant payload protocol, so a wrong-type call can't be written at all — the runtime 92007 path is compile-time-unreachable | `InstanceTests.test_RTINS12d_*` / `test_RTINS14d_*` / `test_RTINS16c_*` (empty, documented) |
+| `RTINS10` | `Instance.compact()` recursive compaction | only `compactJson()` is exposed publicly; asserted on the JSON form | `InstanceTests.test_RTINS10_compact_recursively_compacts` |
+| `RTINS4d` / `RTINS9c` | `value()`/`size()` return null for the wrong wrapped type | Swift exposes `value`/`size` only on the relevant payload; "returns null" is represented as "not that payload type" | `InstanceTests.test_RTINS4_*` / `test_RTINS9_*` |
+| `RTO15` | `channel.object.publish([...])` sends an `OBJECT` PM and returns a `PublishResult` | `publish` / `PublishResult` are internal RealtimeObject members, not on the public `RealtimeObject` protocol (which exposes only `get()` / `on(...)`); the publish path is covered indirectly via the path-object mutation tests (RTO20) | `RealtimeObjectTests.test_RTO15_publish_sends_object_protocol_message` (empty, documented) |
+
+## Mock Infrastructure Limitations
+
+_None yet._