Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions .claude/skills/uts-to-kotlin/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
---
description: "Translate a UTS pseudocode test spec into Kotlin tests in the uts module. Usage: /uts-to-kotlin <path-to-spec-file>"
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/<name>.md` | `uts/src/test/kotlin/io/ably/lib/rest/unit/<Name>Test.kt` |
| `.../uts/test/realtime/unit/<sub>/<name>.md` | `uts/src/test/kotlin/io/ably/lib/realtime/unit/<sub>/<Name>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<Auth>(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<AblyException> { expr }; assertEquals(..., error.errorInfo.code)` |
| `ASSERT list.length == N` | `assertEquals(N, list.size)` |

### Test naming

- Method name: backtick string `` `<spec-id> - <description>` ``
- Add `// UTS: <test-id>` comment on the line immediately above `@Test`
- Use `runTest { }` from `kotlinx.coroutines.test` for all async tests

### File template

```kotlin
package io.ably.lib.<category>.unit[.<subcategory>]

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 <Name>Test {

@AfterTest
fun tearDown() {
// close any clients opened in each test (declare them at test scope, not class scope)
}

// UTS: <test-id>
@Test
fun `<spec-id> - <description>`() = 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 "<package>.<ClassName>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.
9 changes: 9 additions & 0 deletions lib/src/main/java/io/ably/lib/debug/DebugOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
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;
import io.ably.lib.types.ProtocolMessage;
import io.ably.lib.util.Clock;

public class DebugOptions extends ClientOptions {
public interface RawProtocolListener {
Expand All @@ -31,12 +34,18 @@ public interface RawHttpListener {
public RawProtocolListener protocolListener;
public RawHttpListener httpListener;
public ITransport.Factory transportFactory;
public HttpEngine httpEngine;
public WebSocketEngineFactory webSocketEngineFactory;
public Clock clock;

public DebugOptions copy() {
DebugOptions copied = new DebugOptions();
copied.protocolListener = protocolListener;
copied.httpListener = httpListener;
copied.transportFactory = transportFactory;
copied.httpEngine = httpEngine;
copied.webSocketEngineFactory = webSocketEngineFactory;
copied.clock = clock;
copied.clientId = clientId;
copied.logLevel = logLevel;
copied.logHandler = logHandler;
Expand Down
10 changes: 7 additions & 3 deletions lib/src/main/java/io/ably/lib/http/HttpCore.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> dynamicAgents) {
Expand Down
8 changes: 6 additions & 2 deletions lib/src/main/java/io/ably/lib/http/HttpScheduler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -446,6 +449,7 @@ public <T> Future<T> ablyHttpExecuteWithRetry(

protected final CloseableExecutor executor;
private final HttpCore httpCore;
private final Clock clock;

protected static final String TAG = HttpScheduler.class.getName();

Expand Down
Loading
Loading