Skip to content
Open
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
110 changes: 84 additions & 26 deletions .claude/skills/uts-to-kotlin/SKILL.md

Large diffs are not rendered by default.

291 changes: 256 additions & 35 deletions .claude/skills/uts-to-kotlin/references/objects-mapping.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .claude/skills/uts-to-kotlin/scripts/audit_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
UTS_TAG_RE = re.compile(r"@UTS\s+(\S+)")
KOTLIN_ASSERT_RE = re.compile(
r"\b(assertEquals|assertNotEquals|assertNull|assertNotNull|assertTrue|assertFalse|"
r"assertIs|assertIsNot|assertContains|assertFailsWith|assertFails|assertSame|"
r"assertIs|assertIsNot|assertContains|assertContentEquals|assertFailsWith|assertFails|assertSame|"
r"assertNotSame|awaitState|awaitChannelState|pollUntil)\b"
)

Expand Down
35 changes: 26 additions & 9 deletions .claude/skills/uts-to-kotlin/scripts/resolve_uts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
directory (a directory directly under .../specification/uts/), it:

- validates the path and the module's tier structure,
- reads uts-package-mapping.json (next to this script's skill dir),
- resolves, per tier, the target output directory and Kotlin package, and
- reads uts-package-mapping.json (next to this script's skill dir), where each
tier value is ONE repo-root-relative path (never machine-absolute),
- resolves, per tier, the target output directory, Kotlin package (the path
after 'src/test/kotlin/'), and owning Gradle module (from the path's first
segment), and
- lists the candidate spec files with their derived Kotlin class names.

Doing this in code (rather than asking the model to eyeball regexes, join
Expand All @@ -31,6 +34,10 @@
SKILL_DIR = Path(__file__).resolve().parent.parent
MAPPING = SKILL_DIR / "uts-package-mapping.json"
TIERS = ("unit", "integration", "proxy")
# Owning Gradle module, keyed by a target dir's first path segment. The `lib` -> `:java`
# pair is the load-bearing non-obvious mapping (the `:java` module's build file wires
# `../lib/src/...` srcDirs).
MODULE_BY_PREFIX = {"lib": ":java", "liveobjects": ":liveobjects", "uts": ":uts"}


def fail(code, message):
Expand Down Expand Up @@ -88,7 +95,9 @@ def main():
"--create",
metavar="NAME",
help="add a mapping for this source module using NAME as the ably-java "
"module base name, then resolve",
"module base name, then resolve. Scaffolds full lib/-rooted (:java) paths "
"only — a module whose tiers live in another Gradle module (like objects "
"-> :liveobjects) still needs a hand-edit afterwards.",
)
args = ap.parse_args()

Expand All @@ -114,7 +123,6 @@ def main():
fail("MAPPING_NOT_FOUND", f"mapping file not found at {MAPPING}")
data = json.loads(MAPPING.read_text(encoding="utf-8"))
packages = data.setdefault("packages", {})
test_root = data.get("testRoot", "")

if args.create:
target = args.create
Expand All @@ -123,10 +131,14 @@ def main():
f"--create target {target!r} must be a simple module base name "
f"(letters/digits/underscore, e.g. 'liveobjects') so it forms a "
f"valid path and Kotlin package.")
# Full repo-root-relative paths using the realtime/`lib` (:java) template.
# A module whose tiers live in another Gradle module (like objects ->
# :liveobjects) still needs a hand-edit — --create only scaffolds :java-hosted modules.
base = "lib/src/test/kotlin/io/ably/lib/uts"
new_entry = {
"unit": f"unit/{target}",
"integration": f"integration/standard/{target}",
"proxy": f"integration/proxy/{target}",
"unit": f"{base}/unit/{target}",
"integration": f"{base}/integration/standard/{target}",
"proxy": f"{base}/integration/proxy/{target}",
}
# preserve a hand-maintained "notes" pointer when re-creating an existing entry
notes = packages.get(source_module, {}).get("notes")
Expand Down Expand Up @@ -162,20 +174,25 @@ def main():

tiers_out = {}
for tier in TIERS:
target_dir = f"{test_root}/{entry[tier]}" if (mapped and tier in entry) else None
# A tier value is ONE repo-root-relative path (never machine-absolute); the
# owning module comes from its first path segment (MODULE_BY_PREFIX).
target_dir = entry.get(tier) if mapped else None
module = (
MODULE_BY_PREFIX.get(target_dir.split("/", 1)[0]) if target_dir else None
)
tiers_out[tier] = {
"present": src[tier].is_dir(),
"sourceDir": str(src[tier]),
"targetDir": target_dir,
"package": package_for(target_dir) if target_dir else None,
"module": module,
"specs": [{"file": str(p), "className": class_name(p)} for p in specs[tier]],
}

print(json.dumps({
"ok": True,
"sourceModule": source_module,
"mapped": mapped,
"testRoot": test_root,
"translationNotes": translation_notes,
"tiers": tiers_out,
}, indent=2))
Expand Down
21 changes: 10 additions & 11 deletions .claude/skills/uts-to-kotlin/uts-package-mapping.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
{
"_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. Output dir = testRoot + '/' + tier entry; Kotlin package = that path after 'src/test/kotlin/' with '/' -> '.'. An optional 'notes' field points (relative to this skill dir) to a per-module ably-js -> ably-java translation reference, read before translating that module. Used by the uts-to-kotlin skill.",
"testRoot": "uts/src/test/kotlin/io/ably/lib/uts",
"_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test directory per tier. Each tier value is ONE repo-root-relative path (never machine-absolute); the Kotlin package is the path after 'src/test/kotlin/' with '/' -> '.'; the owning Gradle module is the path's first segment (lib/ -> :java, liveobjects/ -> :liveobjects, uts/ -> :uts). Every tier path MUST keep a module segment after the tier (e.g. 'unit/realtime', never bare 'unit') so a derived package can never collide with a :uts smoke package (io.ably.lib.uts.unit / .integration.standard / .integration.proxy) — an invariant currently held only by construction. An optional 'notes' field points (relative to this skill dir) to a per-module ably-js -> ably-java translation reference, read before translating that module. Used by the uts-to-kotlin skill (scripts/resolve_uts.py).",
"packages": {
"realtime": {
"unit": "unit/realtime",
"integration": "integration/standard/realtime",
"proxy": "integration/proxy/realtime"
"unit": "lib/src/test/kotlin/io/ably/lib/uts/unit/realtime",
"integration": "lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime",
"proxy": "lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime"
},
"objects": {
"unit": "unit/liveobjects",
"integration": "integration/standard/liveobjects",
"proxy": "integration/proxy/liveobjects",
"unit": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit",
"integration": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration",
"proxy": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy",
"notes": "references/objects-mapping.md"
},
"rest": {
"unit": "unit/rest",
"integration": "integration/standard/rest",
"proxy": "integration/proxy/rest"
"unit": "lib/src/test/kotlin/io/ably/lib/uts/unit/rest",
"integration": "lib/src/test/kotlin/io/ably/lib/uts/integration/standard/rest",
"proxy": "lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/rest"
}
}
}
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ jobs:
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3
- run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :uts:runUtsUnitTests
- run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectsUnitTests :java:runUtsUnitTests :uts:runUtsUnitTests
2 changes: 1 addition & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,4 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3

- run: ./gradlew :uts:runUtsIntegrationTests
- run: ./gradlew :java:runUtsIntegrationTests :uts:runUtsIntegrationTests
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[versions]
agp = "8.6.1"
junit = "4.13.2"
junit-jupiter = "5.10.1" # matches what kotlin-test-junit5:2.1.10 transitively pins (verified)
gson = "2.9.0"
msgpack = "0.9.11"
java-websocket = "1.5.3"
Expand Down Expand Up @@ -40,6 +41,10 @@ java-websocket = { group = "org.java-websocket", name = "Java-WebSocket", versio
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
vcdiff-core = { group = "com.davidehrmann.vcdiff", name = "vcdiff-core", version.ref = "vcdiff" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit-jupiter" }
junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter" }
junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params" }
junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine" }
hamcrest-all = { group = "org.hamcrest", name = "hamcrest-all", version.ref = "hamcrest" }
nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" }
nanohttpd-nanolets = { group = "org.nanohttpd", name = "nanohttpd-nanolets", version.ref = "nanohttpd" }
Expand Down
73 changes: 73 additions & 0 deletions java/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.build.config)
alias(libs.plugins.maven.publish)
alias(libs.plugins.test.retry)
checkstyle
`java-library`
alias(libs.plugins.kotlin.jvm) // NEW — test-only usage; see stdlib guardrail (step 4)
}

java {
Expand All @@ -28,6 +30,31 @@ dependencies {
runtimeOnly(project(":network-client-default"))
}
testImplementation(libs.bundles.tests)

// The UTS test toolkit — the whole JUnit 5 + kotlin.test-junit5 + coroutines stack arrives
// transitively via :uts's exported (`api`) toolkit, so consumers declare only this one edge.
// The UTS Kotlin suites run via the runUts* tasks only (JUnit4 tasks don't discover Jupiter
// classes and vice versa). Deliberately NO junit-vintage-engine here: unlike :liveobjects, the
// legacy JUnit4 tests stay on the JUnit4 runner, never the platform.
testImplementation(project(":uts"))

@sacOO7 sacOO7 Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have moved uts-infra as shared module for realtime, rest and liveobjects packages.
So, tests now resides in their own packages with access to internal members. So, UTS unit tests don't need to use reflection and can safely access internal methods/properties etc : )

So, you can check this config. I validated locally, so config. works as expected.
You can review this once more @ttypic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, check liveobjects/build.gradle.kts

}

// kotlin-stdlib guardrail (invariant I5): the Kotlin plugin auto-adds kotlin-stdlib to the module's
// main dependency scope, which would leak into :java's published POM/runtime. The leak comes from the
// PLUGIN, NOT from the testImplementation(project(":uts")) dependency — test scopes never enter the
// POM (verified: removing the :uts dep leaves the leak identical). :java is Kotlin-free at
// runtime, so strip it from the main artifact scopes. kotlin-stdlib still reaches the TEST classpath
// transitively (via :uts's kotlin-test-junit5), so the UTS Kotlin suites compile and run.
// Verified empirically on Kotlin 2.1.10: the plugin adds stdlib lazily (it does not appear in any
// declared `(n)` view but resolves top-level onto compile/runtimeClasspath), so the removeIf must
// cover the base scopes the outgoing variants (apiElements/runtimeElements) and classpaths inherit
// from. Test scopes are untouched.
listOf("api", "implementation", "runtimeOnly").forEach { cfg ->
configurations.named(cfg) {
withDependencies {
removeIf { it.group == "org.jetbrains.kotlin" && it.name.startsWith("kotlin-stdlib") }
}
}
}

buildConfig {
Expand All @@ -47,7 +74,15 @@ sourceSets {
java {
srcDirs("src/test/java", "../lib/src/test/java")
}
kotlin {
srcDirs("src/test/kotlin", "../lib/src/test/kotlin") // NEW — UTS Kotlin suites only
}
}
// main gets NO kotlin srcDir — :java main stays pure Java.
}

kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } // match sourceCompatibility 1.8
}

