From ee117f0c9bee036c9c80bf3287be9bdf31529cb0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 14 May 2026 15:12:00 +0100 Subject: [PATCH 1/5] uts: add basic unit test for `ClientOptions` and setup test module --- settings.gradle.kts | 1 + uts/build.gradle.kts | 23 +++++++++++++++++++ uts/src/test/kotlin/io/ably/lib/SampleTest.kt | 13 +++++++++++ 3 files changed, 37 insertions(+) create mode 100644 uts/build.gradle.kts create mode 100644 uts/src/test/kotlin/io/ably/lib/SampleTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 848b36749..dfd7150f4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ include("network-client-okhttp") include("pubsub-adapter") include("liveobjects") include("examples") +include("uts") diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts new file mode 100644 index 000000000..aa9e1d4c6 --- /dev/null +++ b/uts/build.gradle.kts @@ -0,0 +1,23 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +dependencies { + testImplementation(project(":java")) + testImplementation(kotlin("test")) + testImplementation(libs.mockk) + testImplementation(libs.coroutine.test) +} + +tasks.withType().configureEach { + useJUnitPlatform() + testLogging { + exceptionFormat = TestExceptionFormat.FULL + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} diff --git a/uts/src/test/kotlin/io/ably/lib/SampleTest.kt b/uts/src/test/kotlin/io/ably/lib/SampleTest.kt new file mode 100644 index 000000000..86173f5c3 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/SampleTest.kt @@ -0,0 +1,13 @@ +package io.ably.lib + +import io.ably.lib.types.ClientOptions +import kotlin.test.Test +import kotlin.test.assertNotNull + +class SampleTest { + @Test + fun `ClientOptions can be instantiated`() { + val options = ClientOptions("test-key") + assertNotNull(options) + } +} From d4271e2f5c2308376d26b60a153d79b5e1dfc97c Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 14 May 2026 16:31:00 +0100 Subject: [PATCH 2/5] uts: introduce mock HTTP and WebSocket engines for testing - Added `MockHttpClient`, `MockHttpEngine`, and `MockWebSocketEngineFactory` to simulate network interactions. - Extended `DebugOptions` for customizable engine injection. - Updated `HttpCore` and `WebSocketTransport` to support mock engines in debug mode. --- .../java/io/ably/lib/debug/DebugOptions.java | 6 +++ .../main/java/io/ably/lib/http/HttpCore.java | 10 +++-- .../lib/transport/WebSocketTransport.java | 8 +++- uts/build.gradle.kts | 2 + .../io/ably/lib/test/mock/MockHttpClient.kt | 38 ++++++++++++++++ .../io/ably/lib/test/mock/MockHttpEngine.kt | 40 +++++++++++++++++ .../test/mock/MockWebSocketEngineFactory.kt | 44 ++++++++++++++++++ .../ably/lib/test/mock/PendingConnection.kt | 11 +++++ .../lib/test/mock/PendingConnectionImpl.kt | 18 ++++++++ .../io/ably/lib/test/mock/PendingRequest.kt | 13 ++++++ .../ably/lib/test/mock/PendingRequestImpl.kt | 45 +++++++++++++++++++ 11 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequestImpl.kt diff --git a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java index 984e73a5f..83aa0afdc 100644 --- a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java +++ b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java @@ -4,7 +4,9 @@ import java.util.Map; import io.ably.lib.http.HttpCore; +import io.ably.lib.network.HttpEngine; import io.ably.lib.network.HttpRequest; +import io.ably.lib.network.WebSocketEngineFactory; import io.ably.lib.transport.ITransport; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; @@ -31,12 +33,16 @@ public interface RawHttpListener { public RawProtocolListener protocolListener; public RawHttpListener httpListener; public ITransport.Factory transportFactory; + public HttpEngine httpEngine; + public WebSocketEngineFactory webSocketEngineFactory; public DebugOptions copy() { DebugOptions copied = new DebugOptions(); copied.protocolListener = protocolListener; copied.httpListener = httpListener; copied.transportFactory = transportFactory; + copied.httpEngine = httpEngine; + copied.webSocketEngineFactory = webSocketEngineFactory; copied.clientId = clientId; copied.logLevel = logLevel; copied.logHandler = logHandler; diff --git a/lib/src/main/java/io/ably/lib/http/HttpCore.java b/lib/src/main/java/io/ably/lib/http/HttpCore.java index a6e102404..2ba87d453 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpCore.java +++ b/lib/src/main/java/io/ably/lib/http/HttpCore.java @@ -108,9 +108,13 @@ public HttpCore(ClientOptions options, Auth auth, PlatformAgentProvider platform proxyAuth = new HttpAuth(proxyUser, proxyPassword, proxyOptions.prefAuthType); } } - HttpEngineFactory engineFactory = HttpEngineFactory.getFirstAvailable(); - Log.v(TAG, String.format("Using %s HTTP Engine", engineFactory.getEngineType().name())); - this.engine = engineFactory.create(new HttpEngineConfig(ClientOptionsUtils.convertToProxyConfig(options))); + if (options instanceof DebugOptions && ((DebugOptions) options).httpEngine != null) { + this.engine = ((DebugOptions) options).httpEngine; + } else { + HttpEngineFactory engineFactory = HttpEngineFactory.getFirstAvailable(); + Log.v(TAG, String.format("Using %s HTTP Engine", engineFactory.getEngineType().name())); + this.engine = engineFactory.create(new HttpEngineConfig(ClientOptionsUtils.convertToProxyConfig(options))); + } } private HttpCore(HttpCore underlyingHttpCore, Map dynamicAgents) { diff --git a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java index 6d7c087f0..22b72505f 100644 --- a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java +++ b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java @@ -1,5 +1,6 @@ package io.ably.lib.transport; +import io.ably.lib.debug.DebugOptions; import io.ably.lib.http.HttpUtils; import io.ably.lib.network.EngineType; import io.ably.lib.network.NotConnectedException; @@ -70,7 +71,12 @@ protected WebSocketTransport(TransportParams params, ConnectionManager connectio } private static WebSocketEngine createWebSocketEngine(TransportParams params) { - WebSocketEngineFactory engineFactory = WebSocketEngineFactory.getFirstAvailable(); + WebSocketEngineFactory engineFactory; + if (params.options instanceof DebugOptions && ((DebugOptions) params.options).webSocketEngineFactory != null) { + engineFactory = ((DebugOptions) params.options).webSocketEngineFactory; + } else { + engineFactory = WebSocketEngineFactory.getFirstAvailable(); + } Log.v(TAG, String.format("Using %s WebSocket Engine", engineFactory.getEngineType().name())); WebSocketEngineConfig.WebSocketEngineConfigBuilder configBuilder = WebSocketEngineConfig.builder(); configBuilder diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index aa9e1d4c6..e692237df 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -6,8 +6,10 @@ plugins { dependencies { testImplementation(project(":java")) + testImplementation(project(":network-client-core")) testImplementation(kotlin("test")) testImplementation(libs.mockk) + testImplementation(libs.coroutine.core) testImplementation(libs.coroutine.test) } diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt new file mode 100644 index 000000000..4d4fcd996 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt @@ -0,0 +1,38 @@ +package io.ably.lib.test.mock + +import io.ably.lib.debug.DebugOptions +import io.ably.lib.network.HttpEngine +import io.ably.lib.network.WebSocketEngineFactory +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class MockHttpClient { + private var _pendingConnections = Channel(Channel.UNLIMITED) + private var _pendingRequests = Channel(Channel.UNLIMITED) + + val httpEngine: HttpEngine = + MockHttpEngine { _pendingRequests.trySend(it) } + + val webSocketEngineFactory: WebSocketEngineFactory = + MockWebSocketEngineFactory { _pendingConnections.trySend(it) } + + fun installOn(options: DebugOptions) { + options.httpEngine = httpEngine + options.webSocketEngineFactory = webSocketEngineFactory + } + + suspend fun awaitRequest(timeout: Duration = 5.seconds): PendingRequest = + withTimeout(timeout) { _pendingRequests.receive() } + + suspend fun awaitConnectionAttempt(timeout: Duration = 5.seconds): PendingConnection = + withTimeout(timeout) { _pendingConnections.receive() } + + fun reset() { + _pendingConnections.close() + _pendingRequests.close() + _pendingConnections = Channel(Channel.UNLIMITED) + _pendingRequests = Channel(Channel.UNLIMITED) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt new file mode 100644 index 000000000..2cf3749b0 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt @@ -0,0 +1,40 @@ +package io.ably.lib.test.mock + +import io.ably.lib.network.FailedConnectionException +import io.ably.lib.network.HttpCall +import io.ably.lib.network.HttpEngine +import io.ably.lib.network.HttpRequest +import io.ably.lib.network.HttpResponse +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException + +internal class MockHttpEngine(private val onRequest: (PendingRequest) -> Unit) : HttpEngine { + override fun call(request: HttpRequest): HttpCall = MockHttpCall(request, onRequest) + override fun isUsingProxy() = false +} + +internal class MockHttpCall( + private val request: HttpRequest, + private val onRequest: (PendingRequest) -> Unit, +) : HttpCall { + private val future = CompletableFuture() + + override fun execute(): HttpResponse { + val pending = PendingRequestImpl(request, future) + onRequest(pending) + return try { + future.get() + } catch (e: ExecutionException) { + val cause = e.cause + if (cause is FailedConnectionException) throw cause + throw FailedConnectionException(cause ?: e) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw FailedConnectionException(e) + } + } + + override fun cancel() { + future.cancel(true) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt new file mode 100644 index 000000000..f7f9e8679 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt @@ -0,0 +1,44 @@ +package io.ably.lib.test.mock + +import io.ably.lib.network.EngineType +import io.ably.lib.network.WebSocketClient +import io.ably.lib.network.WebSocketEngine +import io.ably.lib.network.WebSocketEngineConfig +import io.ably.lib.network.WebSocketEngineFactory +import io.ably.lib.network.WebSocketListener +import java.net.URI + +internal class MockWebSocketEngineFactory( + private val onConnect: (PendingConnection) -> Unit, +) : WebSocketEngineFactory { + override fun create(config: WebSocketEngineConfig): WebSocketEngine = MockWebSocketEngine(onConnect) + override fun getEngineType(): EngineType = EngineType.DEFAULT +} + +internal class MockWebSocketEngine( + private val onConnect: (PendingConnection) -> Unit, +) : WebSocketEngine { + override fun create(url: String, listener: WebSocketListener): WebSocketClient = + MockWebSocketClient(url, listener, onConnect) + + override fun isPingListenerSupported() = false +} + +internal class MockWebSocketClient( + private val url: String, + private val listener: WebSocketListener, + private val onConnect: (PendingConnection) -> Unit, +) : WebSocketClient { + override fun connect() { + val uri = URI(url.substringBefore('?')) + val tls = uri.scheme == "wss" + val port = if (uri.port == -1) (if (tls) 443 else 80) else uri.port + onConnect(PendingConnectionImpl(uri.host, port, tls, listener)) + } + + override fun close() {} + override fun close(code: Int, reason: String) {} + override fun cancel(code: Int, reason: String) {} + override fun send(message: ByteArray) {} + override fun send(message: String) {} +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt new file mode 100644 index 000000000..48ff4769f --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt @@ -0,0 +1,11 @@ +package io.ably.lib.test.mock + +interface PendingConnection { + val host: String + val port: Int + val tls: Boolean + fun respondWithSuccess() + fun respondWithRefused() + fun respondWithTimeout() + fun respondWithDnsError() +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt new file mode 100644 index 000000000..56d689c84 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt @@ -0,0 +1,18 @@ +package io.ably.lib.test.mock + +import io.ably.lib.network.WebSocketListener +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +internal class PendingConnectionImpl( + override val host: String, + override val port: Int, + override val tls: Boolean, + private val listener: WebSocketListener, +) : PendingConnection { + override fun respondWithSuccess() = listener.onOpen() + override fun respondWithRefused() = listener.onError(IOException("Connection refused to $host:$port")) + override fun respondWithTimeout() = listener.onError(SocketTimeoutException("Connection timed out to $host:$port")) + override fun respondWithDnsError() = listener.onError(UnknownHostException(host)) +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt new file mode 100644 index 000000000..ece6b4bd4 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt @@ -0,0 +1,13 @@ +package io.ably.lib.test.mock + +import java.time.Duration + +interface PendingRequest { + val url: java.net.URL + val method: String + val headers: Map> + val body: ByteArray + fun respondWith(status: Int, body: Any, headers: Map = emptyMap()) + fun respondWithDelay(delay: Duration, status: Int, body: Any) + fun respondWithTimeout() +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequestImpl.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequestImpl.kt new file mode 100644 index 000000000..2db5eeb6b --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequestImpl.kt @@ -0,0 +1,45 @@ +package io.ably.lib.test.mock + +import io.ably.lib.network.FailedConnectionException +import io.ably.lib.network.HttpBody +import io.ably.lib.network.HttpRequest +import io.ably.lib.network.HttpResponse +import java.net.SocketTimeoutException +import java.time.Duration +import java.util.concurrent.CompletableFuture + +internal class PendingRequestImpl( + private val request: HttpRequest, + private val future: CompletableFuture, +) : PendingRequest { + override val url get() = request.url + override val method get() = request.method + override val headers: Map> get() = request.headers ?: emptyMap() + override val body get() = request.body?.content ?: ByteArray(0) + + override fun respondWith(status: Int, body: Any, headers: Map) { + val bytes = when (body) { + is ByteArray -> body + else -> body.toString().toByteArray(Charsets.UTF_8) + } + future.complete( + HttpResponse.builder() + .code(status) + .message("") + .body(HttpBody("application/json", bytes)) + .headers(emptyMap()) + .build() + ) + } + + override fun respondWithDelay(delay: Duration, status: Int, body: Any) { + Thread { + Thread.sleep(delay.toMillis()) + respondWith(status, body) + }.start() + } + + override fun respondWithTimeout() { + future.completeExceptionally(FailedConnectionException(SocketTimeoutException("Connection timed out"))) + } +} From edb54a47dae34c229dc419dc39a1467864647385 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 May 2026 11:18:18 +0100 Subject: [PATCH 3/5] uts: extend WebSocket mock with connection lifecycle events and message handling - Added `MockWebSocket` and `MockEvent` to capture WebSocket connection attempts, messages, and error scenarios. - Added connection lifecycle events (`ConnectionEstablished`, `ConnectionRefused`, etc.) for enhanced testing. - Updated `MockWebSocketEngineFactory` and related components to support event tracking and simulation. --- .../kotlin/io/ably/lib/test/mock/MockEvent.kt | 14 +++ .../io/ably/lib/test/mock/MockHttpClient.kt | 2 +- .../io/ably/lib/test/mock/MockWebSocket.kt | 106 ++++++++++++++++++ .../test/mock/MockWebSocketEngineFactory.kt | 11 +- .../lib/test/mock/PendingConnectionImpl.kt | 7 +- 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt new file mode 100644 index 000000000..d7ae24d80 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt @@ -0,0 +1,14 @@ +package io.ably.lib.test.mock + +import io.ably.lib.types.ProtocolMessage + +sealed class MockEvent { + data class ConnectionAttempt(val host: String, val port: Int, val tls: Boolean) : MockEvent() + data object ConnectionEstablished : MockEvent() + data object ConnectionRefused : MockEvent() + data object ConnectionTimeout : MockEvent() + data object DnsError : MockEvent() + data class HttpRequest(val url: java.net.URL, val method: String) : MockEvent() + data class SentToClient(val message: ProtocolMessage) : MockEvent() + data object Disconnected : MockEvent() +} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt index 4d4fcd996..7611dc975 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt @@ -16,7 +16,7 @@ class MockHttpClient { MockHttpEngine { _pendingRequests.trySend(it) } val webSocketEngineFactory: WebSocketEngineFactory = - MockWebSocketEngineFactory { _pendingConnections.trySend(it) } + MockWebSocketEngineFactory(onConnect = { _pendingConnections.trySend(it) }) fun installOn(options: DebugOptions) { options.httpEngine = httpEngine diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt new file mode 100644 index 000000000..dfe4ed6da --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt @@ -0,0 +1,106 @@ +package io.ably.lib.test.mock + +import io.ably.lib.debug.DebugOptions +import io.ably.lib.network.HttpEngine +import io.ably.lib.network.WebSocketEngineFactory +import io.ably.lib.network.WebSocketListener +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.ProtocolSerializer +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout +import java.nio.ByteBuffer +import java.util.Collections +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class MockWebSocket { + private val _events = Collections.synchronizedList(mutableListOf()) + val events: List get() = _events.toList() + + private var _pendingConnections = Channel(Channel.UNLIMITED) + private var _pendingRequests = Channel(Channel.UNLIMITED) + + @Volatile private var activeListener: WebSocketListener? = null + + val httpEngine: HttpEngine = MockHttpEngine { req -> + _events.add(MockEvent.HttpRequest(req.url, req.method)) + _pendingRequests.trySend(EventTrackingPendingRequest(req, _events)) + } + + val webSocketEngineFactory: WebSocketEngineFactory = MockWebSocketEngineFactory( + onConnect = { pending -> + _events.add(MockEvent.ConnectionAttempt(pending.host, pending.port, pending.tls)) + _pendingConnections.trySend(EventTrackingPendingConnection(pending, _events)) + }, + onConnected = { listener -> + _events.add(MockEvent.ConnectionEstablished) + activeListener = listener + }, + ) + + fun installOn(options: DebugOptions) { + options.httpEngine = httpEngine + options.webSocketEngineFactory = webSocketEngineFactory + } + + suspend fun awaitRequest(timeout: Duration = 5.seconds): PendingRequest = + withTimeout(timeout) { _pendingRequests.receive() } + + suspend fun awaitConnectionAttempt(timeout: Duration = 5.seconds): PendingConnection = + withTimeout(timeout) { _pendingConnections.receive() } + + fun sendToClient(message: ProtocolMessage) { + val listener = checkNotNull(activeListener) { "No active WebSocket connection" } + _events.add(MockEvent.SentToClient(message)) + listener.onMessage(ByteBuffer.wrap(ProtocolSerializer.writeMsgpack(message))) + } + + fun sendToClientAndClose(message: ProtocolMessage) { + val listener = checkNotNull(activeListener) { "No active WebSocket connection" } + _events.add(MockEvent.SentToClient(message)) + listener.onMessage(ByteBuffer.wrap(ProtocolSerializer.writeMsgpack(message))) + activeListener = null + listener.onClose(1000, "Normal closure") + } + + fun simulateDisconnect() { + val listener = checkNotNull(activeListener) { "No active WebSocket connection" } + _events.add(MockEvent.Disconnected) + activeListener = null + listener.onClose(1006, "Abnormal closure") + } + + fun reset() { + _pendingConnections.close() + _pendingRequests.close() + _pendingConnections = Channel(Channel.UNLIMITED) + _pendingRequests = Channel(Channel.UNLIMITED) + _events.clear() + activeListener = null + } +} + +private class EventTrackingPendingConnection( + private val inner: PendingConnection, + private val events: MutableList, +) : PendingConnection by inner { + override fun respondWithRefused() { + events.add(MockEvent.ConnectionRefused) + inner.respondWithRefused() + } + + override fun respondWithTimeout() { + events.add(MockEvent.ConnectionTimeout) + inner.respondWithTimeout() + } + + override fun respondWithDnsError() { + events.add(MockEvent.DnsError) + inner.respondWithDnsError() + } +} + +private class EventTrackingPendingRequest( + private val inner: PendingRequest, + private val events: MutableList, +) : PendingRequest by inner diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt index f7f9e8679..6d3750c3a 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocketEngineFactory.kt @@ -10,16 +10,20 @@ import java.net.URI internal class MockWebSocketEngineFactory( private val onConnect: (PendingConnection) -> Unit, + private val onConnected: (WebSocketListener) -> Unit = {}, ) : WebSocketEngineFactory { - override fun create(config: WebSocketEngineConfig): WebSocketEngine = MockWebSocketEngine(onConnect) + override fun create(config: WebSocketEngineConfig): WebSocketEngine = + MockWebSocketEngine(onConnect, onConnected) + override fun getEngineType(): EngineType = EngineType.DEFAULT } internal class MockWebSocketEngine( private val onConnect: (PendingConnection) -> Unit, + private val onConnected: (WebSocketListener) -> Unit, ) : WebSocketEngine { override fun create(url: String, listener: WebSocketListener): WebSocketClient = - MockWebSocketClient(url, listener, onConnect) + MockWebSocketClient(url, listener, onConnect, onConnected) override fun isPingListenerSupported() = false } @@ -28,12 +32,13 @@ internal class MockWebSocketClient( private val url: String, private val listener: WebSocketListener, private val onConnect: (PendingConnection) -> Unit, + private val onConnected: (WebSocketListener) -> Unit, ) : WebSocketClient { override fun connect() { val uri = URI(url.substringBefore('?')) val tls = uri.scheme == "wss" val port = if (uri.port == -1) (if (tls) 443 else 80) else uri.port - onConnect(PendingConnectionImpl(uri.host, port, tls, listener)) + onConnect(PendingConnectionImpl(uri.host, port, tls, listener, onConnected)) } override fun close() {} diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt index 56d689c84..25400371d 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt @@ -10,8 +10,13 @@ internal class PendingConnectionImpl( override val port: Int, override val tls: Boolean, private val listener: WebSocketListener, + private val onConnected: (WebSocketListener) -> Unit = {}, ) : PendingConnection { - override fun respondWithSuccess() = listener.onOpen() + override fun respondWithSuccess() { + listener.onOpen() + onConnected(listener) + } + override fun respondWithRefused() = listener.onError(IOException("Connection refused to $host:$port")) override fun respondWithTimeout() = listener.onError(SocketTimeoutException("Connection timed out to $host:$port")) override fun respondWithDnsError() = listener.onError(UnknownHostException(host)) From c56d084a59872ae30df4989d258f5c22453cd6a2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 May 2026 13:32:56 +0100 Subject: [PATCH 4/5] uts: introduce `Clock` abstraction for time operations and testability - Added `Clock` interface and concrete implementations (`SystemClock` and `FakeClock`) for unified time management. - Refactored classes (`Auth`, `Presence`, `Hosts`, `WebSocketTransport`, etc.) to use `Clock` instead of direct system calls. - Enabled mockable time-based operations for improved testability. - Updated `DebugOptions` to support custom clocks in debug mode. --- .../java/io/ably/lib/debug/DebugOptions.java | 3 + .../java/io/ably/lib/http/HttpScheduler.java | 8 ++- .../io/ably/lib/realtime/ChannelBase.java | 33 ++++++----- .../java/io/ably/lib/realtime/Presence.java | 2 +- lib/src/main/java/io/ably/lib/rest/Auth.java | 11 +++- .../ably/lib/transport/ConnectionManager.java | 10 +++- .../java/io/ably/lib/transport/Hosts.java | 14 +++-- .../lib/transport/WebSocketTransport.java | 31 +++++----- lib/src/main/java/io/ably/lib/util/Clock.java | 6 ++ .../java/io/ably/lib/util/NamedTimer.java | 8 +++ .../java/io/ably/lib/util/SystemClock.java | 41 ++++++++++++++ .../java/io/ably/lib/util/TimerInstance.java | 6 ++ .../kotlin/io/ably/lib/objects/ServerTime.kt | 6 +- .../lib/objects/type/BaseRealtimeObject.kt | 7 ++- .../type/livecounter/DefaultLiveCounter.kt | 3 +- .../objects/type/livemap/DefaultLiveMap.kt | 5 +- .../lib/objects/type/livemap/LiveMapEntry.kt | 5 +- .../objects/type/livemap/LiveMapManager.kt | 4 +- .../kotlin/io/ably/lib/test/mock/FakeClock.kt | 56 +++++++++++++++++++ 19 files changed, 206 insertions(+), 53 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/util/Clock.java create mode 100644 lib/src/main/java/io/ably/lib/util/NamedTimer.java create mode 100644 lib/src/main/java/io/ably/lib/util/SystemClock.java create mode 100644 lib/src/main/java/io/ably/lib/util/TimerInstance.java create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt diff --git a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java index 83aa0afdc..43a212009 100644 --- a/lib/src/main/java/io/ably/lib/debug/DebugOptions.java +++ b/lib/src/main/java/io/ably/lib/debug/DebugOptions.java @@ -11,6 +11,7 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.util.Clock; public class DebugOptions extends ClientOptions { public interface RawProtocolListener { @@ -35,6 +36,7 @@ public interface RawHttpListener { public ITransport.Factory transportFactory; public HttpEngine httpEngine; public WebSocketEngineFactory webSocketEngineFactory; + public Clock clock; public DebugOptions copy() { DebugOptions copied = new DebugOptions(); @@ -43,6 +45,7 @@ public DebugOptions copy() { copied.transportFactory = transportFactory; copied.httpEngine = httpEngine; copied.webSocketEngineFactory = webSocketEngineFactory; + copied.clock = clock; copied.clientId = clientId; copied.logLevel = logLevel; copied.logHandler = logHandler; diff --git a/lib/src/main/java/io/ably/lib/http/HttpScheduler.java b/lib/src/main/java/io/ably/lib/http/HttpScheduler.java index bce8f5bf9..38157bc4e 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpScheduler.java +++ b/lib/src/main/java/io/ably/lib/http/HttpScheduler.java @@ -13,7 +13,9 @@ import io.ably.lib.types.Callback; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.Param; +import io.ably.lib.util.Clock; import io.ably.lib.util.Log; +import io.ably.lib.util.SystemClock; /** * HttpScheduler schedules HttpCore operations to an Executor, exposing a generic async API. @@ -286,12 +288,12 @@ public T get() throws InterruptedException, ExecutionException { } @Override public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { - long remaining = unit.toMillis(timeout), deadline = System.currentTimeMillis() + remaining; + long remaining = unit.toMillis(timeout), deadline = clock.currentTimeMillis() + remaining; synchronized(this) { while(remaining > 0) { wait(remaining); if(isDone) { break; } - remaining = deadline - System.currentTimeMillis(); + remaining = deadline - clock.currentTimeMillis(); } if(!isDone) { throw new TimeoutException(); @@ -360,6 +362,7 @@ protected synchronized boolean disposeConnection() { protected HttpScheduler(HttpCore httpCore, CloseableExecutor executor) { this.httpCore = httpCore; this.executor = executor; + this.clock = SystemClock.clockFrom(httpCore.options); } @Override @@ -446,6 +449,7 @@ public Future ablyHttpExecuteWithRetry( protected final CloseableExecutor executor; private final HttpCore httpCore; + private final Clock clock; protected static final String TAG = HttpScheduler.class.getName(); diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index 2769893fa..effd5fa93 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -6,7 +6,6 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; @@ -46,12 +45,15 @@ import io.ably.lib.types.PublishResult; import io.ably.lib.types.Summary; import io.ably.lib.types.UpdateDeleteResult; +import io.ably.lib.util.Clock; import io.ably.lib.util.CollectionUtils; import io.ably.lib.util.EventEmitter; import io.ably.lib.util.Listeners; import io.ably.lib.util.Log; +import io.ably.lib.util.NamedTimer; import io.ably.lib.util.ReconnectionStrategy; import io.ably.lib.util.StringUtils; +import io.ably.lib.util.SystemClock; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.Nullable; @@ -508,21 +510,20 @@ private void setFailed(ErrorInfo reason) { } /* Timer for attach operation */ - private Timer attachTimer; + private NamedTimer attachTimer; /* Timer for reattaching if attach failed */ - private Timer reattachTimer; + private NamedTimer reattachTimer; /** * Cancel attach/reattach timers */ synchronized private void clearAttachTimers() { - Timer[] timers = new Timer[]{attachTimer, reattachTimer}; + NamedTimer[] timers = new NamedTimer[]{attachTimer, reattachTimer}; attachTimer = reattachTimer = null; - for (Timer t: timers) { + for (NamedTimer t: timers) { if (t != null) { t.cancel(); - t.purge(); } } } @@ -537,9 +538,9 @@ private void attachWithTimeout(final CompletionListener listener) throws AblyExc */ synchronized private void attachWithTimeout(final boolean forceReattach, final CompletionListener listener, ErrorInfo reattachmentReason) { checkChannelIsNotReleased(); - Timer currentAttachTimer; + NamedTimer currentAttachTimer; try { - currentAttachTimer = new Timer(); + currentAttachTimer = clock.newTimer("attach-timer"); } catch(Throwable t) { /* an exception instancing the timer can arise because the runtime is exiting */ callCompletionListenerError(listener, ErrorInfo.fromThrowable(t)); @@ -571,7 +572,7 @@ public void onError(ErrorInfo reason) { return; } - final Timer inProgressTimer = currentAttachTimer; + final NamedTimer inProgressTimer = currentAttachTimer; attachTimer.schedule( new TimerTask() { @Override @@ -601,9 +602,9 @@ private void checkChannelIsNotReleased() { * try to attach the channel */ synchronized private void reattachAfterTimeout() { - Timer currentReattachTimer; + NamedTimer currentReattachTimer; try { - currentReattachTimer = new Timer(); + currentReattachTimer = clock.newTimer("reattach-timer"); } catch(Throwable t) { /* an exception instancing the timer can arise because the runtime is exiting */ return; @@ -613,7 +614,7 @@ synchronized private void reattachAfterTimeout() { this.retryAttempt++; int retryDelay = ReconnectionStrategy.getRetryTime(ably.options.channelRetryTimeout, retryAttempt); - final Timer inProgressTimer = currentReattachTimer; + final NamedTimer inProgressTimer = currentReattachTimer; reattachTimer.schedule(new TimerTask() { @Override public void run() { @@ -640,9 +641,9 @@ public void run() { */ synchronized private void detachWithTimeout(final CompletionListener listener) { final ChannelState originalState = state; - Timer currentDetachTimer; + NamedTimer currentDetachTimer; try { - currentDetachTimer = released.get() ? null : new Timer(); + currentDetachTimer = released.get() ? null : clock.newTimer("detach-timer"); } catch(Throwable t) { /* an exception instancing the timer can arise because the runtime is exiting */ callCompletionListenerError(listener, ErrorInfo.fromThrowable(t)); @@ -676,7 +677,7 @@ public void onError(ErrorInfo reason) { return; } - final Timer inProgressTimer = currentDetachTimer; + final NamedTimer inProgressTimer = currentDetachTimer; attachTimer.schedule(new TimerTask() { @Override public void run() { @@ -1684,6 +1685,7 @@ else if(stateChange.current.equals(failureState)) { ChannelBase(AblyRealtime ably, String name, ChannelOptions options, @Nullable LiveObjectsPlugin liveObjectsPlugin) throws AblyException { Log.v(TAG, "RealtimeChannel(); channel = " + name); this.ably = ably; + this.clock = SystemClock.clockFrom(ably.options); this.name = name; this.basePath = "/channels/" + HttpUtils.encodeURIComponent(name); this.setOptions(options); @@ -1808,6 +1810,7 @@ public void sendProtocolMessage(ProtocolMessage protocolMessage, CompletionListe private static final String TAG = Channel.class.getName(); final AblyRealtime ably; + final Clock clock; final String basePath; ChannelOptions options; /** diff --git a/lib/src/main/java/io/ably/lib/realtime/Presence.java b/lib/src/main/java/io/ably/lib/realtime/Presence.java index 9a7e89e7e..98b7a1ffe 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Presence.java +++ b/lib/src/main/java/io/ably/lib/realtime/Presence.java @@ -331,7 +331,7 @@ private void endSync() { for (PresenceMessage member: residualMembers) { // RTP19 member.action = PresenceMessage.Action.leave; member.id = null; - member.timestamp = System.currentTimeMillis(); + member.timestamp = channel.clock.currentTimeMillis(); } broadcastPresence(residualMembers); } diff --git a/lib/src/main/java/io/ably/lib/rest/Auth.java b/lib/src/main/java/io/ably/lib/rest/Auth.java index 4abc7c68b..c26b1b3e6 100644 --- a/lib/src/main/java/io/ably/lib/rest/Auth.java +++ b/lib/src/main/java/io/ably/lib/rest/Auth.java @@ -27,8 +27,10 @@ import io.ably.lib.types.NonRetriableTokenException; import io.ably.lib.types.Param; import io.ably.lib.util.Base64Coder; +import io.ably.lib.util.Clock; import io.ably.lib.util.Log; import io.ably.lib.util.Serialisation; +import io.ably.lib.util.SystemClock; /** * Token-generation and authentication operations for the Ably API. @@ -921,7 +923,7 @@ else if(!request.keyName.equals(keyName)) if(request.timestamp == 0) { if(options.queryTime) { long oldNanoTimeDelta = nanoTimeDelta; - long currentNanoTimeDelta = System.currentTimeMillis() - System.nanoTime()/(1000*1000); + long currentNanoTimeDelta = clock.currentTimeMillis() - System.nanoTime()/(1000*1000); if (timeDelta != Long.MAX_VALUE) { /* system time changed by more than 500ms since last time? */ @@ -1036,7 +1038,7 @@ public void onAuthError(ErrorInfo err) { clearTokenDetails(); } - public static long timestamp() { return System.currentTimeMillis(); } + public long timestamp() { return clock.currentTimeMillis(); } /******************** * internal @@ -1050,6 +1052,8 @@ public void onAuthError(ErrorInfo err) { */ Auth(AblyBase ably, ClientOptions options) throws AblyException { this.ably = ably; + this.clock = SystemClock.clockFrom(options); + this.nanoTimeDelta = clock.currentTimeMillis() - System.nanoTime()/(1000*1000); authOptions = options; tokenParams = options.defaultTokenParams != null ? options.defaultTokenParams : new TokenParams(); @@ -1304,6 +1308,7 @@ public long serverTimestamp() { private static final String TAG = Auth.class.getName(); private final AblyBase ably; + private final Clock clock; private final AuthMethod method; private AuthOptions authOptions; private TokenParams tokenParams; @@ -1320,7 +1325,7 @@ public long serverTimestamp() { * Time delta between System.nanoTime() and System.currentTimeMillis. If it changes significantly it * suggests device time/date has changed */ - private long nanoTimeDelta = System.currentTimeMillis() - System.nanoTime()/(1000*1000); + private long nanoTimeDelta; public static final String WILDCARD_CLIENTID = "*"; /** diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index 6367b77bb..01d9f0e98 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -36,9 +36,11 @@ import io.ably.lib.types.ProtocolMessage; import io.ably.lib.types.ProtocolSerializer; import io.ably.lib.types.PublishResult; +import io.ably.lib.util.Clock; import io.ably.lib.util.Log; import io.ably.lib.util.PlatformAgentProvider; import io.ably.lib.util.ReconnectionStrategy; +import io.ably.lib.util.SystemClock; import org.jetbrains.annotations.Nullable; public class ConnectionManager implements ConnectListener { @@ -782,6 +784,7 @@ public void run() { public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider, LiveObjectsPlugin liveObjectsPlugin) throws AblyException { this.ably = ably; + this.clock = SystemClock.clockFrom(ably.options); this.connection = connection; this.channels = channels; this.platformAgentProvider = platformAgentProvider; @@ -1447,7 +1450,7 @@ private boolean checkConnectionStale() { if(lastActivity == 0) { return false; } - long now = System.currentTimeMillis(); + long now = clock.currentTimeMillis(); long intervalSinceLastActivity = now - lastActivity; if(intervalSinceLastActivity > (maxIdleInterval + connectionStateTtl)) { /* RTN15g1, RTN15g2 Force a new connection if the previous one is stale; @@ -1465,7 +1468,7 @@ private boolean checkConnectionStale() { } private synchronized void setSuspendTime() { - suspendTime = (System.currentTimeMillis() + connectionStateTtl); + suspendTime = (clock.currentTimeMillis() + connectionStateTtl); } /** @@ -1490,7 +1493,7 @@ private StateIndication checkFallback(ErrorInfo reason) { } private synchronized StateIndication checkSuspended(ErrorInfo reason) { - long currentTime = System.currentTimeMillis(); + long currentTime = clock.currentTimeMillis(); long timeToSuspend = suspendTime - currentTime; boolean suspendMode = timeToSuspend <= 0; Log.v(TAG, "checkSuspended: timeToSuspend = " + timeToSuspend + "ms; suspendMode = " + suspendMode); @@ -2015,6 +2018,7 @@ private boolean isFatalError(ErrorInfo err) { ******************/ final AblyRealtime ably; + private final Clock clock; private final Channels channels; private final Connection connection; private final ITransport.Factory transportFactory; diff --git a/lib/src/main/java/io/ably/lib/transport/Hosts.java b/lib/src/main/java/io/ably/lib/transport/Hosts.java index a4559b4f6..f9a82348f 100644 --- a/lib/src/main/java/io/ably/lib/transport/Hosts.java +++ b/lib/src/main/java/io/ably/lib/transport/Hosts.java @@ -3,6 +3,8 @@ import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; +import io.ably.lib.util.Clock; +import io.ably.lib.util.SystemClock; import java.util.Arrays; import java.util.Collections; @@ -23,6 +25,7 @@ public class Hosts { private final long fallbackRetryTimeout; private final Preferred preferred = new Preferred(); + private final Clock clock; /** * Create Hosts object @@ -77,6 +80,7 @@ public Hosts(final String primaryHost, final String defaultHost, final ClientOpt /* RSC15a: shuffle the fallback hosts. */ Collections.shuffle(Arrays.asList(fallbackHosts)); fallbackRetryTimeout = options.fallbackRetryTimeout; + this.clock = SystemClock.clockFrom(options); } /** @@ -91,7 +95,7 @@ public synchronized void setPreferredHost(final String prefHost, final boolean t /* a successful request against the primary host; reset */ preferred.clear(); } else { - preferred.setHost(prefHost, temporary ? System.currentTimeMillis() + fallbackRetryTimeout : 0); + preferred.setHost(prefHost, temporary ? clock.currentTimeMillis() + fallbackRetryTimeout : 0); } } @@ -106,7 +110,7 @@ public String getPrimaryHost() { * Get preferred host name (taking into account any affinity to a fallback: see RSC15f) */ public synchronized String getPreferredHost() { - final String host = preferred.getHostOrClearIfExpired(); + final String host = preferred.getHostOrClearIfExpired(clock); return (host == null) ? primaryHost : host; } @@ -128,7 +132,7 @@ public synchronized String getFallback(String lastHost) { if (!primaryHostIsDefault && !fallbackHostsUseDefault && fallbackHostsIsDefault) return null; idx = 0; - } else if(lastHost.equals(preferred.getHostOrClearIfExpired())) { + } else if(lastHost.equals(preferred.getHostOrClearIfExpired(clock))) { /* RSC15f: there was a failure on an unexpired, cached fallback; so try again using the primary */ preferred.clear(); return primaryHost; @@ -174,8 +178,8 @@ public void setHost(final String host, final long expiry) { this.expiry = expiry; } - public String getHostOrClearIfExpired() { - if(expiry > 0 && expiry <= System.currentTimeMillis()) { + public String getHostOrClearIfExpired(Clock clock) { + if(expiry > 0 && expiry <= clock.currentTimeMillis()) { clear(); // expired, so reset } return host; diff --git a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java index 22b72505f..7f4d26ea3 100644 --- a/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java +++ b/lib/src/main/java/io/ably/lib/transport/WebSocketTransport.java @@ -14,14 +14,17 @@ import io.ably.lib.types.Param; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.types.ProtocolSerializer; +import io.ably.lib.util.Clock; import io.ably.lib.util.ClientOptionsUtils; import io.ably.lib.util.Log; +import io.ably.lib.util.NamedTimer; +import io.ably.lib.util.SystemClock; +import io.ably.lib.util.TimerInstance; import javax.net.ssl.SSLContext; import java.nio.ByteBuffer; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.util.Timer; import java.util.TimerTask; public class WebSocketTransport implements ITransport { @@ -48,6 +51,7 @@ public class WebSocketTransport implements ITransport { private final TransportParams params; private final ConnectionManager connectionManager; + private final Clock clock; private final boolean channelBinaryMode; private String wsUri; private ConnectListener connectListener; @@ -64,10 +68,10 @@ public class WebSocketTransport implements ITransport { protected WebSocketTransport(TransportParams params, ConnectionManager connectionManager) { this.params = params; this.connectionManager = connectionManager; + this.clock = SystemClock.clockFrom(params.options); this.channelBinaryMode = params.options.useBinaryProtocol; this.webSocketEngine = createWebSocketEngine(params); params.heartbeats = !this.webSocketEngine.isPingListenerSupported(); - } private static WebSocketEngine createWebSocketEngine(TransportParams params) { @@ -257,8 +261,8 @@ class WebSocketHandler implements WebSocketListener { * WsClient private members ***************************/ - private final Timer timer = new Timer(); - private volatile TimerTask activityTimerTask = null; + private final NamedTimer timer = clock.newTimer("activity-timer"); + private volatile TimerInstance activityTimerHandle = null; private volatile long lastActivityTime; /** @@ -369,7 +373,7 @@ private void dispose() { private void flagActivity() { if (isActiveTransport()) { - lastActivityTime = System.currentTimeMillis(); + lastActivityTime = clock.currentTimeMillis(); connectionManager.setLastActivity(lastActivityTime); } @@ -395,11 +399,11 @@ private void checkActivity() { } // prevent going to the synchronized block if the timer is active - if (activityTimerTask != null) return; + if (activityTimerHandle != null) return; synchronized (activityTimerMonitor) { // Check if timer already running - if (activityTimerTask == null) { + if (activityTimerHandle == null) { // Start the activity timer task startActivityTimer(timeout + 100); } @@ -407,7 +411,7 @@ private void checkActivity() { } private void startActivityTimer(long timeout) { - activityTimerTask = new TimerTask() { + TimerTask task = new TimerTask() { public void run() { try { onActivityTimerExpiry(); @@ -417,19 +421,20 @@ public void run() { } } }; - schedule(activityTimerTask, timeout); + activityTimerHandle = schedule(task, timeout); } - private void schedule(TimerTask task, long delay) { + private TimerInstance schedule(TimerTask task, long delay) { try { - timer.schedule(task, delay); + return timer.schedule(task, delay); } catch (IllegalStateException ise) { Log.w(TAG, "Timer has already has been canceled", ise); + return () -> {}; } } private void onActivityTimerExpiry() { - long timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime; + long timeSinceLastActivity = clock.currentTimeMillis() - lastActivityTime; long timeRemaining = getActivityTimeout() - timeSinceLastActivity; // If we have no time remaining, then close the connection @@ -440,7 +445,7 @@ private void onActivityTimerExpiry() { } synchronized (activityTimerMonitor) { - activityTimerTask = null; + activityTimerHandle = null; // Otherwise, we've had some activity, restart the timer for the next timeout Log.v(TAG, "onActivityTimerExpiry: ok"); startActivityTimer(timeRemaining + 100); diff --git a/lib/src/main/java/io/ably/lib/util/Clock.java b/lib/src/main/java/io/ably/lib/util/Clock.java new file mode 100644 index 000000000..a234213b6 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/Clock.java @@ -0,0 +1,6 @@ +package io.ably.lib.util; + +public interface Clock { + long currentTimeMillis(); + NamedTimer newTimer(String name); +} diff --git a/lib/src/main/java/io/ably/lib/util/NamedTimer.java b/lib/src/main/java/io/ably/lib/util/NamedTimer.java new file mode 100644 index 000000000..0c39540d4 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/NamedTimer.java @@ -0,0 +1,8 @@ +package io.ably.lib.util; + +import java.util.TimerTask; + +public interface NamedTimer { + TimerInstance schedule(TimerTask task, long delayMs); + void cancel(); +} diff --git a/lib/src/main/java/io/ably/lib/util/SystemClock.java b/lib/src/main/java/io/ably/lib/util/SystemClock.java new file mode 100644 index 000000000..32db8634c --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/SystemClock.java @@ -0,0 +1,41 @@ +package io.ably.lib.util; + +import java.util.Timer; +import java.util.TimerTask; + +import io.ably.lib.debug.DebugOptions; +import io.ably.lib.types.ClientOptions; + +public class SystemClock implements Clock { + public static final SystemClock INSTANCE = new SystemClock(); + + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public NamedTimer newTimer(String name) { + Timer jTimer = new Timer(name, true); + return new NamedTimer() { + @Override + public TimerInstance schedule(TimerTask task, long delayMs) { + jTimer.schedule(task, delayMs); + return task::cancel; + } + + @Override + public void cancel() { + jTimer.cancel(); + } + }; + } + + public static Clock clockFrom(ClientOptions opts) { + if (opts instanceof DebugOptions) { + Clock c = ((DebugOptions) opts).clock; + if (c != null) return c; + } + return INSTANCE; + } +} diff --git a/lib/src/main/java/io/ably/lib/util/TimerInstance.java b/lib/src/main/java/io/ably/lib/util/TimerInstance.java new file mode 100644 index 000000000..0b9d0f6bb --- /dev/null +++ b/lib/src/main/java/io/ably/lib/util/TimerInstance.java @@ -0,0 +1,6 @@ +package io.ably.lib.util; + +@FunctionalInterface +public interface TimerInstance { + void cancel(); +} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt index dfb1a12bc..09b8b1c14 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt @@ -1,6 +1,7 @@ package io.ably.lib.objects import io.ably.lib.types.AblyException +import io.ably.lib.util.SystemClock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -21,15 +22,16 @@ internal object ServerTime { */ @Throws(AblyException::class) internal suspend fun getCurrentTime(adapter: ObjectsAdapter): Long { + val clock = SystemClock.clockFrom(adapter.clientOptions) if (serverTimeOffset == null) { mutex.withLock { if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety val serverTime: Long = withContext(Dispatchers.IO) { adapter.time } - serverTimeOffset = serverTime - System.currentTimeMillis() + serverTimeOffset = serverTime - clock.currentTimeMillis() return serverTime } } } - return System.currentTimeMillis() + serverTimeOffset!! + return clock.currentTimeMillis() + serverTimeOffset!! } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt index 2eca29b55..934789789 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt @@ -7,7 +7,9 @@ import io.ably.lib.objects.ObjectsOperationSource import io.ably.lib.objects.objectError import io.ably.lib.objects.type.livecounter.noOpCounterUpdate import io.ably.lib.objects.type.livemap.noOpMapUpdate +import io.ably.lib.util.Clock import io.ably.lib.util.Log +import io.ably.lib.util.SystemClock internal enum class ObjectType(val value: String) { Map("map"), @@ -27,6 +29,7 @@ internal val ObjectUpdate.noOp get() = this.update == null internal abstract class BaseRealtimeObject( internal val objectId: String, // // RTLO3a internal val objectType: ObjectType, + internal val clock: Clock = SystemClock.INSTANCE, ) : ObjectLifecycleCoordinator() { protected open val tag = "BaseRealtimeObject" @@ -128,7 +131,7 @@ internal abstract class BaseRealtimeObject( Log.w(tag, "Tombstoning object $objectId without serial timestamp, using local timestamp instead") } isTombstoned = true - tombstonedAt = serialTimestamp?: System.currentTimeMillis() + tombstonedAt = serialTimestamp?: clock.currentTimeMillis() val update = clearData() // Emit object lifecycle event for deletion objectLifecycleChanged(ObjectLifecycle.Deleted) @@ -149,7 +152,7 @@ internal abstract class BaseRealtimeObject( * false otherwise */ internal fun isEligibleForGc(gcGracePeriod: Long): Boolean { - val currentTime = System.currentTimeMillis() + val currentTime = clock.currentTimeMillis() return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt index 4f1ef28e5..b3b795bd3 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt @@ -14,6 +14,7 @@ import io.ably.lib.objects.type.counter.LiveCounterUpdate import io.ably.lib.objects.type.noOp import java.util.concurrent.atomic.AtomicReference import io.ably.lib.util.Log +import io.ably.lib.util.SystemClock import kotlinx.coroutines.runBlocking /** @@ -22,7 +23,7 @@ import kotlinx.coroutines.runBlocking internal class DefaultLiveCounter private constructor( objectId: String, private val realtimeObjects: DefaultRealtimeObjects, -) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter) { +) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter, SystemClock.clockFrom(realtimeObjects.adapter.clientOptions)) { override val tag = "LiveCounter" diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt index 8e9746d6e..79b979a0d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt @@ -17,6 +17,7 @@ import io.ably.lib.objects.type.map.LiveMapUpdate import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.objects.type.noOp import io.ably.lib.util.Log +import io.ably.lib.util.SystemClock import kotlinx.coroutines.runBlocking import java.util.Base64 import java.util.concurrent.ConcurrentHashMap @@ -29,7 +30,7 @@ internal class DefaultLiveMap private constructor( objectId: String, private val realtimeObjects: DefaultRealtimeObjects, internal val semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW -) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map) { +) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map, SystemClock.clockFrom(realtimeObjects.adapter.clientOptions)) { override val tag = "LiveMap" @@ -191,7 +192,7 @@ internal class DefaultLiveMap private constructor( } override fun onGCInterval(gcGracePeriod: Long) { - data.entries.removeIf { (_, entry) -> entry.isEligibleForGc(gcGracePeriod) } + data.entries.removeIf { (_, entry) -> entry.isEligibleForGc(gcGracePeriod, clock) } } companion object { diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt index f12e88d88..2b21a7f2f 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt @@ -8,6 +8,7 @@ import io.ably.lib.objects.type.ObjectType import io.ably.lib.objects.type.counter.LiveCounter import io.ably.lib.objects.type.map.LiveMap import io.ably.lib.objects.type.map.LiveMapValue +import io.ably.lib.util.Clock import java.util.Base64 /** @@ -72,8 +73,8 @@ internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapVal /** * Extension function to check if a LiveMapEntry is expired and ready for garbage collection */ -internal fun LiveMapEntry.isEligibleForGc(gcGracePeriod: Long): Boolean { - val currentTime = System.currentTimeMillis() +internal fun LiveMapEntry.isEligibleForGc(gcGracePeriod: Long, clock: Clock): Boolean { + val currentTime = clock.currentTimeMillis() return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt index c8990f06b..71cd4e4a2 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt @@ -37,7 +37,7 @@ internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChang objectState.map?.entries?.forEach { (key, entry) -> liveMap.data[key] = LiveMapEntry( isTombstoned = entry.tombstone ?: false, - tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: System.currentTimeMillis() else null, + tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: liveMap.clock.currentTimeMillis() else null, timeserial = entry.timeserial, data = entry.data ) @@ -212,7 +212,7 @@ internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChang "No timestamp provided for MAP_REMOVE op on key=\"${mapRemove.key}\"; using current time as tombstone time; " + "objectId=${objectId}" ) - System.currentTimeMillis() + liveMap.clock.currentTimeMillis() } if (existingEntry != null) { diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt new file mode 100644 index 000000000..034fa5954 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt @@ -0,0 +1,56 @@ +package io.ably.lib.test.mock + +import io.ably.lib.util.Clock +import io.ably.lib.util.NamedTimer +import io.ably.lib.util.TimerInstance +import java.util.TimerTask + +class FakeClock(initialTimeMs: Long = 0L) : Clock { + @Volatile private var time = initialTimeMs + private val timers = mutableMapOf() + + override fun currentTimeMillis() = time + + override fun newTimer(name: String): NamedTimer { + val t = FakeNamedTimer(name) + timers[name] = t + return t + } + + fun advance(ms: Long) { + time += ms + timers.values.forEach { it.fireDue(time) } + } + + fun advance(timerName: String, ms: Long) { + time += ms + timers[timerName]?.fireDue(time) + } + + fun pendingTaskCount(timerName: String) = timers[timerName]?.pendingCount ?: 0 + + inner class FakeNamedTimer(val name: String) : NamedTimer { + private val pending = mutableListOf() + val pendingCount get() = pending.size + + override fun schedule(task: TimerTask, delayMs: Long): TimerInstance { + val s = Scheduled(task, time + delayMs) + pending += s + pending.sortBy { it.fireAt } + return TimerInstance { task.cancel(); pending -= s } + } + + override fun cancel() { + pending.forEach { it.task.cancel() } + pending.clear() + } + + fun fireDue(now: Long) { + val due = pending.filter { it.fireAt <= now } + pending -= due.toSet() + due.forEach { it.task.run() } + } + } + + class Scheduled(val task: TimerTask, val fireAt: Long) +} From ec7e6c432a88c9caa80d698b00411226f868ddc6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 May 2026 13:58:22 +0100 Subject: [PATCH 5/5] uts: add `uts-to-kotlin` skill for translating UTS pseudocode to Kotlin tests - Introduced a new skill for converting UTS pseudocode specs into runnable Kotlin tests. - Included detailed translation rules for pseudocode to Kotlin, mock setup, and assertions. - Added file templates and steps for compilation, testing, and handling deviations. - Enhanced developer workflow for UTS test authoring. --- .claude/skills/uts-to-kotlin/SKILL.md | 250 ++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 .claude/skills/uts-to-kotlin/SKILL.md diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md new file mode 100644 index 000000000..b2a7d61fb --- /dev/null +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -0,0 +1,250 @@ +--- +description: "Translate a UTS pseudocode test spec into Kotlin tests in the uts module. Usage: /uts-to-kotlin " +allowed-tools: Bash, Read, Edit, Write +--- + +You are translating a UTS pseudocode test spec file into a runnable Kotlin test in the `uts` module. Follow these steps in order. + +--- + +## Step 1 — Read the spec + +Read the file at `$ARGUMENTS`. Identify: +- All test cases (each has an ID like `RTN4a`, `RSC1`, etc. and a description) +- The protocol/transport used (WebSocket for Realtime, HTTP for REST) +- Any timer usage (`enable_fake_timers`, `ADVANCE_TIME`) + +--- + +## Step 2 — Determine output path and package + +Map the spec path to a test path: + +| Spec location | Test location | +|---|---| +| `.../uts/test/rest/unit/.md` | `uts/src/test/kotlin/io/ably/lib/rest/unit/Test.kt` | +| `.../uts/test/realtime/unit//.md` | `uts/src/test/kotlin/io/ably/lib/realtime/unit//Test.kt` | + +Class name: take the file name, strip `_test` suffix, convert `snake_case` → `PascalCase`, append `Test`. + +Example: `connection_state_machine_test.md` → `ConnectionStateMachineTest` + +Package: derived from the output path under `kotlin/`. + +--- + +## Step 3 — Read mock infrastructure files + +Read ALL of these before generating any code (you need exact method signatures): + +``` +uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt +uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpClient.kt +uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt +uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt +uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt +uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt +``` + +--- + +## Step 4 — Generate the Kotlin test file + +Apply the translation rules below, then write the file. + +### Client construction + +| Pseudocode | Kotlin | +|---|---| +| `Rest(options: ClientOptions(key: "..."))` | `AblyRest(DebugOptions("..."))` | +| `Realtime(options: ClientOptions(key: "...", autoConnect: false))` | `DebugOptions("...").apply { autoConnect = false }.let { AblyRealtime(it) }` | +| `ClientOptions(token: "...", autoConnect: false)` | `DebugOptions().apply { token = "..."; autoConnect = false }` | + +### Mock setup — CRITICAL + +The pseudocode uses callback-style (`onConnectionAttempt: (conn) => {...}`) but Kotlin mocks use **coroutine await-style**. Each callback body becomes a `launch { ... }` block started **before** the SDK client is created or connected. + +| Pseudocode | Kotlin | +|---|---| +| `mock_http = MockHttpClient(...)` + `install_mock(mock_http)` | `val mock = MockHttpClient(); mock.installOn(options)` | +| `mock_ws = MockWebSocket(...)` + `install_mock(mock_ws)` | `val mock = MockWebSocket(); mock.installOn(options)` | +| `onConnectionAttempt: (conn) => { conn.respond_with_success() }` | `launch { val conn = mock.awaitConnectionAttempt(); conn.respondWithSuccess() }` | +| `onRequest: (req) => { req.respond_with(200, body) }` | `launch { val req = mock.awaitRequest(); req.respondWith(200, body) }` | +| Repeated connection attempts | `launch { repeat(N) { val conn = mock.awaitConnectionAttempt(); conn.respondWithRefused() } }` | +| `enable_fake_timers()` | `val clock = FakeClock(); options.clock = clock` (before client construction) | + +### Connection/request actions + +| Pseudocode | Kotlin | +|---|---| +| `conn.respond_with_success()` | `conn.respondWithSuccess()` | +| `conn.respond_with_refused()` | `conn.respondWithRefused()` | +| `conn.respond_with_timeout()` | `conn.respondWithTimeout()` | +| `conn.respond_with_dns_error()` | `conn.respondWithDnsError()` | +| `conn.send_to_client(msg)` | `mock.sendToClient(msg)` (after `respondWithSuccess()`) | +| `conn.send_to_client_and_close(msg)` | `mock.sendToClientAndClose(msg)` | +| `mock_ws.simulate_disconnect()` | `mock.simulateDisconnect()` | +| `req.respond_with(200, {...})` | `req.respondWith(200, mapOf(...))` | +| `req.respond_with_timeout()` | `req.respondWithTimeout()` | + +### Protocol messages and types + +| Pseudocode | Kotlin | +|---|---| +| `ProtocolMessage(action: CONNECTED, ...)` | `ProtocolMessage().apply { action = ProtocolMessage.Action.connected; ... }` | +| `CONNECTED` / `DISCONNECTED` / `ERROR` / `HEARTBEAT` / `ATTACH` / `DETACHED` | `.connected` / `.disconnected` / `.error` / `.heartbeat` / `.attach` / `.detached` | +| `ErrorInfo(code: X, statusCode: Y, message: "...")` | `ErrorInfo("...", X, Y)` | +| `ConnectionDetails(connectionKey: ..., maxIdleInterval: ..., connectionStateTtl: ...)` | `ConnectionDetails().apply { connectionKey = "..."; maxIdleInterval = ...; connectionStateTtl = ... }` | +| `ConnectionState.connected` etc. | `ConnectionState.connected`, `.disconnected`, `.suspended`, `.failed`, `.connecting`, `.closing`, `.closed` | + +### Awaiting state + +`AWAIT_STATE client.connection.state == ConnectionState.X WITH timeout: N seconds` → call the `awaitState()` helper (included in the file template below): + +```kotlin +awaitState(client, ConnectionState.x, timeoutMs = N * 1000L) +``` + +### Timer control + +| Pseudocode | Kotlin | +|---|---| +| `enable_fake_timers()` | `val clock = FakeClock()` then `options.clock = clock` | +| `ADVANCE_TIME(ms)` | `clock.advance(ms)` | + +After `clock.advance()`, always yield to let the SDK's timer callbacks dispatch: + +```kotlin +clock.advance(30_000) +yield() +``` + +### Assertions + +| Pseudocode | Kotlin | +|---|---| +| `ASSERT x == y` | `assertEquals(y, x)` | +| `ASSERT x IS NOT null` | `assertNotNull(x)` | +| `ASSERT x IS null` | `assertNull(x)` | +| `ASSERT x IS Auth` | `assertIs(x)` | +| `ASSERT "key" IN map` | `assertContains(map, "key")` | +| `ASSERT x matches pattern "..."` | `assertTrue(x.matches(Regex("...")))` | +| `ASSERT list CONTAINS_IN_ORDER [a, b, c]` | `val it = list.iterator(); assertEquals(a, it.next()); assertEquals(b, it.next()); ...` | +| `AWAIT expr FAILS WITH error` | `val error = assertFailsWith { expr }; assertEquals(..., error.errorInfo.code)` | +| `ASSERT list.length == N` | `assertEquals(N, list.size)` | + +### Test naming + +- Method name: backtick string `` ` - ` `` +- Add `// UTS: ` comment on the line immediately above `@Test` +- Use `runTest { }` from `kotlinx.coroutines.test` for all async tests + +### File template + +```kotlin +package io.ably.lib..unit[.] + +import io.ably.lib.debug.DebugOptions +import io.ably.lib.realtime.AblyRealtime // or AblyRest for REST tests +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.realtime.ConnectionStateListener +import io.ably.lib.test.mock.FakeClock +import io.ably.lib.test.mock.MockWebSocket // or MockHttpClient +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.coroutines.resume +import kotlin.test.* + +class Test { + + @AfterTest + fun tearDown() { + // close any clients opened in each test (declare them at test scope, not class scope) + } + + // UTS: + @Test + fun ` - `() = runTest { + val mock = MockWebSocket() + val options = DebugOptions("appId.keyId:keySecret").apply { + autoConnect = false + mock.installOn(this) + } + + launch { + val conn = mock.awaitConnectionAttempt() + conn.respondWithSuccess() + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "test-connection-id" + connectionKey = "test-key" + }) + } + + val client = AblyRealtime(options) + client.connect() + awaitState(client, ConnectionState.connected) + + assertEquals(ConnectionState.connected, client.connection.state) + client.close() + } + + private suspend fun awaitState( + client: AblyRealtime, + target: ConnectionState, + timeoutMs: Long = 5000 + ) { + if (client.connection.state == target) return + withTimeout(timeoutMs) { + suspendCancellableCoroutine { cont -> + val listener = ConnectionStateListener { change -> + if (change.current == target && cont.isActive) cont.resume(Unit) + } + client.connection.on(listener) + cont.invokeOnCancellation { client.connection.off(listener) } + } + } + } +} +``` + +--- + +## Step 5 — Compile + +```bash +./gradlew :uts:compileTestKotlin +``` + +Fix any compilation errors and recompile until clean. Common issues: +- Missing imports (add them) +- Method names differ from what you read in the mock files (use the exact names you read) +- `Scheduled` is a top-level class in `FakeClock`, not nested inside `FakeNamedTimer` + +--- + +## Step 6 — Run tests + +```bash +./gradlew :uts:test --tests ".Test" +``` + +Handle test failures: + +1. **UTS spec error** (pseudocode itself is wrong): fix the test to match what the spec intends, add a `// NOTE: spec pseudocode had X, corrected to Y` comment. +2. **Translation error** (you misread the pseudocode): fix silently. +3. **SDK deviation** (confirmed against `uts/spec/features.md` — SDK does not comply): + - Wrap the failing assertion in an env gate: + ```kotlin + if (System.getenv("RUN_DEVIATIONS") != null) { + assertEquals(specCorrectValue, actualValue) + } + ``` + - Add a comment explaining the deviation. + - Append an entry to `uts/src/test/kotlin/io/ably/lib/deviations.md`: + - Spec point, what spec requires, what SDK does, which test is affected.