From 0a207750cc827f6893a8906090f6ab0bc165e16f Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 30 Jul 2026 21:10:30 +0200 Subject: [PATCH 1/3] uts: add AblyLiveObjects dependency to the UTS test target The UTS harness references the LiveObjects plugin's types (ProtocolTypes, DefaultInternalPlugin.ObjectMessageBox) for the `objects` module, so the UTS target must link against AblyLiveObjects. Co-Authored-By: Claude Opus 4.8 --- Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index d6a5e8a29..f7fa24c0c 100644 --- a/Package.swift +++ b/Package.swift @@ -112,7 +112,10 @@ let package = Package( name: "UTS", dependencies: [ .byName(name: "Ably"), - .target(name: "_AblyPluginSupportPrivate") + .target(name: "_AblyPluginSupportPrivate"), + // The `objects` UTS module exercises the LiveObjects plugin, so the harness + // references its types (e.g. ProtocolTypes, DefaultInternalPlugin.ObjectMessageBox). + .target(name: "AblyLiveObjects") ], path: "Test/UTS", exclude: [ From 122c726a28dd2044c0a310ea8e78634647c2b48e Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 30 Jul 2026 21:11:29 +0200 Subject: [PATCH 2/3] uts: add LiveObjects OBJECT/OBJECT_SYNC support to the unit harness Apply the harness changes from ably-liveobjects-swift-plugin PR #135 (commit 9009067) to the UTS unit mock: - ProtocolMessage: OBJECT / OBJECT_SYNC / DETACHED / channel-ERROR builders, the HAS_OBJECTS attach flag, connection-details siteCode/objectsGCGracePeriod, and ack `res` serials. Object state is delivered to ARTProtocolMessage.state via KVC as the plugin's ObjectMessageBox boxes. - MockWebSocket: the onMessageFromClient hook, sentFrames, and provider wiring (used by setup_synced_channel to answer ATTACH with ATTACHED+OBJECT_SYNC and to auto-ACK OBJECT publishes). Co-Authored-By: Claude Opus 4.8 --- Test/UTS/infra/unit/MockWebSocket.swift | 30 +++++- Test/UTS/infra/unit/ProtocolMessage.swift | 111 ++++++++++++++++++---- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/Test/UTS/infra/unit/MockWebSocket.swift b/Test/UTS/infra/unit/MockWebSocket.swift index 9c8a190b7..50a3f77d7 100644 --- a/Test/UTS/infra/unit/MockWebSocket.swift +++ b/Test/UTS/infra/unit/MockWebSocket.swift @@ -16,8 +16,13 @@ import Ably.Private /// (`respondWithSuccess()` + `sendToClient(...)`). final class MockWebSocketProvider: @unchecked Sendable { typealias ConnectionAttemptHandler = @Sendable (MockWebSocket) -> 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? @@ -29,8 +34,10 @@ final class MockWebSocketProvider: @unchecked Sendable { return latestConnection } - init(onConnectionAttempt: ConnectionAttemptHandler? = nil) { + init(onConnectionAttempt: ConnectionAttemptHandler? = nil, + onMessageFromClient: MessageFromClientHandler? = nil) { self.onConnectionAttempt = onConnectionAttempt + self.onMessageFromClient = onMessageFromClient } fileprivate func register(_ socket: MockWebSocket) { @@ -42,6 +49,10 @@ final class MockWebSocketProvider: @unchecked Sendable { 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 @@ -74,10 +85,23 @@ final class MockWebSocket: NSObject, ARTWebSocket, @unchecked Sendable { 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 @@ -103,7 +127,10 @@ final class MockWebSocket: NSObject, ARTWebSocket, @unchecked Sendable { 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 @@ -202,6 +229,7 @@ final class MockWebSocketFactory: NSObject, WebSocketFactory { 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 diff --git a/Test/UTS/infra/unit/ProtocolMessage.swift b/Test/UTS/infra/unit/ProtocolMessage.swift index ad8e56ece..a5f02ce26 100644 --- a/Test/UTS/infra/unit/ProtocolMessage.swift +++ b/Test/UTS/infra/unit/ProtocolMessage.swift @@ -1,17 +1,30 @@ 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) - case attached(channel: String, channelSerial: String) + 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) + 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 @@ -27,13 +40,16 @@ struct ProtocolMessage: Sendable { static func connected(connectionId: String, connectionKey: String, maxIdleInterval: TimeInterval = 15, - connectionStateTtl: TimeInterval = 120) -> ProtocolMessage { - .init(kind: .connected(connectionId: connectionId, connectionKey: connectionKey, maxIdleInterval: maxIdleInterval, connectionStateTtl: connectionStateTtl)) + 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, ...)`). - static func attached(channel: String, channelSerial: String) -> ProtocolMessage { - .init(kind: .attached(channel: channel, channelSerial: channelSerial)) + /// 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(...))`). @@ -41,9 +57,11 @@ struct ProtocolMessage: Sendable { .init(kind: .error(code: code, statusCode: statusCode, message: message)) } - /// An `ACK` message (UTS `ProtocolMessage(action: ACK, msgSerial: ..., count: ...)`). - static func ack(msgSerial: Int, count: Int) -> ProtocolMessage { - .init(kind: .ack(msgSerial: msgSerial, count: count)) + /// 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)`). @@ -51,11 +69,32 @@ struct ProtocolMessage: Sendable { .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): + case let .connected(connectionId, connectionKey, maxIdleInterval, connectionStateTtl, siteCode, objectsGCGracePeriod): message.action = .connected message.connectionId = connectionId message.connectionKey = connectionKey @@ -68,23 +107,63 @@ struct ProtocolMessage: Sendable { connectionStateTtl: connectionStateTtl, serverId: "", maxIdleInterval: maxIdleInterval, - objectsGCGracePeriod: nil, - siteCode: nil + objectsGCGracePeriod: objectsGCGracePeriod.map { NSNumber(value: $0) }, + siteCode: siteCode ) - case let .attached(channel, channelSerial): + 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): + 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") + } +} From c2277a71476fcf71c8c50610fd27dcfc67a60684 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 30 Jul 2026 21:18:58 +0200 Subject: [PATCH 3/3] uts: add LiveObjects unit-harness helpers (StandardTestPool, setup_synced_channel) Port the LiveObjects UTS helpers from ably-liveobjects-swift-plugin PR #135 (commit 9009067) into Test/UTS/infra/unit: - StandardTestPool: canonical object-tree fixtures and OBJECT/OBJECT_SYNC message builders (uts/objects/helpers/standard_test_pool.md). - UTSTestCase+LiveObjects: the setup_synced_channel pattern (connected client + synced objects channel via the mock) and sent-operation inspection. Co-Authored-By: Claude Opus 4.8 --- Test/UTS/infra/unit/StandardTestPool.swift | 217 ++++++++++++++++++ .../infra/unit/UTSTestCase+LiveObjects.swift | 130 +++++++++++ 2 files changed, 347 insertions(+) create mode 100644 Test/UTS/infra/unit/StandardTestPool.swift create mode 100644 Test/UTS/infra/unit/UTSTestCase+LiveObjects.swift diff --git a/Test/UTS/infra/unit/StandardTestPool.swift b/Test/UTS/infra/unit/StandardTestPool.swift new file mode 100644 index 000000000..be66f8b7c --- /dev/null +++ b/Test/UTS/infra/unit/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/Test/UTS/infra/unit/UTSTestCase+LiveObjects.swift b/Test/UTS/infra/unit/UTSTestCase+LiveObjects.swift new file mode 100644 index 000000000..3b60ac7af --- /dev/null +++ b/Test/UTS/infra/unit/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 +}