tasks.checkstyleMain.configure {
Expand Down Expand Up @@ -103,9 +138,47 @@ as it only contains the REST and Realtime suites.
tasks.register<Test>("runUnitTests") {
filter {
excludeTestsMatching("io.ably.lib.test.*")
excludeTestsMatching("io.ably.lib.uts.*") // UTS Jupiter suites run via runUts* tasks only
}
jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
outputs.upToDateWhen { false }
}

// UTS realtime suites (Kotlin, JUnit Jupiter). These are the only :java tasks on the JUnit Platform;
// the legacy JUnit4 tasks above never see the Jupiter classes (no vintage engine on the classpath),
// and these never see the JUnit4 classes. --add-opens is set per-task (not withType), so these new
// tasks must declare it explicitly.
tasks.register<Test>("runUtsUnitTests") {
useJUnitPlatform()
filter {
includeTestsMatching("io.ably.lib.uts.unit.*")
}
jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
outputs.upToDateWhen { false }
}

tasks.register<Test>("runUtsIntegrationTests") {
useJUnitPlatform()
filter {
includeTestsMatching("io.ably.lib.uts.integration.*")
}
jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
outputs.upToDateWhen { false }

// Gradle does not forward -D system properties to the forked test JVM, so propagate the
// local uts-proxy override explicitly (invariant I6; AuthReauthTest launches the proxy).
// Accepts either `-Duts.proxy.localPath=...` on the Gradle invocation or the
// `UTS_PROXY_LOCAL_PATH` environment variable. See ProxyManager.
systemProperty(
"uts.proxy.localPath",
providers.systemProperty("uts.proxy.localPath")
.orElse(providers.environmentVariable("UTS_PROXY_LOCAL_PATH"))
.getOrElse(""),
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package io.ably.lib.liveobjects.message;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Payload of a {@link ObjectOperationAction#COUNTER_CREATE} operation, describing the
Expand All @@ -15,7 +15,8 @@ public interface CounterCreate {
*
* <p>Spec: CCR2a
*
* @return the initial counter value
* @return the initial counter value, or {@code null} if absent from the operation
* (such an operation marks the create as merged without changing the value, per RTLC16d)
*/
@NotNull Double getCount();
@Nullable Double getCount();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package io.ably.lib.liveobjects.message;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Payload of a {@link ObjectOperationAction#COUNTER_INC} operation, describing an amount
Expand All @@ -16,7 +16,8 @@ public interface CounterInc {
*
* <p>Spec: CIN2a
*
* @return the increment amount (may be negative)
* @return the increment amount (may be negative), or {@code null} if absent from the
* operation (such an operation is applied as a no-op, per RTLC9h)
*/
@NotNull Double getNumber();
@Nullable Double getNumber();
}
Loading
Loading