Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ee2e1f1
fix(liveobjects): spec-compliance fixes from the cross-SDK objects au…
sacOO7 Jul 21, 2026
4b357c9
refactor(uts): share test infra via testFixtures, move objects unit s…
sacOO7 Jul 30, 2026
8da2152
liveobjects: tag handleStateChange with RTO4 + RTO27 spec comments
sacOO7 Jul 31, 2026
3fcb956
fix(liveobjects): align with Objects spec updates (RTO23c1, RTO5a6) a…
sacOO7 Aug 9, 2026
ec50ac9
test(liveobjects): port the no-op update UTS unit cases
sacOO7 Aug 11, 2026
d96329f
fix(liveobjects): measure ObjectsMap entry keys as UTF-8 byte length …
sacOO7 Aug 12, 2026
2d3128f
refactor(uts): make :uts a shared test-infra module; move UTS suites …
sacOO7 Aug 24, 2026
c4502c6
fix(uts): deterministic smoke test, contract-correct mocks, run-to-qu…
sacOO7 Aug 27, 2026
85040d2
fix(uts): address review round 2 on the shared mock infra
sacOO7 Aug 27, 2026
873fcb5
test(liveobjects): fix RTO24b1 CI flake — await the seed before subsc…
sacOO7 Aug 27, 2026
e20bc69
Merge pull request #1231 from ably/refactor/uts-shared-infra-module-a…
sacOO7 Aug 27, 2026
9afa940
Merge pull request #1229 from ably/refactor/uts-objects-unit-into-liv…
sacOO7 Aug 27, 2026
f047486
docs(uts): retire the implemented FUTURE_WORK plan; fix proxy-doc poi…
sacOO7 Aug 27, 2026
f9492ab
test(liveobjects): fix RTO23c1 CI flake — pin the sync-waiter ordering
sacOO7 Aug 27, 2026
e1ed6cd
test(uts): address review feedback — harden shared infra, fix test-re…
sacOO7 Aug 28, 2026
9648094
docs(readme): point the Live Objects section at the web docs and Java…
sacOO7 Aug 28, 2026
960d044
chore(gradle): clean up build files per review
sacOO7 Aug 28, 2026
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ realtimeClient.connection.on(ConnectionEvent.connected, connectionStateChange ->

[Ably Live Objects](https://ably.com/docs/liveobjects) provide realtime, collaborative data structures that automatically synchronize state across all connected clients. Build interactive applications with shared data that updates instantly across devices.

For a comprehensive guide, check the [Ably Live Objects documentation](https://ably.com/docs/liveobjects), starting with the [Java quickstart](https://ably.com/docs/liveobjects/quickstart/java).

### Install Live Objects

Add the following dependency to your `build.gradle` file:
Expand Down
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
46 changes: 46 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)
}

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

// Brings in the shared UTS test toolkit (JUnit 5 + kotlin.test + coroutines) transitively via :uts api.
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

good, I would drop "Deliberately NO junit-vintage-engine here: unlike :liveobjects, the
// legacy JUnit4 tests stay on the JUnit4 runner, never the platform." - doesn't add any useful information

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.

Dropped in 960d044 — the whole 5-line block is now a single line noting the toolkit arrives transitively via :uts's api. The vintage-engine sentence and the rest were refactor-time narration.

}

buildConfig {
Expand All @@ -47,9 +52,16 @@ sourceSets {
java {
srcDirs("src/test/java", "../lib/src/test/java")
}
kotlin {
srcDirs("src/test/kotlin", "../lib/src/test/kotlin")
}
}
}

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

tasks.checkstyleMain.configure {
exclude("io/ably/lib/BuildConfig.java")
}
Expand Down Expand Up @@ -103,9 +115,43 @@ 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 }
}

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(""),
)
}
4 changes: 4 additions & 0 deletions java/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ POM_ARTIFACT_ID=ably-java
POM_NAME=Ably Java client library SDK
POM_DESCRIPTION=A Java Realtime and REST client library SDK for the Ably platform.
POM_PACKAGING=jar

# The Kotlin plugin is applied for TEST-ONLY Kotlin sources; keep kotlin-stdlib out of :java's
# main scopes so it never enters the published io.ably:ably-java POM/runtime (Java-only artifact).
kotlin.stdlib.default.dependency=false
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