From ee117f0c9bee036c9c80bf3287be9bdf31529cb0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 14 May 2026 15:12:00 +0100 Subject: [PATCH 01/11] 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 02/11] 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 4c808133fb35dc5898a84a49abbf817658e24b55 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 May 2026 11:18:18 +0100 Subject: [PATCH 03/11] 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. --- .../lib/test/mock/DefaultPendingConnection.kt | 33 ++++ ...equestImpl.kt => DefaultPendingRequest.kt} | 12 +- .../kotlin/io/ably/lib/test/mock/MockEvent.kt | 16 ++ .../io/ably/lib/test/mock/MockHttpClient.kt | 46 ++++-- .../io/ably/lib/test/mock/MockHttpEngine.kt | 64 +++++--- .../io/ably/lib/test/mock/MockWebSocket.kt | 148 ++++++++++++++++++ .../test/mock/MockWebSocketEngineFactory.kt | 28 +++- .../io/ably/lib/test/mock/NetworkMocks.kt | 41 +++++ .../ably/lib/test/mock/PendingConnection.kt | 3 + .../lib/test/mock/PendingConnectionImpl.kt | 18 --- 10 files changed, 346 insertions(+), 63 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt rename uts/src/test/kotlin/io/ably/lib/test/mock/{PendingRequestImpl.kt => DefaultPendingRequest.kt} (79%) 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 create mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt new file mode 100644 index 000000000..8f43db177 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt @@ -0,0 +1,33 @@ +package io.ably.lib.test.mock + +import io.ably.lib.network.WebSocketListener +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.util.Serialisation +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +internal class DefaultPendingConnection( + override val host: String, + override val port: Int, + override val tls: Boolean, + private val listener: WebSocketListener, + private val onConnected: (WebSocketListener) -> Unit = {}, +) : PendingConnection { + override fun respondWithSuccess() { + listener.onOpen() + onConnected(listener) + } + + override fun respondWithSuccess(message: ProtocolMessage) { + listener.onOpen() + onConnected(listener) + // Async delivery per spec: the library must store the WS reference before processing CONNECTED. + val encoded = Serialisation.gson.toJson(message) + Thread { listener.onMessage(encoded) }.apply { isDaemon = true }.start() + } + + 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/PendingRequestImpl.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt similarity index 79% rename from uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequestImpl.kt rename to uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt index 2db5eeb6b..4b8bcee83 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequestImpl.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt @@ -4,13 +4,13 @@ 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 kotlinx.coroutines.CompletableDeferred import java.net.SocketTimeoutException import java.time.Duration -import java.util.concurrent.CompletableFuture -internal class PendingRequestImpl( +internal class DefaultPendingRequest( private val request: HttpRequest, - private val future: CompletableFuture, + private val deferred: CompletableDeferred, ) : PendingRequest { override val url get() = request.url override val method get() = request.method @@ -22,7 +22,7 @@ internal class PendingRequestImpl( is ByteArray -> body else -> body.toString().toByteArray(Charsets.UTF_8) } - future.complete( + deferred.complete( HttpResponse.builder() .code(status) .message("") @@ -36,10 +36,10 @@ internal class PendingRequestImpl( Thread { Thread.sleep(delay.toMillis()) respondWith(status, body) - }.start() + }.apply { isDaemon = true }.start() } override fun respondWithTimeout() { - future.completeExceptionally(FailedConnectionException(SocketTimeoutException("Connection timed out"))) + deferred.completeExceptionally(FailedConnectionException(SocketTimeoutException("Connection timed out"))) } } 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..138ab74f6 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt @@ -0,0 +1,16 @@ +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() + data class ClientClose(val code: Int, val reason: String) : MockEvent() + data class MessageFromClient(val message: ProtocolMessage) : 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..947be66ec 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 @@ -2,32 +2,46 @@ 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.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -class MockHttpClient { +class HttpMockConfig { + var onConnectionAttempt: ((PendingConnection) -> Unit)? = null + var onRequest: ((PendingRequest) -> Unit)? = null +} + +class MockHttpClient(private val config: HttpMockConfig = HttpMockConfig()) { 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) } + val engine: HttpEngine = MockHttpEngine( + onConnect = { conn -> + val handler = config.onConnectionAttempt + if (handler != null) handler(conn) else _pendingConnections.trySend(conn) + }, + onRequest = { pending -> + val handler = config.onRequest + if (handler != null) handler(pending) else _pendingRequests.trySend(pending) + }, + ) fun installOn(options: DebugOptions) { - options.httpEngine = httpEngine - options.webSocketEngineFactory = webSocketEngineFactory + options.httpEngine = engine } - suspend fun awaitRequest(timeout: Duration = 5.seconds): PendingRequest = - withTimeout(timeout) { _pendingRequests.receive() } - suspend fun awaitConnectionAttempt(timeout: Duration = 5.seconds): PendingConnection = - withTimeout(timeout) { _pendingConnections.receive() } + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { _pendingConnections.receive() } + } + + suspend fun awaitRequest(timeout: Duration = 5.seconds): PendingRequest = + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { _pendingRequests.receive() } + } fun reset() { _pendingConnections.close() @@ -36,3 +50,9 @@ class MockHttpClient { _pendingRequests = Channel(Channel.UNLIMITED) } } + +fun installMockHttpClient(options: DebugOptions, init: HttpMockConfig.() -> Unit): MockHttpClient { + val mock = MockHttpClient(config = HttpMockConfig().apply(init)) + mock.installOn(options) + return mock +} 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 index 2cf3749b0..83c38cfd5 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt @@ -5,36 +5,62 @@ 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 +import io.ably.lib.types.ProtocolMessage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException -internal class MockHttpEngine(private val onRequest: (PendingRequest) -> Unit) : HttpEngine { - override fun call(request: HttpRequest): HttpCall = MockHttpCall(request, onRequest) +internal class MockHttpEngine( + private val onConnect: (PendingConnection) -> Unit, + private val onRequest: (PendingRequest) -> Unit, +) : HttpEngine { + override fun call(request: HttpRequest): HttpCall = MockHttpCall(request, onConnect, onRequest) override fun isUsingProxy() = false } internal class MockHttpCall( private val request: HttpRequest, + private val onConnect: (PendingConnection) -> Unit, private val onRequest: (PendingRequest) -> Unit, ) : HttpCall { - private val future = CompletableFuture() + @Volatile private var connDeferred: CompletableDeferred? = null + @Volatile private var respDeferred: CompletableDeferred? = null + + override fun execute(): HttpResponse = runBlocking { + // Phase 1 — connection + val cd = CompletableDeferred().also { connDeferred = it } + val url = request.url + val tls = url.protocol == "https" + val port = if (url.port != -1) url.port else if (tls) 443 else 80 + onConnect(DefaultHttpPendingConnection(url.host, port, tls, cd)) + cd.await() - 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) - } + // Phase 2 — request + val rd = CompletableDeferred().also { respDeferred = it } + onRequest(DefaultPendingRequest(request, rd)) + rd.await() } override fun cancel() { - future.cancel(true) + connDeferred?.cancel() + respDeferred?.cancel() } } + +internal class DefaultHttpPendingConnection( + override val host: String, + override val port: Int, + override val tls: Boolean, + private val deferred: CompletableDeferred, +) : PendingConnection { + override fun respondWithSuccess() { deferred.complete(Unit) } + override fun respondWithSuccess(message: ProtocolMessage) = respondWithSuccess() + override fun respondWithRefused() { deferred.completeExceptionally( + FailedConnectionException(IOException("Connection refused to $host:$port"))) } + override fun respondWithTimeout() { deferred.completeExceptionally( + FailedConnectionException(SocketTimeoutException("Connection timed out to $host:$port"))) } + override fun respondWithDnsError() { deferred.completeExceptionally( + FailedConnectionException(UnknownHostException(host))) } +} 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..8f77101b5 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt @@ -0,0 +1,148 @@ +package io.ably.lib.test.mock + +import io.ably.lib.debug.DebugOptions +import io.ably.lib.network.WebSocketEngineFactory +import io.ably.lib.network.WebSocketListener +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.util.Serialisation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.util.Collections +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class WebSocketMockConfig { + var onConnectionAttempt: ((PendingConnection) -> Unit)? = null + var onMessageFromClient: ((ProtocolMessage) -> Unit)? = null + var onTextDataFrame: ((String) -> Unit)? = null + var onBinaryDataFrame: ((ByteArray) -> Unit)? = null +} + +class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { + private val _events = Collections.synchronizedList(mutableListOf()) + val events: List get() = _events.toList() + + private var _pendingConnections = Channel(Channel.UNLIMITED) + private var _messagesFromClient = Channel(Channel.UNLIMITED) + private var _clientCloseEvents = Channel(Channel.UNLIMITED) + + @Volatile private var activeListener: WebSocketListener? = null + + val engineFactory: WebSocketEngineFactory = MockWebSocketEngineFactory( + onConnect = { pending -> + _events.add(MockEvent.ConnectionAttempt(pending.host, pending.port, pending.tls)) + val tracked = EventTrackingPendingConnection(pending, _events) + val handler = config.onConnectionAttempt + if (handler != null) { + handler(tracked) + } else { + _pendingConnections.trySend(tracked) + } + }, + onConnected = { listener -> + _events.add(MockEvent.ConnectionEstablished) + activeListener = listener + }, + onTextFrame = { text -> + config.onTextDataFrame?.invoke(text) + val decoded = runCatching { Serialisation.gson.fromJson(text, ProtocolMessage::class.java) }.getOrNull() + if (decoded != null) { + _events.add(MockEvent.MessageFromClient(decoded)) + val handler = config.onMessageFromClient + if (handler != null) { + handler(decoded) + } else { + _messagesFromClient.trySend(decoded) + } + } + }, + onBinaryFrame = { bytes -> + config.onBinaryDataFrame?.invoke(bytes) + }, + onClientClose = { code, reason -> + val event = MockEvent.ClientClose(code, reason) + _events.add(event) + _clientCloseEvents.trySend(event) + }, + ) + + fun installOn(options: DebugOptions) { + options.webSocketEngineFactory = engineFactory + } + + suspend fun awaitConnectionAttempt(timeout: Duration = 5.seconds): PendingConnection = + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { _pendingConnections.receive() } + } + + suspend fun awaitNextMessageFromClient(timeout: Duration = 5.seconds): ProtocolMessage = + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { _messagesFromClient.receive() } + } + + suspend fun awaitClientClose(timeout: Duration = 5.seconds): MockEvent.ClientClose = + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { _clientCloseEvents.receive() } + } + + fun sendToClient(message: ProtocolMessage) { + val listener = checkNotNull(activeListener) { "No active WebSocket connection" } + _events.add(MockEvent.SentToClient(message)) + listener.onMessage(Serialisation.gson.toJson(message)) + } + + fun sendToClientAndClose(message: ProtocolMessage) { + val listener = checkNotNull(activeListener) { "No active WebSocket connection" } + _events.add(MockEvent.SentToClient(message)) + listener.onMessage(Serialisation.gson.toJson(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() + _messagesFromClient.close() + _clientCloseEvents.close() + _pendingConnections = Channel(Channel.UNLIMITED) + _messagesFromClient = Channel(Channel.UNLIMITED) + _clientCloseEvents = 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() + } +} + +fun installMockWebSocket(options: DebugOptions, init: WebSocketMockConfig.() -> Unit): MockWebSocket { + val mock = MockWebSocket(config = WebSocketMockConfig().apply(init)) + mock.installOn(options) + return mock +} + 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..3c9a88e81 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,26 @@ import java.net.URI internal class MockWebSocketEngineFactory( private val onConnect: (PendingConnection) -> Unit, + private val onConnected: (WebSocketListener) -> Unit = {}, + private val onTextFrame: (String) -> Unit = {}, + private val onBinaryFrame: (ByteArray) -> Unit = {}, + private val onClientClose: (Int, String) -> Unit = { _, _ -> }, ) : WebSocketEngineFactory { - override fun create(config: WebSocketEngineConfig): WebSocketEngine = MockWebSocketEngine(onConnect) + override fun create(config: WebSocketEngineConfig): WebSocketEngine = + MockWebSocketEngine(onConnect, onConnected, onTextFrame, onBinaryFrame, onClientClose) + override fun getEngineType(): EngineType = EngineType.DEFAULT } internal class MockWebSocketEngine( private val onConnect: (PendingConnection) -> Unit, + private val onConnected: (WebSocketListener) -> Unit, + private val onTextFrame: (String) -> Unit, + private val onBinaryFrame: (ByteArray) -> Unit, + private val onClientClose: (Int, String) -> Unit, ) : WebSocketEngine { override fun create(url: String, listener: WebSocketListener): WebSocketClient = - MockWebSocketClient(url, listener, onConnect) + MockWebSocketClient(url, listener, onConnect, onConnected, onTextFrame, onBinaryFrame, onClientClose) override fun isPingListenerSupported() = false } @@ -28,17 +38,21 @@ internal class MockWebSocketClient( private val url: String, private val listener: WebSocketListener, private val onConnect: (PendingConnection) -> Unit, + private val onConnected: (WebSocketListener) -> Unit, + private val onTextFrame: (String) -> Unit, + private val onBinaryFrame: (ByteArray) -> Unit, + private val onClientClose: (Int, String) -> 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(DefaultPendingConnection(uri.host, port, tls, listener, onConnected)) } 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) {} + override fun close(code: Int, reason: String) { onClientClose(code, reason) } + override fun cancel(code: Int, reason: String) { onClientClose(code, reason) } + override fun send(message: ByteArray) { onBinaryFrame(message) } + override fun send(message: String) { onTextFrame(message) } } diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt new file mode 100644 index 000000000..b5bb3725c --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt @@ -0,0 +1,41 @@ +package io.ably.lib.test.mock + +import io.ably.lib.debug.DebugOptions +import io.ably.lib.types.ConnectionDetails +import io.ably.lib.types.ProtocolMessage + +data class NetworkMocks(val http: MockHttpClient, val webSocket: MockWebSocket) + +class NetworkMocksConfig { + internal val httpConfig = HttpMockConfig() + internal val wsConfig = WebSocketMockConfig() + + fun httpClient(block: HttpMockConfig.() -> Unit) { + httpConfig.apply(block) + } + + fun webSocketClient(block: WebSocketMockConfig.() -> Unit) { + wsConfig.apply(block) + } +} + +fun installNetworkMocks(options: DebugOptions, block: NetworkMocksConfig.() -> Unit = {}): NetworkMocks { + val cfg = NetworkMocksConfig().apply(block) + val http = MockHttpClient(cfg.httpConfig) + val ws = MockWebSocket(cfg.wsConfig) + options.httpEngine = http.engine + options.webSocketEngineFactory = ws.engineFactory + return NetworkMocks(http, ws) +} + +/** Pre-built CONNECTED message suitable for most unit tests. */ +val CONNECTED_MESSAGE: ProtocolMessage + get() = ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "test-connection-id" + connectionDetails = ConnectionDetails { + connectionKey = "test-connection-key" + connectionStateTtl = 120_000L + maxIdleInterval = 15_000L + } + } 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 index 48ff4769f..2df3785d6 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt @@ -1,10 +1,13 @@ package io.ably.lib.test.mock +import io.ably.lib.types.ProtocolMessage + interface PendingConnection { val host: String val port: Int val tls: Boolean fun respondWithSuccess() + fun respondWithSuccess(message: ProtocolMessage) 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 deleted file mode 100644 index 56d689c84..000000000 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnectionImpl.kt +++ /dev/null @@ -1,18 +0,0 @@ -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)) -} From fc8461cfabd93c31b29808dda315b7be5e501d4c Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 May 2026 13:32:56 +0100 Subject: [PATCH 04/11] 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 8317619c09b8c460c9bd6dc748a9e9e1f3d0a1b3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 15 May 2026 13:58:22 +0100 Subject: [PATCH 05/11] 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. From 7408981aab17482b50bce563dbb45b29acb47124 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 18 May 2026 11:32:14 +0100 Subject: [PATCH 06/11] uts: first UTS generation attempt --- .claude/skills/uts-to-kotlin/SKILL.md | 211 +++++---- .../kotlin/io/ably/lib/ClientFactories.kt | 23 + uts/src/test/kotlin/io/ably/lib/SampleTest.kt | 13 - uts/src/test/kotlin/io/ably/lib/Utils.kt | 57 +++ .../unit/connection/ConnectionRecoveryTest.kt | 427 ++++++++++++++++++ .../kotlin/io/ably/lib/test/mock/FakeClock.kt | 7 +- .../io/ably/lib/test/mock/MockHttpClient.kt | 6 +- .../io/ably/lib/test/mock/MockWebSocket.kt | 19 +- .../io/ably/lib/test/mock/NetworkMocks.kt | 41 -- .../test/kotlin/io/ably/lib/types/Utils.kt | 3 + 10 files changed, 659 insertions(+), 148 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/ClientFactories.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/SampleTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/Utils.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt delete mode 100644 uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/types/Utils.kt diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index b2a7d61fb..ebf5b5ac0 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -33,11 +33,12 @@ Package: derived from the output path under `kotlin/`. --- -## Step 3 — Read mock infrastructure files +## Step 3 — Read infrastructure files Read ALL of these before generating any code (you need exact method signatures): ``` +uts/src/test/kotlin/io/ably/lib/ClientFactories.kt 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 @@ -54,35 +55,83 @@ Apply the translation rules below, then write the file. ### Client construction +Use `TestRealtimeClient { }` and `TestRestClient { }` from `io.ably.lib`. These are DSL builders that extend `DebugOptions` — all `ClientOptions` fields (`autoConnect`, `recover`, `disconnectedRetryTimeout`, etc.) are settable directly inside the block. The default API key is `"appId.keyId:keySecret"`. + | 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 }` | +| `Rest(options: ClientOptions(key: "..."))` | `TestRestClient { key = "..." }` | +| `Realtime(options: ClientOptions(key: "...", autoConnect: false))` | `TestRealtimeClient { key = "..."; autoConnect = false }` | +| `Realtime(options: ClientOptions(autoConnect: false))` | `TestRealtimeClient { autoConnect = false }` | +| Installing mocks | `install(mock)` inside the builder block | +| Fake timers | Create `val fakeClock = FakeClock()` before the builder, then call `enableFakeTimers(fakeClock)` inside | -### Mock setup — CRITICAL +### Mock setup -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. +**Prefer `onConnectionAttempt` callback** over `launch { awaitConnectionAttempt() }` for straightforward connections. Use `respondWithSuccess(message)` to open the socket and deliver the CONNECTED message in a single call. -| 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) | +```kotlin +val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "test-connection-id" + connectionDetails = ConnectionDetails { + connectionKey = "test-key" + maxIdleInterval = 15_000L + connectionStateTtl = 120_000L + } + }) + } +} +val client = TestRealtimeClient { + autoConnect = false + install(mock) +} +``` + +**Use `awaitConnectionAttempt()` only** when different connection attempts need different behaviour (e.g. first attempt succeeds, subsequent ones are refused). In that case, set up the initial connection in a `launch` block before calling `connect()`, then handle reconnections separately: + +```kotlin +val mockWs = MockWebSocket() +val client = TestRealtimeClient { + autoConnect = false + install(mockWs) +} + +launch { + mockWs.awaitConnectionAttempt().respondWithSuccess(ProtocolMessage().apply { ... }) +} + +client.connect() +awaitState(client, ConnectionState.connected) + +// handle reconnection attempts differently +val refuseJob = launch { + repeat(10) { + fakeClock.advance(2.seconds) + mockWs.awaitConnectionAttempt().respondWithRefused() + ... + } +} +``` + +For HTTP: +```kotlin +val mockHttp = MockHttpClient { onRequest = { req -> req.respondWith(200, body) } } +val client = TestRestClient { install(mockHttp) } +``` -### Connection/request actions +### Mock method reference | Pseudocode | Kotlin | |---|---| -| `conn.respond_with_success()` | `conn.respondWithSuccess()` | +| `conn.respond_with_success()` | `conn.respondWithSuccess()` (opens socket only) | +| `conn.respond_with_success(msg)` | `conn.respondWithSuccess(msg)` (opens socket + delivers message) | | `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.send_to_client(msg)` | `mock.sendToClient(msg)` | +| `mock_ws.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()` | @@ -93,29 +142,41 @@ The pseudocode uses callback-style (`onConnectionAttempt: (conn) => {...}`) but |---|---| | `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 = ... }` | +| `ErrorInfo(code: X, statusCode: Y, message: "...")` | `ErrorInfo("...", Y, X)` — note arg order: message, statusCode, code | +| `ConnectionDetails(connectionKey: ..., maxIdleInterval: ..., connectionStateTtl: ...)` | `ConnectionDetails { 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): +`AWAIT_STATE client.connection.state == ConnectionState.X` → use the top-level `awaitState()` helper from `io.ably.lib`: ```kotlin -awaitState(client, ConnectionState.x, timeoutMs = N * 1000L) +awaitState(client, ConnectionState.x) // default 5s timeout +awaitState(client, ConnectionState.x, 10.seconds) ``` +For channels: `awaitChannelState(channel, ChannelState.attached)`. + ### Timer control -| Pseudocode | Kotlin | -|---|---| -| `enable_fake_timers()` | `val clock = FakeClock()` then `options.clock = clock` | -| `ADVANCE_TIME(ms)` | `clock.advance(ms)` | +```kotlin +val fakeClock = FakeClock() +val client = TestRealtimeClient { + autoConnect = false + install(mock) + enableFakeTimers(fakeClock) +} + +// Advance time — timer callbacks fire synchronously within advance() +fakeClock.advance(30_000) +// or +fakeClock.advance(30.seconds) +``` -After `clock.advance()`, always yield to let the SDK's timer callbacks dispatch: +After `fakeClock.advance()` inside a coroutine, yield to let any newly dispatched coroutines run: ```kotlin -clock.advance(30_000) +fakeClock.advance(30.seconds) yield() ``` @@ -133,83 +194,73 @@ yield() | `AWAIT expr FAILS WITH error` | `val error = assertFailsWith { expr }; assertEquals(..., error.errorInfo.code)` | | `ASSERT list.length == N` | `assertEquals(N, list.size)` | -### Test naming +### Test naming and annotation +- KDoc comment immediately above `@Test` using `/** @UTS */` format - Method name: backtick string `` ` - ` `` -- Add `// UTS: ` comment on the line immediately above `@Test` - Use `runTest { }` from `kotlinx.coroutines.test` for all async tests +```kotlin +/** + * @UTS realtime/unit/RTN4a/some-description-0 + */ +@Test +fun `RTN4a - description of what is being tested`() = runTest { + ... +} +``` + ### 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.TestRealtimeClient // or TestRestClient +import io.ably.lib.awaitChannelState // if testing channels +import io.ably.lib.awaitState +import io.ably.lib.realtime.ChannelState // if testing channels 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.test.mock.FakeClock // if using fake timers +import io.ably.lib.test.mock.MockWebSocket // or MockHttpClient +import io.ably.lib.types.ConnectionDetails import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.ProtocolMessage 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.* +import kotlin.time.Duration.Companion.seconds // if using Duration literals class Test { - @AfterTest - fun tearDown() { - // close any clients opened in each test (declare them at test scope, not class scope) - } - - // UTS: + /** + * @UTS realtime/unit// + */ @Test fun ` - `() = runTest { - val mock = MockWebSocket() - val options = DebugOptions("appId.keyId:keySecret").apply { - autoConnect = false - mock.installOn(this) + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "test-connection-id" + connectionDetails = ConnectionDetails { + connectionKey = "test-key" + maxIdleInterval = 15_000L + connectionStateTtl = 120_000L + } + }) + } } - - launch { - val conn = mock.awaitConnectionAttempt() - conn.respondWithSuccess() - mock.sendToClient(ProtocolMessage().apply { - action = ProtocolMessage.Action.connected - connectionId = "test-connection-id" - connectionKey = "test-key" - }) + val client = TestRealtimeClient { + autoConnect = false + install(mock) } - 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) } - } - } - } } ``` @@ -224,14 +275,14 @@ class Test { 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` +- `ErrorInfo` constructor arg order is `(message, statusCode, code)` — not `(code, statusCode, message)` --- ## Step 6 — Run tests ```bash -./gradlew :uts:test --tests ".Test" +./gradlew :uts:test --tests "." ``` Handle test failures: @@ -247,4 +298,4 @@ Handle test failures: ``` - 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. + - Spec point, what spec requires, what SDK does, which test is affected. \ No newline at end of file diff --git a/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt new file mode 100644 index 000000000..d5ef8fe27 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt @@ -0,0 +1,23 @@ +package io.ably.lib + +import io.ably.lib.debug.DebugOptions +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.rest.AblyRest +import io.ably.lib.test.mock.FakeClock +import io.ably.lib.test.mock.MockHttpClient +import io.ably.lib.test.mock.MockWebSocket + +class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") { + fun install(mock: MockWebSocket) = mock.installOn(this) + fun install(mock: MockHttpClient) = mock.installOn(this) + + fun enableFakeTimers(fakeClock: FakeClock) { + clock = fakeClock + } +} + +fun TestRealtimeClient(block: ClientOptionsBuilder.() -> Unit): AblyRealtime = + AblyRealtime(ClientOptionsBuilder().apply(block)) + +fun TestRestClient(block: ClientOptionsBuilder.() -> Unit): AblyRest = + AblyRest(ClientOptionsBuilder().apply(block)) diff --git a/uts/src/test/kotlin/io/ably/lib/SampleTest.kt b/uts/src/test/kotlin/io/ably/lib/SampleTest.kt deleted file mode 100644 index 86173f5c3..000000000 --- a/uts/src/test/kotlin/io/ably/lib/SampleTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -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) - } -} diff --git a/uts/src/test/kotlin/io/ably/lib/Utils.kt b/uts/src/test/kotlin/io/ably/lib/Utils.kt new file mode 100644 index 000000000..85c2ecb48 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/Utils.kt @@ -0,0 +1,57 @@ +package io.ably.lib + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ChannelStateListener +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.realtime.ConnectionStateListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +suspend fun awaitState( + client: AblyRealtime, + target: ConnectionState, + timeout: Duration = 5.seconds +) { + // withContext uses a real-thread dispatcher so withTimeout measures wall-clock time, + // not virtual (kotlinx.coroutines.test) time. + // Listener is registered BEFORE the state check to avoid the race where the target + // state fires on the ActionHandler thread between the check and the registration. + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { + suspendCancellableCoroutine { cont -> + val listener = ConnectionStateListener { change -> + if (change.current == target && cont.isActive) cont.resume(Unit) + } + client.connection.on(listener) + if (client.connection.state == target && cont.isActive) cont.resume(Unit) + cont.invokeOnCancellation { client.connection.off(listener) } + } + } + } +} + +suspend fun awaitChannelState( + channel: Channel, + target: ChannelState, + timeout: Duration = 5.seconds +) { + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeout) { + suspendCancellableCoroutine { cont -> + val listener = ChannelStateListener { change -> + if (change.current == target && cont.isActive) cont.resume(Unit) + } + channel.on(listener) + if (channel.state == target && cont.isActive) cont.resume(Unit) + cont.invokeOnCancellation { channel.off(listener) } + } + } + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt b/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt new file mode 100644 index 000000000..970b88752 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt @@ -0,0 +1,427 @@ +package io.ably.lib.realtime.unit.connection + +import io.ably.lib.TestRealtimeClient +import io.ably.lib.awaitChannelState +import io.ably.lib.awaitState +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.test.mock.CONNECTED_MESSAGE +import io.ably.lib.test.mock.FakeClock +import io.ably.lib.test.mock.MockWebSocket +import io.ably.lib.types.ConnectionDetails +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.RecoveryKeyContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.* +import kotlin.time.Duration.Companion.seconds + +class ConnectionRecoveryTest { + + /** + * @UTS realtime/unit/RTN16g/recovery-key-structure-0 + */ + @Test + fun `RTN16g, RTN16g1 - createRecoveryKey returns string with connectionKey, msgSerial, and channel and channelSerial pairs`() = + runTest { + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(CONNECTED_MESSAGE.apply { + connectionDetails = connectionDetails.apply { + connectionKey = "key-abc-123" + } + }) + } + } + val client = TestRealtimeClient { + autoConnect = false + install(mock) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + val channelA = client.channels.get("channel-alpha") + val channelB = client.channels.get("channel-éàü-世界") + + channelA.attach() + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = "channel-alpha" + channelSerial = "serial-a-001" + }) + awaitChannelState(channelA, ChannelState.attached) + + channelB.attach() + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = "channel-éàü-世界" + channelSerial = "serial-b-002" + }) + awaitChannelState(channelB, ChannelState.attached) + + val recoveryKeyString = client.connection.createRecoveryKey() + assertNotNull(recoveryKeyString) + + val recoveryKey = RecoveryKeyContext.decode(recoveryKeyString) + assertNotNull(recoveryKey) + assertEquals("key-abc-123", recoveryKey!!.connectionKey) + assertEquals(0L, recoveryKey.msgSerial) + assertNotNull(recoveryKey.channelSerials) + assertEquals("serial-a-001", recoveryKey.channelSerials["channel-alpha"]) + // RTN16g1: Unicode channel name is correctly encoded in the serialized key + assertEquals("serial-b-002", recoveryKey.channelSerials["channel-éàü-世界"]) + + // Verify round-trip: re-encoding and re-decoding preserves the unicode name + val reParsed = RecoveryKeyContext.decode(recoveryKey.encode()) + assertEquals("serial-b-002", reParsed!!.channelSerials["channel-éàü-世界"]) + + client.close() + } + + /** + * @UTS realtime/unit/RTN16g2/recovery-key-null-inactive-0 + */ + @Test + fun `RTN16g2 - createRecoveryKey returns null in inactive states and before first connect`() = runTest { + // --- Part 1: INITIALIZED state (before connect) --- + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "connection-1" + connectionDetails = ConnectionDetails { + connectionKey = "key-1" + maxIdleInterval = 15000L + connectionStateTtl = 120000L + } + }) + } + } + val client = TestRealtimeClient { + autoConnect = false + install(mock) + } + + // Before connecting — no connectionKey → null + assertNull(client.connection.createRecoveryKey()) + + client.connect() + awaitState(client, ConnectionState.connected) + assertNotNull(client.connection.createRecoveryKey()) + + // --- CLOSING and CLOSED states --- + // connection.close() sets key = null immediately (Connection.java:116) + client.connection.close() + assertNull(client.connection.createRecoveryKey()) + + awaitState(client, ConnectionState.closing) + assertNull(client.connection.createRecoveryKey()) + + // MockWebSocketClient.close() is a no-op; simulate the WS closing to trigger CLOSED + mock.simulateDisconnect() + awaitState(client, ConnectionState.closed) + assertNull(client.connection.createRecoveryKey()) + + // --- FAILED state --- + val mockFailed = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "conn-f" + connectionDetails = ConnectionDetails { + connectionKey = "key-f" + maxIdleInterval = 15000L + connectionStateTtl = 120000L + } + }) + } + } + val clientFailed = TestRealtimeClient { + autoConnect = false + install(mockFailed) + } + + clientFailed.connect() + awaitState(clientFailed, ConnectionState.connected) + + // NOTE: spec pseudocode used code 50000/statusCode 500 but that is non-fatal per SDK's + // isFatalError() (requires code 40000–49999 or statusCode < 500). Using code=40000, statusCode=400. + // ErrorInfo constructor: ErrorInfo(message, statusCode, code). + // sendToClient (not sendToClientAndClose): the mock's onClose(1000) would trigger + // SynchronousStateChangeAction(DISCONNECTED) which preempts the async FAILED action. + // The SDK's FAILED state handling calls clearTransport() itself. + mockFailed.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.error + error = ErrorInfo("Fatal error", 400, 40000) + }) + awaitState(clientFailed, ConnectionState.failed) + assertNull(clientFailed.connection.createRecoveryKey()) + + // --- SUSPENDED state (fake timers, short timeouts) --- + // Initial connection uses awaitConnectionAttempt because reconnection attempts after + // disconnect need different behavior (refused), and onConnectionAttempt handles all + // attempts uniformly. + val fakeClock = FakeClock() + val mockSuspended = MockWebSocket() + val clientSuspended = TestRealtimeClient { + autoConnect = false + disconnectedRetryTimeout = 300 + fallbackHosts = emptyArray() + install(mockSuspended) + enableFakeTimers(fakeClock) + } + + launch { + mockSuspended.awaitConnectionAttempt().respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "conn-s" + connectionDetails = ConnectionDetails { + connectionKey = "key-s" + maxIdleInterval = 15000L + connectionStateTtl = 800L + } + }) + } + + clientSuspended.connect() + awaitState(clientSuspended, ConnectionState.connected) + + mockSuspended.simulateDisconnect() + awaitState(clientSuspended, ConnectionState.disconnected) + + // Refuse all reconnection attempts after disconnect — refuseJob started here, + // AFTER the initial connection succeeded, so it cannot intercept the first connect. + val refuseJob = launch { + repeat(10) { + fakeClock.advance(2.seconds) + val pendingConnection = mockSuspended.awaitConnectionAttempt() + pendingConnection.respondWithRefused() + if (clientSuspended.connection.state == ConnectionState.suspended) return@launch + } + } + + awaitState(clientSuspended, ConnectionState.suspended) + refuseJob.cancel() + + assertNull(clientSuspended.connection.createRecoveryKey()) + clientSuspended.close() + } + + /** + * @UTS realtime/unit/RTN16k/recover-query-param-0 + */ + @Test + fun `RTN16k - recover option adds recover query param to WebSocket URL`() = runTest { + val recoveryKeyJson = RecoveryKeyContext("recovered-key-xyz", 5, emptyMap()).encode() + + var connectAttempt = 0 + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + val key = if (connectAttempt++ == 0) "new-key-after-recovery" else "resumed-key" + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "recovered-conn-id" + connectionDetails = ConnectionDetails { + connectionKey = key + maxIdleInterval = 15000L + connectionStateTtl = 120000L + } + }) + } + } + val client = TestRealtimeClient { + autoConnect = false + recover = recoveryKeyJson + install(mock) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + mock.simulateDisconnect() + awaitState(client, ConnectionState.connected) + + // NOTE: PendingConnection does not expose URL query params (host/port/tls only). + // The following assertions require URL inspection which is not available in the current mock. + // They are verified only when RUN_DEVIATIONS is set (treated as mock limitation, not SDK deviation). + if (System.getenv("RUN_DEVIATIONS") != null) { + // ASSERT captured_connection_attempts[0].url.query_params["recover"] == "recovered-key-xyz" + // ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params + // ASSERT captured_connection_attempts[1].url.query_params["resume"] == "new-key-after-recovery" + // ASSERT "recover" NOT IN captured_connection_attempts[1].url.query_params + fail("URL query param assertions require extending PendingConnection with a url field") + } + + client.close() + } + + /** + * @UTS realtime/unit/RTN16f/recover-initializes-msgserial-0 + */ + @Test + fun `RTN16f - recover option initializes msgSerial from recoveryKey`() = runTest { + val recoveryKeyJson = RecoveryKeyContext( + "old-key", + 42L, + mapOf("test-channel" to "ch-serial-1") + ).encode() + + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "recovered-conn" + connectionDetails = ConnectionDetails { + connectionKey = "new-key" + maxIdleInterval = 15000L + connectionStateTtl = 120000L + } + }) + } + } + val client = TestRealtimeClient { + autoConnect = false + recover = recoveryKeyJson + install(mock) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + val testChannel = client.channels.get("test-channel") + testChannel.attach() + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = "test-channel" + channelSerial = "ch-serial-updated" + }) + awaitChannelState(testChannel, ChannelState.attached) + + // Verify msgSerial was initialized from the recoveryKey (RTN16f). + // SDK deviation: ConnectionManager.onConnected() resets msgSerial = 0 when connection.id == null + // (fresh client, ConnectionManager.java:1316), even when using the recover option. + // Spec requires msgSerial to be preserved from the recovery key (42). See deviations.md. + val currentRecoveryKey = RecoveryKeyContext.decode(client.connection.createRecoveryKey()!!) + assertNotNull(currentRecoveryKey) + if (System.getenv("RUN_DEVIATIONS") != null) { + assertEquals(42L, currentRecoveryKey.msgSerial) + } + + client.close() + } + + /** + * @UTS realtime/unit/RTN16f1/malformed-recovery-key-0 + */ + @Test + fun `RTN16f1 - Malformed recoveryKey logs error and connects normally`() = runTest { + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "fresh-conn" + connectionDetails = ConnectionDetails { + connectionKey = "fresh-key" + maxIdleInterval = 15000L + connectionStateTtl = 120000L + } + }) + } + } + val client = TestRealtimeClient { + autoConnect = false + recover = "this-is-not-valid-json!!!" + install(mock) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + assertEquals(ConnectionState.connected, client.connection.state) + assertEquals("fresh-conn", client.connection.id) + assertEquals("fresh-key", client.connection.key) + + // NOTE: PendingConnection does not expose URL query params. + // The assertions below verify no recover/resume params were sent but require URL access. + if (System.getenv("RUN_DEVIATIONS") != null) { + // ASSERT "recover" NOT IN captured_connection_attempts[0].url.query_params + // ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params + fail("URL query param assertions require extending PendingConnection with a url field") + } + + client.close() + } + + /** + * @UTS realtime/unit/RTN16j/recover-channel-serials-0 + */ + @Test + fun `RTN16j - recover option instantiates channels from recoveryKey with correct channelSerials`() = runTest { + val recoveryKeyJson = RecoveryKeyContext( + "old-key-abc", + 10L, + mapOf( + "channel-one" to "serial-1-abc", + "channel-two" to "serial-2-def", + "channel-üñîçöðé" to "serial-3-unicode" + ) + ).encode() + + val mock = MockWebSocket { + onConnectionAttempt = { conn -> + conn.respondWithSuccess(ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "recovered-conn" + connectionDetails = ConnectionDetails { + connectionKey = "new-key" + maxIdleInterval = 15000L + connectionStateTtl = 120000L + } + }) + } + } + val client = TestRealtimeClient { + autoConnect = false + recover = recoveryKeyJson + install(mock) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + // RTN16j: Channels from the recoveryKey are instantiated with their channelSerials + val channelOne = client.channels.get("channel-one") + val channelTwo = client.channels.get("channel-two") + val channelUnicode = client.channels.get("channel-üñîçöðé") + + assertEquals("serial-1-abc", channelOne.properties.channelSerial) + assertEquals("serial-2-def", channelTwo.properties.channelSerial) + assertEquals("serial-3-unicode", channelUnicode.properties.channelSerial) + + // RTN16i: Channels are NOT automatically attached — should be in INITIALIZED state + assertEquals(ChannelState.initialized, channelOne.state) + assertEquals(ChannelState.initialized, channelTwo.state) + assertEquals(ChannelState.initialized, channelUnicode.state) + + channelOne.attach() + + // NOTE: MockEvent does not capture outgoing WS frames (no ClientFrame event type). + // The assertion that the ATTACH message includes channelSerial="serial-1-abc" requires + // outgoing frame inspection and is gated below. + if (System.getenv("RUN_DEVIATIONS") != null) { + // ASSERT sent ATTACH frame for "channel-one" has channelSerial == "serial-1-abc" + fail("Outgoing WS frame inspection requires a ClientFrame event type in MockEvent") + } + + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = "channel-one" + channelSerial = "serial-1-abc-updated" + }) + awaitChannelState(channelOne, ChannelState.attached) + + client.close() + } +} \ No newline at end of file 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 index 034fa5954..5edcc04b9 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt @@ -1,9 +1,11 @@ package io.ably.lib.test.mock +import io.ably.lib.debug.DebugOptions import io.ably.lib.util.Clock import io.ably.lib.util.NamedTimer import io.ably.lib.util.TimerInstance import java.util.TimerTask +import kotlin.time.Duration class FakeClock(initialTimeMs: Long = 0L) : Clock { @Volatile private var time = initialTimeMs @@ -22,10 +24,7 @@ class FakeClock(initialTimeMs: Long = 0L) : Clock { timers.values.forEach { it.fireDue(time) } } - fun advance(timerName: String, ms: Long) { - time += ms - timers[timerName]?.fireDue(time) - } + fun advance(time: Duration) = advance(time.inWholeMilliseconds) fun pendingTaskCount(timerName: String) = timers[timerName]?.pendingCount ?: 0 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 947be66ec..9895cdde6 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 @@ -51,8 +51,4 @@ class MockHttpClient(private val config: HttpMockConfig = HttpMockConfig()) { } } -fun installMockHttpClient(options: DebugOptions, init: HttpMockConfig.() -> Unit): MockHttpClient { - val mock = MockHttpClient(config = HttpMockConfig().apply(init)) - mock.installOn(options) - return mock -} +fun MockHttpClient(init: HttpMockConfig.() -> Unit) = MockHttpClient(config = HttpMockConfig().apply(init)) 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 index 8f77101b5..9423313a6 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt @@ -3,6 +3,7 @@ package io.ably.lib.test.mock import io.ably.lib.debug.DebugOptions import io.ably.lib.network.WebSocketEngineFactory import io.ably.lib.network.WebSocketListener +import io.ably.lib.types.ConnectionDetails import io.ably.lib.types.ProtocolMessage import io.ably.lib.util.Serialisation import kotlinx.coroutines.Dispatchers @@ -140,9 +141,17 @@ private class EventTrackingPendingConnection( } } -fun installMockWebSocket(options: DebugOptions, init: WebSocketMockConfig.() -> Unit): MockWebSocket { - val mock = MockWebSocket(config = WebSocketMockConfig().apply(init)) - mock.installOn(options) - return mock -} +fun MockWebSocket(init: WebSocketMockConfig.() -> Unit): MockWebSocket = MockWebSocket(config = WebSocketMockConfig().apply(init)) + +/** Pre-built CONNECTED message suitable for most unit tests. */ +val CONNECTED_MESSAGE: ProtocolMessage + get() = ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "test-connection-id" + connectionDetails = ConnectionDetails { + connectionKey = "test-connection-key" + connectionStateTtl = 120_000L + maxIdleInterval = 15_000L + } + } diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt deleted file mode 100644 index b5bb3725c..000000000 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/NetworkMocks.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.ably.lib.test.mock - -import io.ably.lib.debug.DebugOptions -import io.ably.lib.types.ConnectionDetails -import io.ably.lib.types.ProtocolMessage - -data class NetworkMocks(val http: MockHttpClient, val webSocket: MockWebSocket) - -class NetworkMocksConfig { - internal val httpConfig = HttpMockConfig() - internal val wsConfig = WebSocketMockConfig() - - fun httpClient(block: HttpMockConfig.() -> Unit) { - httpConfig.apply(block) - } - - fun webSocketClient(block: WebSocketMockConfig.() -> Unit) { - wsConfig.apply(block) - } -} - -fun installNetworkMocks(options: DebugOptions, block: NetworkMocksConfig.() -> Unit = {}): NetworkMocks { - val cfg = NetworkMocksConfig().apply(block) - val http = MockHttpClient(cfg.httpConfig) - val ws = MockWebSocket(cfg.wsConfig) - options.httpEngine = http.engine - options.webSocketEngineFactory = ws.engineFactory - return NetworkMocks(http, ws) -} - -/** Pre-built CONNECTED message suitable for most unit tests. */ -val CONNECTED_MESSAGE: ProtocolMessage - get() = ProtocolMessage().apply { - action = ProtocolMessage.Action.connected - connectionId = "test-connection-id" - connectionDetails = ConnectionDetails { - connectionKey = "test-connection-key" - connectionStateTtl = 120_000L - maxIdleInterval = 15_000L - } - } diff --git a/uts/src/test/kotlin/io/ably/lib/types/Utils.kt b/uts/src/test/kotlin/io/ably/lib/types/Utils.kt new file mode 100644 index 000000000..15c11d557 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/types/Utils.kt @@ -0,0 +1,3 @@ +package io.ably.lib.types + +fun ConnectionDetails(init: ConnectionDetails.() -> Unit) = ConnectionDetails().apply(init) From 8b95ba060e1d76bd95f8e73c68ae367d0544d563 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 18 May 2026 15:07:30 +0100 Subject: [PATCH 07/11] uts: add outgoing frame inspection and extend `MockWebSocket` with message decoding - Added support for inspecting outgoing WebSocket frames (`MessageFromClient` events) in `MockWebSocket`. - Enhanced `ClientOptionsBuilder` to set `useBinaryProtocol = false` by default for JSON text frame testing. - Updated `ConnectionRecoveryTest` to assert on outgoing ATTACH messages. --- .claude/skills/uts-to-kotlin/SKILL.md | 22 +++++++++++++++++++ .github/workflows/check.yml | 2 +- .../kotlin/io/ably/lib/ClientFactories.kt | 4 ++++ .../unit/connection/ConnectionRecoveryTest.kt | 12 ++++------ .../io/ably/lib/test/mock/MockWebSocket.kt | 13 +++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index ebf5b5ac0..c09645b9d 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -121,6 +121,28 @@ val mockHttp = MockHttpClient { onRequest = { req -> req.respondWith(200, body) val client = TestRestClient { install(mockHttp) } ``` +### Inspecting outgoing frames (client → server) + +`ClientOptionsBuilder` sets `useBinaryProtocol = false`, so the SDK sends JSON text frames. Every outgoing frame is captured as `MockEvent.MessageFromClient` and queued in `awaitNextMessageFromClient()`. + +To assert on a message the client sent: + +```kotlin +// After triggering the SDK action (e.g. attach): +channelOne.attach() +val msg = mock.awaitNextMessageFromClient() +assertEquals(ProtocolMessage.Action.attach, msg.action) +assertEquals("channel-one", msg.channel) +assertEquals("expected-serial", msg.channelSerial) + +// Or filter the full event log when order doesn't matter: +val sent = mock.events + .filterIsInstance() + .firstOrNull { it.message.action == ProtocolMessage.Action.attach && it.message.channel == "channel-one" } +assertNotNull(sent) +assertEquals("expected-serial", sent!!.message.channelSerial) +``` + ### Mock method reference | Pseudocode | Kotlin | diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0ae15c491..1f12a91f6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -19,4 +19,4 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@v3 - - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests :uts:test diff --git a/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt b/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt index d5ef8fe27..3bc7694a8 100644 --- a/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt +++ b/uts/src/test/kotlin/io/ably/lib/ClientFactories.kt @@ -8,6 +8,10 @@ import io.ably.lib.test.mock.MockHttpClient import io.ably.lib.test.mock.MockWebSocket class ClientOptionsBuilder : DebugOptions("appId.keyId:keySecret") { + init { + useBinaryProtocol = false + } + fun install(mock: MockWebSocket) = mock.installOn(this) fun install(mock: MockHttpClient) = mock.installOn(this) diff --git a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt b/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt index 970b88752..3e5149dc0 100644 --- a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt @@ -406,14 +406,10 @@ class ConnectionRecoveryTest { assertEquals(ChannelState.initialized, channelUnicode.state) channelOne.attach() - - // NOTE: MockEvent does not capture outgoing WS frames (no ClientFrame event type). - // The assertion that the ATTACH message includes channelSerial="serial-1-abc" requires - // outgoing frame inspection and is gated below. - if (System.getenv("RUN_DEVIATIONS") != null) { - // ASSERT sent ATTACH frame for "channel-one" has channelSerial == "serial-1-abc" - fail("Outgoing WS frame inspection requires a ClientFrame event type in MockEvent") - } + val attachMessage = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.attach, attachMessage.action) + assertEquals("channel-one", attachMessage.channel) + assertEquals("serial-1-abc", attachMessage.channelSerial) mock.sendToClient(ProtocolMessage().apply { action = ProtocolMessage.Action.attached 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 index 9423313a6..7ae6b1539 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt @@ -5,6 +5,7 @@ import io.ably.lib.network.WebSocketEngineFactory import io.ably.lib.network.WebSocketListener import io.ably.lib.types.ConnectionDetails import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.ProtocolSerializer import io.ably.lib.util.Serialisation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -61,6 +62,18 @@ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { }, onBinaryFrame = { bytes -> config.onBinaryDataFrame?.invoke(bytes) + // The SDK always sends byte[] frames (even for JSON encoding). Try JSON first + // (useBinaryProtocol = false), fall back to msgpack (useBinaryProtocol = true). + val decoded = runCatching { + Serialisation.gson.fromJson(String(bytes, Charsets.UTF_8), ProtocolMessage::class.java) + }.getOrElse { + runCatching { ProtocolSerializer.readMsgpack(bytes) }.getOrNull() + } + if (decoded != null) { + _events.add(MockEvent.MessageFromClient(decoded)) + val handler = config.onMessageFromClient + if (handler != null) handler(decoded) else _messagesFromClient.trySend(decoded) + } }, onClientClose = { code, reason -> val event = MockEvent.ClientClose(code, reason) From 4fe57847ffc3b3688a57f505bbdaf67e6482e5bb Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 18 May 2026 15:19:05 +0100 Subject: [PATCH 08/11] uts: add support for query parameter parsing and assertions in mock connections - Added `queryParams` field and `parseQueryString` utility for `PendingConnection` implementations. - Updated `MockWebSocketEngineFactory` and `MockHttpEngine` to parse and pass query parameters. - Enhanced connection recovery tests with assertions on query parameters. --- .../unit/connection/ConnectionRecoveryTest.kt | 28 +++++++------------ .../lib/test/mock/DefaultPendingConnection.kt | 1 + .../kotlin/io/ably/lib/test/mock/FakeClock.kt | 1 - .../io/ably/lib/test/mock/MockHttpEngine.kt | 4 ++- .../test/mock/MockWebSocketEngineFactory.kt | 4 +-- .../ably/lib/test/mock/PendingConnection.kt | 12 ++++++++ 6 files changed, 28 insertions(+), 22 deletions(-) diff --git a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt b/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt index 3e5149dc0..5d664a93b 100644 --- a/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt +++ b/uts/src/test/kotlin/io/ably/lib/realtime/unit/connection/ConnectionRecoveryTest.kt @@ -216,9 +216,11 @@ class ConnectionRecoveryTest { fun `RTN16k - recover option adds recover query param to WebSocket URL`() = runTest { val recoveryKeyJson = RecoveryKeyContext("recovered-key-xyz", 5, emptyMap()).encode() + val capturedQueryParams = mutableListOf>() var connectAttempt = 0 val mock = MockWebSocket { onConnectionAttempt = { conn -> + capturedQueryParams += conn.queryParams val key = if (connectAttempt++ == 0) "new-key-after-recovery" else "resumed-key" conn.respondWithSuccess(ProtocolMessage().apply { action = ProtocolMessage.Action.connected @@ -243,16 +245,10 @@ class ConnectionRecoveryTest { mock.simulateDisconnect() awaitState(client, ConnectionState.connected) - // NOTE: PendingConnection does not expose URL query params (host/port/tls only). - // The following assertions require URL inspection which is not available in the current mock. - // They are verified only when RUN_DEVIATIONS is set (treated as mock limitation, not SDK deviation). - if (System.getenv("RUN_DEVIATIONS") != null) { - // ASSERT captured_connection_attempts[0].url.query_params["recover"] == "recovered-key-xyz" - // ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params - // ASSERT captured_connection_attempts[1].url.query_params["resume"] == "new-key-after-recovery" - // ASSERT "recover" NOT IN captured_connection_attempts[1].url.query_params - fail("URL query param assertions require extending PendingConnection with a url field") - } + assertEquals("recovered-key-xyz", capturedQueryParams[0]["recover"]) + assertNull(capturedQueryParams[0]["resume"]) + assertEquals("new-key-after-recovery", capturedQueryParams[1]["resume"]) + assertNull(capturedQueryParams[1]["recover"]) client.close() } @@ -317,8 +313,10 @@ class ConnectionRecoveryTest { */ @Test fun `RTN16f1 - Malformed recoveryKey logs error and connects normally`() = runTest { + var capturedQueryParams: Map? = null val mock = MockWebSocket { onConnectionAttempt = { conn -> + capturedQueryParams = conn.queryParams conn.respondWithSuccess(ProtocolMessage().apply { action = ProtocolMessage.Action.connected connectionId = "fresh-conn" @@ -342,14 +340,8 @@ class ConnectionRecoveryTest { assertEquals(ConnectionState.connected, client.connection.state) assertEquals("fresh-conn", client.connection.id) assertEquals("fresh-key", client.connection.key) - - // NOTE: PendingConnection does not expose URL query params. - // The assertions below verify no recover/resume params were sent but require URL access. - if (System.getenv("RUN_DEVIATIONS") != null) { - // ASSERT "recover" NOT IN captured_connection_attempts[0].url.query_params - // ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params - fail("URL query param assertions require extending PendingConnection with a url field") - } + assertNull(capturedQueryParams!!["recover"]) + assertNull(capturedQueryParams!!["resume"]) client.close() } diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt index 8f43db177..5df6ad88c 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingConnection.kt @@ -11,6 +11,7 @@ internal class DefaultPendingConnection( override val host: String, override val port: Int, override val tls: Boolean, + override val queryParams: Map, private val listener: WebSocketListener, private val onConnected: (WebSocketListener) -> Unit = {}, ) : PendingConnection { 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 index 5edcc04b9..e378bb158 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt @@ -1,6 +1,5 @@ package io.ably.lib.test.mock -import io.ably.lib.debug.DebugOptions import io.ably.lib.util.Clock import io.ably.lib.util.NamedTimer import io.ably.lib.util.TimerInstance 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 index 83c38cfd5..b7e07ee8b 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockHttpEngine.kt @@ -34,7 +34,8 @@ internal class MockHttpCall( val url = request.url val tls = url.protocol == "https" val port = if (url.port != -1) url.port else if (tls) 443 else 80 - onConnect(DefaultHttpPendingConnection(url.host, port, tls, cd)) + val queryParams = parseQueryString(url.query) + onConnect(DefaultHttpPendingConnection(url.host, port, tls, queryParams, cd)) cd.await() // Phase 2 — request @@ -53,6 +54,7 @@ internal class DefaultHttpPendingConnection( override val host: String, override val port: Int, override val tls: Boolean, + override val queryParams: Map, private val deferred: CompletableDeferred, ) : PendingConnection { override fun respondWithSuccess() { deferred.complete(Unit) } 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 3c9a88e81..53906ec16 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 @@ -44,10 +44,10 @@ internal class MockWebSocketClient( private val onClientClose: (Int, String) -> Unit, ) : WebSocketClient { override fun connect() { - val uri = URI(url.substringBefore('?')) + val uri = URI(url) val tls = uri.scheme == "wss" val port = if (uri.port == -1) (if (tls) 443 else 80) else uri.port - onConnect(DefaultPendingConnection(uri.host, port, tls, listener, onConnected)) + onConnect(DefaultPendingConnection(uri.host, port, tls, parseQueryString(uri.rawQuery), listener, onConnected)) } override fun close() {} 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 index 2df3785d6..a644bf925 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt @@ -1,11 +1,23 @@ package io.ably.lib.test.mock import io.ably.lib.types.ProtocolMessage +import java.net.URLDecoder + +internal fun parseQueryString(rawQuery: String?): Map { + if (rawQuery.isNullOrEmpty()) return emptyMap() + return rawQuery.split("&").mapNotNull { pair -> + val idx = pair.indexOf('=') + if (idx < 0) null + else URLDecoder.decode(pair.substring(0, idx), "UTF-8") to + URLDecoder.decode(pair.substring(idx + 1), "UTF-8") + }.toMap() +} interface PendingConnection { val host: String val port: Int val tls: Boolean + val queryParams: Map fun respondWithSuccess() fun respondWithSuccess(message: ProtocolMessage) fun respondWithRefused() From c9d79f25ac7aa8f569ee0445c7968469b05a3afd Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 18 May 2026 15:29:05 +0100 Subject: [PATCH 09/11] uts: add inline documentation for fake clocks, HTTP, and WebSocket mocks - Added KDoc comments to `FakeClock`, `MockHttpClient`, and `MockWebSocket` explaining their purpose and usage. --- .../kotlin/io/ably/lib/test/mock/FakeClock.kt | 9 ++++++ .../kotlin/io/ably/lib/test/mock/MockEvent.kt | 11 +++++++ .../io/ably/lib/test/mock/MockHttpClient.kt | 15 ++++++++++ .../io/ably/lib/test/mock/MockWebSocket.kt | 29 +++++++++++++++++++ .../ably/lib/test/mock/PendingConnection.kt | 8 +++++ .../io/ably/lib/test/mock/PendingRequest.kt | 7 +++++ 6 files changed, 79 insertions(+) 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 index e378bb158..1ac0feaf4 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt @@ -6,6 +6,12 @@ import io.ably.lib.util.TimerInstance import java.util.TimerTask import kotlin.time.Duration +/** + * Virtual clock for deterministic time control in unit tests. + * + * Install via `enableFakeTimers(fakeClock)` inside a `TestRealtimeClient` or `TestRestClient` block. + * Time only advances when [advance] is called; timer callbacks fire synchronously within that call. + */ class FakeClock(initialTimeMs: Long = 0L) : Clock { @Volatile private var time = initialTimeMs private val timers = mutableMapOf() @@ -18,13 +24,16 @@ class FakeClock(initialTimeMs: Long = 0L) : Clock { return t } + /** Advance virtual time by [ms] milliseconds, firing any timers that become due. */ fun advance(ms: Long) { time += ms timers.values.forEach { it.fireDue(time) } } + /** Advance virtual time by [time], firing any timers that become due. */ fun advance(time: Duration) = advance(time.inWholeMilliseconds) + /** Number of tasks currently scheduled on the named timer — useful for asserting retry state. */ fun pendingTaskCount(timerName: String) = timers[timerName]?.pendingCount ?: 0 inner class FakeNamedTimer(val name: String) : NamedTimer { 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 index 138ab74f6..09354baf5 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockEvent.kt @@ -2,15 +2,26 @@ package io.ably.lib.test.mock import io.ably.lib.types.ProtocolMessage +/** Ordered log of everything that happened on a mock transport. Inspect via [MockWebSocket.events]. */ sealed class MockEvent { + /** SDK initiated a WebSocket connection to [host]:[port]. */ data class ConnectionAttempt(val host: String, val port: Int, val tls: Boolean) : MockEvent() + /** WebSocket handshake completed (after [PendingConnection.respondWithSuccess]). */ data object ConnectionEstablished : MockEvent() + /** Test responded to a connection attempt with [PendingConnection.respondWithRefused]. */ data object ConnectionRefused : MockEvent() + /** Test responded to a connection attempt with [PendingConnection.respondWithTimeout]. */ data object ConnectionTimeout : MockEvent() + /** Test responded to a connection attempt with [PendingConnection.respondWithDnsError]. */ data object DnsError : MockEvent() + /** SDK made an HTTP request (recorded by [MockHttpClient]). */ data class HttpRequest(val url: java.net.URL, val method: String) : MockEvent() + /** Test delivered [message] to the SDK via [MockWebSocket.sendToClient]. */ data class SentToClient(val message: ProtocolMessage) : MockEvent() + /** Test called [MockWebSocket.simulateDisconnect] (abnormal close, code 1006). */ data object Disconnected : MockEvent() + /** SDK closed the WebSocket (code and reason from the SDK's close frame). */ data class ClientClose(val code: Int, val reason: String) : MockEvent() + /** SDK sent [message] to the server (decoded from text or binary frame). */ data class MessageFromClient(val message: ProtocolMessage) : 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 9895cdde6..df4895c88 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 @@ -9,11 +9,22 @@ import kotlinx.coroutines.withTimeout import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Callbacks for [MockHttpClient]. Both fields are optional. + * + * When a callback is set it is invoked synchronously; when null the event queues for the + * corresponding `await*` method. + */ class HttpMockConfig { + /** Called for every TCP connection attempt. Leave null to use [MockHttpClient.awaitConnectionAttempt]. */ var onConnectionAttempt: ((PendingConnection) -> Unit)? = null + /** Called for every HTTP request. Leave null to use [MockHttpClient.awaitRequest]. */ var onRequest: ((PendingRequest) -> Unit)? = null } +/** + * Fake HTTP engine for SDK unit tests. Install via [installOn] or `TestRestClient`/`TestRealtimeClient`. + */ class MockHttpClient(private val config: HttpMockConfig = HttpMockConfig()) { private var _pendingConnections = Channel(Channel.UNLIMITED) private var _pendingRequests = Channel(Channel.UNLIMITED) @@ -29,20 +40,24 @@ class MockHttpClient(private val config: HttpMockConfig = HttpMockConfig()) { }, ) + /** Wire this mock into [options] so the SDK uses it instead of a real HTTP client. */ fun installOn(options: DebugOptions) { options.httpEngine = engine } + /** Suspend until the SDK opens a TCP connection. Only usable when [HttpMockConfig.onConnectionAttempt] is null. */ suspend fun awaitConnectionAttempt(timeout: Duration = 5.seconds): PendingConnection = withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { _pendingConnections.receive() } } + /** Suspend until the SDK makes an HTTP request. Only usable when [HttpMockConfig.onRequest] is null. */ suspend fun awaitRequest(timeout: Duration = 5.seconds): PendingRequest = withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { _pendingRequests.receive() } } + /** Clear all queued pending connections and requests. Call between tests when reusing a mock instance. */ fun reset() { _pendingConnections.close() _pendingRequests.close() 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 index 7ae6b1539..e6c3db0cf 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/MockWebSocket.kt @@ -15,15 +15,36 @@ import java.util.Collections import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Callbacks for [MockWebSocket]. All fields are optional. + * + * When a callback is set it receives the event immediately (synchronous, on the SDK's thread). + * When a callback is null the event is queued for the corresponding `await*` method instead. + * The two styles cannot be mixed for the same event type. + */ class WebSocketMockConfig { + /** Called for every connection attempt. Set to respond inline; leave null to use [MockWebSocket.awaitConnectionAttempt]. */ var onConnectionAttempt: ((PendingConnection) -> Unit)? = null + /** Called for every decoded protocol message from the SDK. Leave null to use [MockWebSocket.awaitNextMessageFromClient]. */ var onMessageFromClient: ((ProtocolMessage) -> Unit)? = null + /** Raw text frame callback — rarely needed; prefer [onMessageFromClient]. */ var onTextDataFrame: ((String) -> Unit)? = null + /** Raw binary frame callback — rarely needed; prefer [onMessageFromClient]. */ var onBinaryDataFrame: ((ByteArray) -> Unit)? = null } +/** + * Fake WebSocket transport for SDK unit tests. Install via [installOn] or `TestRealtimeClient`. + * + * Two usage styles for connection/message handling (cannot mix per event type): + * - **Callback** (`onConnectionAttempt`, `onMessageFromClient` in [WebSocketMockConfig]): handle + * inline, synchronously on the SDK thread. Preferred for single-behaviour setups. + * - **Await** ([awaitConnectionAttempt], [awaitNextMessageFromClient]): suspend until the SDK + * triggers the event. Required when initial and reconnection attempts need different behaviour. + */ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { private val _events = Collections.synchronizedList(mutableListOf()) + /** Snapshot of all events recorded since construction (or last [reset]). */ val events: List get() = _events.toList() private var _pendingConnections = Channel(Channel.UNLIMITED) @@ -82,31 +103,37 @@ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { }, ) + /** Wire this mock into [options] so the SDK uses it instead of a real WebSocket. */ fun installOn(options: DebugOptions) { options.webSocketEngineFactory = engineFactory } + /** Suspend until the SDK opens a new WebSocket connection. Only usable when [WebSocketMockConfig.onConnectionAttempt] is null. */ suspend fun awaitConnectionAttempt(timeout: Duration = 5.seconds): PendingConnection = withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { _pendingConnections.receive() } } + /** Suspend until the SDK sends a protocol message to the server. Only usable when [WebSocketMockConfig.onMessageFromClient] is null. */ suspend fun awaitNextMessageFromClient(timeout: Duration = 5.seconds): ProtocolMessage = withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { _messagesFromClient.receive() } } + /** Suspend until the SDK sends a WebSocket close frame. */ suspend fun awaitClientClose(timeout: Duration = 5.seconds): MockEvent.ClientClose = withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(timeout) { _clientCloseEvents.receive() } } + /** Deliver [message] from the fake server to the SDK over the active connection. */ fun sendToClient(message: ProtocolMessage) { val listener = checkNotNull(activeListener) { "No active WebSocket connection" } _events.add(MockEvent.SentToClient(message)) listener.onMessage(Serialisation.gson.toJson(message)) } + /** Deliver [message] to the SDK then immediately close the connection with code 1000. */ fun sendToClientAndClose(message: ProtocolMessage) { val listener = checkNotNull(activeListener) { "No active WebSocket connection" } _events.add(MockEvent.SentToClient(message)) @@ -115,6 +142,7 @@ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { listener.onClose(1000, "Normal closure") } + /** Simulate an abnormal network drop (close code 1006). Triggers DISCONNECTED on the SDK. */ fun simulateDisconnect() { val listener = checkNotNull(activeListener) { "No active WebSocket connection" } _events.add(MockEvent.Disconnected) @@ -122,6 +150,7 @@ class MockWebSocket(config: WebSocketMockConfig = WebSocketMockConfig()) { listener.onClose(1006, "Abnormal closure") } + /** Clear all queued events and pending channels. Call between tests when reusing a mock instance. */ fun reset() { _pendingConnections.close() _messagesFromClient.close() 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 index a644bf925..55f3fb384 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingConnection.kt @@ -13,12 +13,20 @@ internal fun parseQueryString(rawQuery: String?): Map { }.toMap() } +/** + * A WebSocket or HTTP connection attempt that the test must resolve. + * + * Received via [MockWebSocket.awaitConnectionAttempt] or [WebSocketMockConfig.onConnectionAttempt]. + */ interface PendingConnection { val host: String val port: Int val tls: Boolean + /** URL query parameters decoded from the connection URL (e.g. `key`, `recover`, `resume`, `format`). */ val queryParams: Map + /** Open the connection without sending any initial message. */ fun respondWithSuccess() + /** Open the connection and immediately deliver [message] to the SDK (e.g. CONNECTED). */ fun respondWithSuccess(message: ProtocolMessage) fun respondWithRefused() fun respondWithTimeout() 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 index ece6b4bd4..eeaadddd4 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt @@ -2,12 +2,19 @@ package io.ably.lib.test.mock import java.time.Duration +/** + * An in-flight HTTP request that the test must resolve. + * + * Received via [MockHttpClient.awaitRequest] or [HttpMockConfig.onRequest]. + */ interface PendingRequest { val url: java.net.URL val method: String val headers: Map> val body: ByteArray + /** Complete the request with [status] and [body] (Map, String, or ByteArray). */ fun respondWith(status: Int, body: Any, headers: Map = emptyMap()) + /** Complete the request after [delay], useful for testing timeout behaviour. */ fun respondWithDelay(delay: Duration, status: Int, body: Any) fun respondWithTimeout() } From cdf3a7cc30362dcf70f1001e0efea2b27e428539 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 19 May 2026 10:11:44 +0100 Subject: [PATCH 10/11] uts: updated skill to reference spec guide for writing tests --- .claude/skills/uts-to-kotlin/SKILL.md | 89 ++++++++++++++++++--------- 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index c09645b9d..657824ea5 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -3,15 +3,17 @@ description: "Translate a UTS pseudocode test spec into Kotlin tests in the uts 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. +Translate the UTS pseudocode test spec at `$ARGUMENTS` into a runnable Kotlin test in the `uts` module. + +Reference: [Writing Derived Tests](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) --- ## 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) +- All test cases — each has a structured ID like `realtime/unit/RSA4c2/callback-error-connecting-disconnected-0` and a description +- The protocol used (WebSocket for Realtime, HTTP for REST) - Any timer usage (`enable_fake_timers`, `ADVANCE_TIME`) --- @@ -110,7 +112,6 @@ val refuseJob = launch { repeat(10) { fakeClock.advance(2.seconds) mockWs.awaitConnectionAttempt().respondWithRefused() - ... } } ``` @@ -121,7 +122,7 @@ val mockHttp = MockHttpClient { onRequest = { req -> req.respondWith(200, body) val client = TestRestClient { install(mockHttp) } ``` -### Inspecting outgoing frames (client → server) +### Inspecting outgoing frames `ClientOptionsBuilder` sets `useBinaryProtocol = false`, so the SDK sends JSON text frames. Every outgoing frame is captured as `MockEvent.MessageFromClient` and queued in `awaitNextMessageFromClient()`. @@ -147,8 +148,8 @@ assertEquals("expected-serial", sent!!.message.channelSerial) | Pseudocode | Kotlin | |---|---| -| `conn.respond_with_success()` | `conn.respondWithSuccess()` (opens socket only) | -| `conn.respond_with_success(msg)` | `conn.respondWithSuccess(msg)` (opens socket + delivers message) | +| `conn.respond_with_success()` | `conn.respondWithSuccess()` | +| `conn.respond_with_success(msg)` | `conn.respondWithSuccess(msg)` | | `conn.respond_with_refused()` | `conn.respondWithRefused()` | | `conn.respond_with_timeout()` | `conn.respondWithTimeout()` | | `conn.respond_with_dns_error()` | `conn.respondWithDnsError()` | @@ -164,16 +165,16 @@ assertEquals("expected-serial", sent!!.message.channelSerial) |---|---| | `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("...", Y, X)` — note arg order: message, statusCode, code | +| `ErrorInfo(code: X, statusCode: Y, message: "...")` | `ErrorInfo("...", Y, X)` — arg order: message, statusCode, code | | `ConnectionDetails(connectionKey: ..., maxIdleInterval: ..., connectionStateTtl: ...)` | `ConnectionDetails { connectionKey = "..."; maxIdleInterval = ...; connectionStateTtl = ... }` | | `ConnectionState.connected` etc. | `ConnectionState.connected`, `.disconnected`, `.suspended`, `.failed`, `.connecting`, `.closing`, `.closed` | ### Awaiting state -`AWAIT_STATE client.connection.state == ConnectionState.X` → use the top-level `awaitState()` helper from `io.ably.lib`: +`AWAIT_STATE client.connection.state == ConnectionState.X` → use the top-level `awaitState()` helper: ```kotlin -awaitState(client, ConnectionState.x) // default 5s timeout +awaitState(client, ConnectionState.x) // default 5s timeout awaitState(client, ConnectionState.x, 10.seconds) ``` @@ -195,7 +196,7 @@ fakeClock.advance(30_000) fakeClock.advance(30.seconds) ``` -After `fakeClock.advance()` inside a coroutine, yield to let any newly dispatched coroutines run: +After `fakeClock.advance()` inside a coroutine, yield to let newly dispatched coroutines run: ```kotlin fakeClock.advance(30.seconds) @@ -295,9 +296,9 @@ class Test { ``` 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) -- `ErrorInfo` constructor arg order is `(message, statusCode, code)` — not `(code, statusCode, message)` +- Missing imports +- Method names differ from what you read in the mock files (use the exact names from Step 3) +- `ErrorInfo` constructor arg order is `(message, statusCode, code)` --- @@ -307,17 +308,49 @@ Fix any compilation errors and recompile until clean. Common issues: ./gradlew :uts:test --tests "." ``` -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. \ No newline at end of file +Handle test failures using this decision tree (see [reference doc](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md) for full detail): + +``` +Test fails + | + +-- Does UTS spec match features spec? + | NO → fix test, record UTS spec error in deviations file + | YES + | +-- Does test accurately translate the UTS spec? + | NO → fix the test (no deviation entry needed) + | YES → SDK deviation — adapt test, record in deviations file +``` + +### Deviation patterns + +**Env-gated skip (preferred)** — test contains spec-correct assertions but is skipped by default: + +```kotlin +/** + * @UTS realtime/unit/RSA4c2/callback-error-connecting-disconnected-0 + */ +@Test +fun `RSA4c2 - callback error connecting disconnected`() = runTest { + // DEVIATION: see deviations.md + if (System.getenv("RUN_DEVIATIONS") != null) return@runTest + + // ... spec-correct setup and assertions ... +} +``` + +**Adapted assertion** — when you still want to assert on the SDK's actual behaviour to prevent regressions: + +```kotlin +// DEVIATION: spec requires error code 40106, SDK returns 40160 — see deviations.md +assertEquals(40160, error.errorInfo.code) +``` + +**Never use the accommodate-both pattern** (accept either spec or SDK behaviour). Every test must assert either spec behaviour or the SDK's actual behaviour — never both at once. + +### Deviations file + +Append to `uts/src/test/kotlin/io/ably/lib/deviations.md`. Each entry needs: +1. The spec point (e.g. `RSA4c2`) +2. What the spec says +3. What the SDK does +4. Which test is affected and how it was adapted From ce4f2f2553deb4395141437a168ae6d1862a6ad9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 21 May 2026 12:44:53 +0100 Subject: [PATCH 11/11] uts: mock object.wait(timeout) calls as well --- .../java/io/ably/lib/http/HttpScheduler.java | 2 +- .../ably/lib/transport/ConnectionManager.java | 4 +- lib/src/main/java/io/ably/lib/util/Clock.java | 40 +++++++++++++++++++ .../java/io/ably/lib/util/SystemClock.java | 5 +++ .../lib/test/mock/DefaultPendingRequest.kt | 4 +- .../kotlin/io/ably/lib/test/mock/FakeClock.kt | 23 +++++++++++ .../io/ably/lib/test/mock/PendingRequest.kt | 3 +- 7 files changed, 75 insertions(+), 6 deletions(-) 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 38157bc4e..19aa71afa 100644 --- a/lib/src/main/java/io/ably/lib/http/HttpScheduler.java +++ b/lib/src/main/java/io/ably/lib/http/HttpScheduler.java @@ -291,7 +291,7 @@ public T get(long timeout, TimeUnit unit) throws InterruptedException, Execution long remaining = unit.toMillis(timeout), deadline = clock.currentTimeMillis() + remaining; synchronized(this) { while(remaining > 0) { - wait(remaining); + clock.waitOn(this, remaining); if(isDone) { break; } remaining = deadline - clock.currentTimeMillis(); } 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 01d9f0e98..c9985ef61 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -995,7 +995,7 @@ public void run() { boolean pending; synchronized(heartbeatWaiters) { try { - heartbeatWaiters.wait(HEARTBEAT_TIMEOUT); + clock.waitOn(heartbeatWaiters, HEARTBEAT_TIMEOUT); } catch (InterruptedException ie) { } pending = clear(); @@ -1506,7 +1506,7 @@ private void tryWait(long timeout) { if(timeout == 0) { wait(); } else { - wait(timeout); + clock.waitOn(this, timeout); } } catch (InterruptedException e) {} } diff --git a/lib/src/main/java/io/ably/lib/util/Clock.java b/lib/src/main/java/io/ably/lib/util/Clock.java index a234213b6..a86e6b463 100644 --- a/lib/src/main/java/io/ably/lib/util/Clock.java +++ b/lib/src/main/java/io/ably/lib/util/Clock.java @@ -1,6 +1,46 @@ package io.ably.lib.util; +/** + * Abstraction over time-related operations used throughout the SDK. + * + *

The default implementation, {@link SystemClock}, delegates to the real system clock and + * standard Java concurrency primitives. Tests and debug builds can supply an alternative + * implementation (e.g. a fake/controllable clock) via {@link io.ably.lib.debug.DebugOptions#clock} + * to drive time-dependent behaviour deterministically without sleeping. + */ public interface Clock { + + /** + * Returns the current wall-clock time in milliseconds since the Unix epoch + * (1 January 1970 00:00:00 UTC), analogous to {@link System#currentTimeMillis()}. + * + * @return current time in milliseconds + */ long currentTimeMillis(); + + /** + * Creates a new {@link NamedTimer} backed by this clock. + * + *

The name is used for diagnostic and logging purposes (e.g. as the underlying + * {@link java.util.Timer} thread name). + * + * @param name a human-readable label for the timer; must not be {@code null} + * @return a new {@link NamedTimer} instance ready to schedule tasks + */ NamedTimer newTimer(String name); + + /** + * Causes the current thread to wait until either another thread calls + * {@link Object#notify()} / {@link Object#notifyAll()} on {@code target}, or the + * specified timeout elapses — analogous to {@link Object#wait(long)}. + * + *

The caller must hold the monitor of {@code target} before invoking this method, + * exactly as required by {@link Object#wait(long)}. + * + * @param target the object whose monitor the current thread holds and will wait on; + * must not be {@code null} + * @param timeout maximum time to wait in milliseconds; {@code 0} means wait indefinitely + * @throws InterruptedException if the current thread is interrupted while waiting + */ + void waitOn(Object target, long timeout) throws InterruptedException; } diff --git a/lib/src/main/java/io/ably/lib/util/SystemClock.java b/lib/src/main/java/io/ably/lib/util/SystemClock.java index 32db8634c..696c9b34d 100644 --- a/lib/src/main/java/io/ably/lib/util/SystemClock.java +++ b/lib/src/main/java/io/ably/lib/util/SystemClock.java @@ -31,6 +31,11 @@ public void cancel() { }; } + @Override + public void waitOn(Object target, long timeout) throws InterruptedException { + target.wait(timeout); + } + public static Clock clockFrom(ClientOptions opts) { if (opts instanceof DebugOptions) { Clock c = ((DebugOptions) opts).clock; diff --git a/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt index 4b8bcee83..441b446c2 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/DefaultPendingRequest.kt @@ -4,9 +4,9 @@ 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 kotlin.time.Duration import kotlinx.coroutines.CompletableDeferred import java.net.SocketTimeoutException -import java.time.Duration internal class DefaultPendingRequest( private val request: HttpRequest, @@ -34,7 +34,7 @@ internal class DefaultPendingRequest( override fun respondWithDelay(delay: Duration, status: Int, body: Any) { Thread { - Thread.sleep(delay.toMillis()) + Thread.sleep(delay.inWholeMilliseconds) respondWith(status, body) }.apply { isDaemon = true }.start() } 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 index 1ac0feaf4..890674dcf 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/FakeClock.kt @@ -15,6 +15,7 @@ import kotlin.time.Duration class FakeClock(initialTimeMs: Long = 0L) : Clock { @Volatile private var time = initialTimeMs private val timers = mutableMapOf() + private val waiters = mutableListOf() override fun currentTimeMillis() = time @@ -24,10 +25,29 @@ class FakeClock(initialTimeMs: Long = 0L) : Clock { return t } + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + override fun waitOn(target: Any, timeout: Long) { + synchronized(waiters) { + waiters.add(Waiter(target as Object, time + timeout)) + } + (target as Object).wait() + } + /** Advance virtual time by [ms] milliseconds, firing any timers that become due. */ fun advance(ms: Long) { time += ms timers.values.forEach { it.fireDue(time) } + val due = synchronized(waiters) { + waiters.filter { it.fireAt <= time }.also { + waiters.removeIf { it.fireAt <= time } + } + } + // notifyAll() requires holding the target's monitor. + due.forEach { waiter -> + synchronized(waiter.target) { + waiter.target.notifyAll() + } + } } /** Advance virtual time by [time], firing any timers that become due. */ @@ -60,4 +80,7 @@ class FakeClock(initialTimeMs: Long = 0L) : Clock { } class Scheduled(val task: TimerTask, val fireAt: Long) + + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + class Waiter(val target: Object, val fireAt: Long) } 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 index eeaadddd4..8046b88d3 100644 --- a/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt +++ b/uts/src/test/kotlin/io/ably/lib/test/mock/PendingRequest.kt @@ -1,6 +1,7 @@ package io.ably.lib.test.mock -import java.time.Duration +import kotlin.time.Duration + /** * An in-flight HTTP request that the test must resolve.