diff --git a/.claude/skills/uts-to-kotlin/SKILL.md b/.claude/skills/uts-to-kotlin/SKILL.md index 052e8f4b2..4c00a1594 100644 --- a/.claude/skills/uts-to-kotlin/SKILL.md +++ b/.claude/skills/uts-to-kotlin/SKILL.md @@ -1,10 +1,11 @@ --- -description: "Translate the UTS pseudocode test specs in a whole module directory into runnable Kotlin tests in the ably-java uts module. Takes a UTS module directory (e.g. /uts/objects), validates its structure, resolves the target ably-java module, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Kotlin test per spec. Usage: /uts-to-kotlin " +description: "Translate the UTS pseudocode test specs in a whole module directory into runnable Kotlin tests in the owning ably-java module (:java for realtime/rest, :liveobjects for objects; :uts hosts the shared infra + smoke examples). Takes a UTS module directory (e.g. /uts/objects), validates its structure, resolves the target ably-java module, lets you pick a tier (unit/integration/proxy) and which specs, then derives a Kotlin test per spec. Usage: /uts-to-kotlin " allowed-tools: Bash, Read, Edit, Write, WebFetch --- Translate the UTS pseudocode test specs under the **module directory** `$ARGUMENTS` into runnable Kotlin -tests in the ably-java `uts` module. +tests in the owning ably-java module (`:java` for realtime/rest, `:liveobjects` for objects; `:uts` hosts +the shared infra + smoke examples). `$ARGUMENTS` is a UTS *module* directory — a directory sitting directly under the spec repo's `uts/`, e.g. `/uts/objects`. Its name (`objects`, `realtime`, @@ -46,9 +47,10 @@ python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py "" It prints one JSON object. **If `ok` is `false`, relay `message` to the user and stop** — error codes: `NOT_A_UTS_MODULE_PATH` (not a `.../uts/` directory), `DIR_NOT_FOUND`, `NO_TIER_DIRS` (no `unit/` -or `integration/`). On success it gives `sourceModule`, `mapped`, `testRoot`, `translationNotes`, and a +or `integration/`). On success it gives `sourceModule`, `mapped`, `translationNotes`, and a `tiers` object with one entry per tier (`unit` / `integration` / `proxy`), each carrying `present`, -`sourceDir`, `targetDir`, `package`, and `specs` (a list of `{file, className}`). Everything downstream +`sourceDir`, `targetDir`, `package`, `module` (the owning Gradle module — `:java` / `:liveobjects` / +`:uts`), and `specs` (a list of `{file, className}`). Everything downstream reads from this output — treat it as the single source of truth and don't recompute paths or names by hand. `translationNotes` is the path to a per-module ably-js → ably-java type/interface map when the module @@ -61,12 +63,15 @@ Phase 2** — see Step 1. The target dirs come from `uts-package-mapping.json` (alongside this skill); spec and ably-java module names don't always match (e.g. `objects` → `liveobjects`), which is why it's explicit. -A tier's mapping value is either a **string** (a path relative to the global `testRoot`, i.e. inside the -`:uts` module) or an **object** `{root, path}` carrying its own module root — used when a tier's tests live -in another Gradle module's test source set (e.g. `objects`/`unit` → -`liveobjects/src/test/kotlin/io/ably/lib/liveobjects` + `uts/unit`, because those specs assert on -`:liveobjects` internals only visible to that module's own tests). The resolver handles both forms; its -`targetDir`/`package` output is what you use either way. +Each tier's mapping value is **one repo-root-relative path** (never a machine-absolute `/Users/...` path). +The resolver derives everything from it: `targetDir` is the path, `package` is the path after +`src/test/kotlin/` with `/` → `.`, and `module` is the owning Gradle module from the path's first segment +(`lib/` → `:java`, `liveobjects/` → `:liveobjects`, `uts/` → `:uts`). Use the resolver's `targetDir` / +`package` / `module` output directly — don't recompute. The `objects` entry is **hand-maintained**: its +tiers live in `:liveobjects`'s own test source set (its specs assert on `:liveobjects` internals only +visible to that module's own tests) under `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{unit, +integration,proxy}` — a sibling layout that differs from the `--create` template below, so `--create` never +regenerates it. - **If `mapped` is `true`**: show the resolved `targetDir` for each present tier and ask the user to confirm. If they say the mapping is wrong, ask for the correct ably-java module base name and re-run with `--create` @@ -79,9 +84,13 @@ in another Gradle module's test source set (e.g. `objects`/`unit` → python3 .claude/skills/uts-to-kotlin/scripts/resolve_uts.py "" --create ``` - This adds `unit/`, `integration/standard/`, and `integration/proxy/` under - `packages` and re-prints the resolved output. (`` must be a simple module base name — letters, - digits, underscore; the script returns `BAD_TARGET_NAME` otherwise, so just ask again.) + This adds full `lib/`-rooted (`:java`) paths under `packages` — + `lib/src/test/kotlin/io/ably/lib/uts/unit/`, `.../integration/standard/`, and + `.../integration/proxy/` — and re-prints the resolved output. **`--create` only scaffolds + `:java`-hosted modules;** a module whose tiers live in another Gradle module (like `objects` → + `:liveobjects`, a sibling `uts/{unit,integration,proxy}` layout) still needs a hand-edit afterwards. + (`` must be a simple module base name — letters, digits, underscore; the script returns + `BAD_TARGET_NAME` otherwise, so just ask again.) ## Step C — Choose the tier @@ -163,12 +172,18 @@ integration tests** section; **proxy** → the proxy subsections of the **Integr > and a file-map of every infra helper with its public surface (Appendix B). Skim it for the *why* and > the *what's available*; the per-file list below is the *what to open for exact signatures* before > writing code. - -Infrastructure lives in `:uts`'s **test-fixtures** variant — -`uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/` — so other Gradle modules can consume it via -`testImplementation(testFixtures(project(":uts")))`; `:uts`'s own tests see it automatically. -Kotlin packages are `io.ably.lib.uts.infra.*` regardless of source set, so imports in generated -tests are unaffected. It is split by tier: +> +> README §9–§11's walkthroughs use the `:uts` infra smoke tests (`UnitInfraSmokeTest` / +> `IntegrationInfraSmokeTest` / `ProxyInfraSmokeTest`) as examples — treat them as the **structural** +> template only (client wiring, guarded awaits, teardown). They are deliberately NOT spec-derived: no +> `@UTS` marker, several teaching points folded into each method. Every derived test still needs one +> `@Test` with a `@UTS ` KDoc per spec Test ID — never copy the smokes' marker-less +> multi-point-per-method shape. + +Infrastructure lives in `:uts`'s **main** source set — +`uts/src/main/kotlin/io/ably/lib/uts/infra/` — so other Gradle modules can consume it via +`testImplementation(project(":uts"))`. Kotlin packages are `io.ably.lib.uts.infra.*`, so imports in +generated tests are unaffected. It is split by tier: - `infra/Utils.kt` — shared async helpers (`awaitState`, `awaitChannelState`, `pollUntil`), package `io.ably.lib.uts.infra`. - `infra/unit/` — unit-test mocks/factories (`ClientFactories.kt`, `MockWebSocket.kt`, `MockHttpClient.kt`, `FakeClock.kt`, `MockEvent.kt`, the `PendingConnection`/`PendingRequest` pairs, and `Utils.kt` with the `ConnectionDetails { }` builder), package `io.ably.lib.uts.infra.unit`. @@ -176,8 +191,9 @@ tests are unaffected. It is split by tier: For a **unit** test, read all files under `infra/unit/` plus `infra/Utils.kt` before generating any code (you need exact method signatures). -**Objects/unit additionally:** the suite lives in `:liveobjects`'s own test source set (Step B), so also -read the module-local helpers at +**Module-local helpers.** Every module's tiers live in that module's own test source set (the resolver's +`targetDir` / `module`), so also read any module-local helpers alongside the target — they sit in the +tier's `targetDir`. For **objects/unit** (module `:liveobjects`) that's `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt` (package `io.ably.lib.liveobjects.uts.unit`) — `setupSyncedChannel`, the `build_*` message builders (typed `Wire*` constructions, no JSON/reflection), `STANDARD_POOL_OBJECTS` and the canonical serial constants. Access @@ -376,10 +392,11 @@ This scaffold is for the **unit** tier — it wires the mocked transport (`infra `ConnectionDetails`). For the **integration** (direct sandbox) and **proxy** tiers, start from the **Proxy integration tests** section instead (`SandboxApp` / `ProxySession` wiring), not from this template. -For **objects/unit** the same scaffold applies with two differences: the package is the resolver's -`io.ably.lib.liveobjects.uts.unit`, and channel/objects setup goes through the module-local helpers -(`setupSyncedChannel` etc. from the same package — see Step 3) rather than raw `MockWebSocket` wiring. -The `io.ably.lib.uts.infra.*` imports are unchanged. +The `package` is always the resolver's `package` for the chosen tier (Step 2), and the +`io.ably.lib.uts.infra.*` imports stay valid whatever module the tier lands in. For **objects/unit** the +same scaffold applies with the resolver's `io.ably.lib.liveobjects.uts.unit` package, and channel/objects +setup goes through the module-local helpers (`setupSyncedChannel` etc. from the same package — see Step 3) +rather than raw `MockWebSocket` wiring. ```kotlin package // the resolver's package for the chosen tier (Step 2) @@ -435,14 +452,15 @@ class { ## Step 5 — Compile -Compile the module that owns the chosen tier's `targetDir` (`:uts` unless the mapping's object form says -otherwise): +Compile the module the resolver reports for the chosen tier (its `module` field — Step A): ```bash -./gradlew :uts:compileTestKotlin # tiers inside the :uts module -./gradlew :liveobjects:compileTestKotlin # objects/unit (targetDir under liveobjects/) +./gradlew :java:compileTestKotlin # realtime / rest tiers (module :java) +./gradlew :liveobjects:compileTestKotlin # objects tiers (module :liveobjects) ``` +(`:uts` is no longer a spec-test compile target — it holds only the shared infra + smoke tests.) + Fix any compilation errors and recompile until clean. Common issues: - Missing imports - Method names differ from what you read in the mock files (use the exact names from Step 3) @@ -460,21 +478,24 @@ every failure via the decision tree below. Each test must end in exactly one of - a documented **UTS spec error** — **fails fast** (the spec is wrong; fix belongs in the spec). This is the one acceptable red. -Use the per-tier task of the module that owns the tier's `targetDir`, and the resolver's `package` + the -spec's `className` for the `--tests` filter: +Use the per-tier task of the module the resolver reports for the tier (its `module` field), and the +resolver's `package` + the spec's `className` for the `--tests` filter: ```bash -# unit tier, tiers inside :uts → io.ably.lib.uts.unit.* -./gradlew :uts:runUtsUnitTests --tests "." +# realtime / rest unit (module :java) → io.ably.lib.uts.unit.* +./gradlew :java:runUtsUnitTests --tests "." + +# realtime / rest integration + proxy (module :java) → io.ably.lib.uts.integration.* +./gradlew :java:runUtsIntegrationTests --tests "." -# objects/unit (in :liveobjects) → io.ably.lib.liveobjects.uts.unit.* +# objects unit (module :liveobjects) → io.ably.lib.liveobjects.uts.unit.* ./gradlew :liveobjects:runLiveObjectsUnitTests --tests "." -# integration / proxy → io.ably.lib.uts.integration.* -./gradlew :uts:runUtsIntegrationTests --tests "." +# objects integration + proxy (module :liveobjects) → io.ably.lib.liveobjects.uts.{integration,proxy}.* +./gradlew :liveobjects:runLiveObjectsIntegrationTests --tests "." ``` -(`./gradlew :uts:test` still runs all `:uts` tiers — unit, standard, and proxy.) +(`./gradlew :uts:test` now runs only the infra smoke tests — see `uts/README.md`.) Handle test failures using this decision tree (the **Required reading** doc you fetched up front has the full detail): @@ -536,7 +557,7 @@ fun `RTLC7c2 - LOCAL source does not write siteTimeserials`() = runTest { ### Deviations file Append to the deviations file that belongs to the tier's module: -`uts/src/test/kotlin/io/ably/lib/uts/deviations.md` for tiers inside `:uts`, or +`lib/src/test/kotlin/io/ably/lib/uts/deviations.md` for realtime/rest tiers (module `:java`), or `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md` for objects/unit. Use the manual's **Recording deviations** entry format and sections. The ably-java-specific mapping: a **UTS Spec Error** (test fails fast — fix in the spec) goes under the manual's *UTS Spec Errors* section; an **SDK deviation** (env-gated/adapted — fix @@ -624,7 +645,7 @@ For each test case, verify: Deviations are discovered by running, so this check applies in evaluate mode. For any place where the generated test diverges from the spec pseudocode (adapted assertion, env-gated skip, or omitted step): - [ ] A `// DEVIATION:` comment explains why -- [ ] The deviation is recorded in `uts/src/test/kotlin/io/ably/lib/uts/deviations.md` +- [ ] The deviation is recorded in `lib/src/test/kotlin/io/ably/lib/uts/deviations.md` If you find gaps during this review, fix them, then **re-run the audit script** until `missingInKotlin` / `orphanInKotlin` are empty and every `perTest` entry reconciles, and re-run Step 5 (compile) — and, in @@ -703,7 +724,7 @@ Use generous timeouts (10–30s) — real network is involved. Everything else i ### Infrastructure -Three helpers live under `uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/`. **Read the ones your tier uses before translating an integration spec** — they hold the exact method signatures. `SandboxApp` serves **both** tiers; `ProxyManager` and `ProxySession` are **proxy-only**. +Three helpers live under `uts/src/main/kotlin/io/ably/lib/uts/infra/integration/`. **Read the ones your tier uses before translating an integration spec** — they hold the exact method signatures. `SandboxApp` serves **both** tiers; `ProxyManager` and `ProxySession` are **proxy-only**. - **`ProxyManager`** (`infra/integration/proxy/ProxyManager.kt`, package `io.ably.lib.uts.infra.integration.proxy`) — downloads/starts the shared `uts-proxy` process. Call `ProxyManager.ensureProxy()` once per suite in setup. - **`ProxySession`** (`infra/integration/proxy/ProxySession.kt`, same package) — one programmable session wrapping the proxy control API; also defines the `connectThroughProxy` extension and the rule-builder helpers. diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md index 18770c788..10161b1c0 100644 --- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md @@ -507,7 +507,7 @@ Several **unit** specs assert on the **internal CRDT graph**, not the public API - the internal-state assertions in `internal_live_counter.md` / `internal_live_map.md` (`.data`, `.siteTimeserials`, `.createOperationIsMerged`, `.isTombstone`, `applyOperation`, `replaceData`) — internal; their public-facing read/write counterparts live in `internal_live_counter_api.md` / - `internal_live_map_api.md` (which *are* translated in `:uts`). + `internal_live_map_api.md` (which *are* translated in `:liveobjects`). - `value_types.md` — the *public* `LiveMap.create` / `LiveCounter.create` surface maps via §6, but the evaluation half (`COUNTER_CREATE` / `MAP_CREATE` `ObjectMessage` generation, nonce/`initialValue`/ `objectId` derivation, the `*WithObjectId` wire forms) is internal/wire-level. @@ -520,11 +520,11 @@ Several **unit** specs assert on the **internal CRDT graph**, not the public API In ably-java these are **not public**. They live in the `:liveobjects` module as `Internal*` / `Default*` / `Wire*` / `ResolvedValue` / `Leaf` / `MapRef` / `CounterRef` classes (package `io.ably.lib.liveobjects.*`, -implementation source set), visible only to that module's own tests. Because of this, **the entire -objects unit tier translates into `:liveobjects`'s own test source set** — -`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/` (the resolver's Step B object-form -mapping) — while `:uts` hosts the objects **integration** and **proxy** tiers (public API + -`testRuntimeOnly(project(":liveobjects"))` for the plugin at runtime). Consequences when translating: +implementation source set), visible only to that module's own tests. Because of this, **all objects tiers +translate into `:liveobjects`'s own test source set** — +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{unit,integration,proxy}/` — so the unit tier +sees module internals directly, and the integration/proxy tiers reach the plugin through the same module. +Consequences when translating: - **Public-tier unit specs** (`path_object*.md`, `instance.md`, `live_object_subscribe.md`, `public_object_message.md`, `internal_live_counter_api.md`, `internal_live_map_api.md`, and the @@ -548,7 +548,7 @@ mapping) — while `:uts` hosts the objects **integration** and **proxy** tiers Every objects unit spec opens with `setup_synced_channel` and constructs protocol/object messages with the `build_*` helpers. These are implemented in `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/Helpers.kt` (package -`io.ably.lib.liveobjects.uts.unit`; transport bootstrap via the shared `io.ably.lib.uts.infra.*` fixtures, +`io.ably.lib.liveobjects.uts.unit`; transport bootstrap via the shared `io.ably.lib.uts.infra.*` infra helpers, message construction via the typed `Wire*` DTOs — §17.10) — **call them; don't hand-roll the mock setup or message wire forms.** @@ -596,10 +596,11 @@ wire `action` / `semantics` are integer enum codes — the builders emit the cod Some objects **integration** specs (tier `integration/standard`) seed object state over REST *before* the realtime client connects, via the spec's `## REST Fixture Provisioning` helper `provision_objects_via_rest`. Its ably-java translation lives in -`uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt` (package -`io.ably.lib.uts.integration.standard.liveobjects`) — **call it; don't hand-roll the REST request or payload -JSON.** (Currently only `objects/integration/RTPO15` uses it.) Unlike the unit helpers (§13), this needs no -reflection and no `:liveobjects` dependency — it compiles and runs against `:java`'s public `AblyRest`. +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt` (package +`io.ably.lib.liveobjects.uts.integration`) — **call it; don't hand-roll the REST request or payload +JSON.** (Currently only `objects/integration/RTPO15` uses it.) It lives inside `:liveobjects`'s own tests +now (like the whole objects suite), but still uses only the public `AblyRest` — the "public API only for +REST provisioning" convention survives even though the module's internals are visible. | Spec helper / operation shape | integration `Helpers.kt` | |---|---| @@ -707,7 +708,7 @@ This section maps the **5 internal-graph unit specs** — `internal_live_counter `internal_live_map.md`, `object_id.md`, `objects_pool.md`, `parent_references.md` (they assert on internal CRDT state, so they cannot translate against the public API — background in `JAVA_LIVEOBJECTS_INTERNAL_METHODS_ACCESS_REPORT.md` at the repo root) — onto the internal engine. -Unlike §1–§12 these do **not** translate into the `:uts` module: they go into +Like the rest of the objects suite, they go into **`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/`** (package `io.ably.lib.liveobjects.uts.unit`, task `:liveobjects:runLiveObjectsUnitTests`), where the test compilation is associated with `main`, so every `internal` declaration is directly visible — no diff --git a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py index 813b43c8b..773f839bf 100644 --- a/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py +++ b/.claude/skills/uts-to-kotlin/scripts/resolve_uts.py @@ -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 @@ -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): @@ -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() @@ -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 @@ -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") @@ -162,21 +174,18 @@ def main(): tiers_out = {} for tier in TIERS: - # A tier value is either a string (relative to the global testRoot) or an object - # {root, path} carrying its own module root — used when a tier's tests live outside - # the :uts module (e.g. objects/unit -> :liveobjects's own test source set). - tier_val = entry.get(tier) if mapped else None - if isinstance(tier_val, dict): - target_dir = f"{tier_val['root']}/{tier_val['path']}" - elif tier_val: - target_dir = f"{test_root}/{tier_val}" - else: - target_dir = 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]], } @@ -184,7 +193,6 @@ def main(): "ok": True, "sourceModule": source_module, "mapped": mapped, - "testRoot": test_root, "translationNotes": translation_notes, "tiers": tiers_out, }, indent=2)) diff --git a/.claude/skills/uts-to-kotlin/uts-package-mapping.json b/.claude/skills/uts-to-kotlin/uts-package-mapping.json index 53ef7da00..f334189e1 100644 --- a/.claude/skills/uts-to-kotlin/uts-package-mapping.json +++ b/.claude/skills/uts-to-kotlin/uts-package-mapping.json @@ -1,25 +1,21 @@ { - "_comment": "Maps each UTS spec module (a dir under specification/uts/) to its target test packages. A tier value is either a string (output dir = testRoot + '/' + value) or an object {root, path} with its own module root (output dir = root + '/' + path) for tiers that live outside the :uts module. Kotlin package = the output dir 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 (scripts/resolve_uts.py).", - "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": { - "root": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects", - "path": "uts/unit" - }, - "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" } } } diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a2fe3a2bb..36fb02df5 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index df5e399a8..bbe3a5423 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -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 diff --git a/FUTURE_WORK_UTS_INFRA.md b/FUTURE_WORK_UTS_INFRA.md index d93cc6145..64998dbdd 100644 --- a/FUTURE_WORK_UTS_INFRA.md +++ b/FUTURE_WORK_UTS_INFRA.md @@ -1,261 +1,78 @@ -# Future work — extract shared UTS test infra into a dedicated module - -**Status:** proposed / not started. Deferred from `MOVE_COMMON_INFRA/` (which put the infra in -`:uts`'s `testFixtures` variant — the correct incremental step). This doc captures the extraction to -do **when a second consumer beyond `:liveobjects` materialises**. - -## 1. Why (the trigger) - -The shared UTS test infrastructure (mock WebSocket/HTTP transports, `FakeClock`, client factories, -`SandboxApp`, proxy control) currently lives in **`uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/`** -and is consumed by: - -- `:uts`'s own tests (automatically — a module sees its own test fixtures), and -- `:liveobjects` tests via `testImplementation(testFixtures(project(":uts")))`. - -Two more consumers are anticipated: - -1. **The Chat SDK** — will reuse the mock transport / sandbox provisioning for its own UTS-derived tests. -2. **`lib/src/test`** — i.e. the **`:java` module's test source set** (its build file is `java/build.gradle.kts`; - sources are wired in via `srcDirs(".../lib/src/test/java")`). - -Once infra is shared by three-plus modules, hanging it off `:uts`'s test-fixtures variant becomes a -naming/ownership smell: modules that have nothing to do with UTS would depend on `testFixtures(project(":uts"))`, -and the package is `io.ably.lib.uts.infra`. At that point a **dedicated module** is the clean home — it -gets a name that reflects "shared test support," and IntelliJ shows it as an ordinary module with a normal -`src/main/kotlin` (no special test-fixtures source-root category). - -Until then, `testFixtures` is intentionally preferred: zero custom Gradle, test-only, already cross-module -consumable. **Do not do this extraction speculatively** — it only pays for itself once the second consumer -is real. - -## 2. Target state - -A new module, e.g. `:test-support` (neutral name; not `:uts-test-infra`, since chat/`:java` aren't UTS): - -``` -test-support/ - build.gradle.kts - src/main/kotlin/io/ably/lib/testsupport/… # the infra, moved from uts/src/testFixtures -``` - -Consumed uniformly: - -```kotlin -// uts/build.gradle.kts, liveobjects/build.gradle.kts, java/build.gradle.kts, chat/… -testImplementation(project(":test-support")) -``` - -`:uts` drops the `java-test-fixtures` plugin and its `testFixtures*` dependency block; the -`testFixtures(project(":uts"))` line in `:liveobjects` becomes `project(":test-support")`. - -## 3. The hard constraint — Kotlin infra vs. Java consumers ⚠️ - -**This is the single biggest consideration and the reason the extraction is non-trivial.** - -The infra is **idiomatic Kotlin**: lambda-with-receiver builder DSLs (`MockWebSocket { onConnectionAttempt = … }`), -`suspend` functions (`setupSyncedChannel`, `SandboxApp.create`, `awaitState`), extension functions, -`data class`es, and default arguments. - -`lib/src/test` is **pure Java** (verified: 75 `.java` files, 0 `.kt`). Java can put compiled Kotlin on its -classpath, but **cannot ergonomically call this API**: - -- `suspend` functions are effectively uncallable from Java (they compile to a hidden `Continuation` - parameter) — `setupSyncedChannel`, `SandboxApp.create()`, the `await*` helpers. -- Lambda-with-receiver config DSLs don't exist in Java. -- Extension functions become awkward static calls; default args require `@JvmOverloads` to be visible. - -**Decision (confirmed): add a Kotlin test source set to `:java`** and write the Java module's new -UTS-derived tests in Kotlin. The infra is Kotlin-first by design and UTS tests are Kotlin, so a Java -facade (`@JvmStatic`/`@JvmOverloads`/blocking `suspend` wrappers/builder classes) would fight the grain; -it's the rejected alternative. Existing Java tests in `lib/src/test/java` are untouched. - -(The Chat SDK is Kotlin, so it has no such friction — only `lib/src/test` does.) - -### 3.1 Concrete `:java` wiring - -`:java` is today a **pure-Java `java-library`** (build file `java/build.gradle.kts`; `sourceCompatibility`/ -`targetCompatibility = 1.8`; sources wired from `../lib/src/…/java` via `sourceSets { … srcDirs(…) }`). It -does **not** apply the Kotlin plugin. Steps: - -1. Apply the Kotlin JVM plugin: `alias(libs.plugins.kotlin.jvm)` in `java/build.gradle.kts` `plugins {}`. -2. Add a Kotlin test source dir mirroring the existing Java wiring, and create the folder: - ```kotlin - sourceSets { - named("test") { - java { srcDirs("src/test/java", "../lib/src/test/java") } // existing - // new — Kotlin UTS tests + consumed infra - kotlin { srcDirs("src/test/kotlin", "../lib/src/test/kotlin") } - } - } - ``` - (`mkdir -p lib/src/test/kotlin`.) -3. `testImplementation(project(":test-support"))` + the `--add-opens` JVM args (§5 step 4) + - `kotlin("test")` as needed. - -### 3.2 ⚠️ Guardrail — do NOT ship kotlin-stdlib in the `:java` main artifact - -`:java` is the **core, widely-consumed SDK artifact and is currently Kotlin-free at runtime**. Applying -`org.jetbrains.kotlin.jvm` adds `kotlin-stdlib` to the `implementation` (main) configuration by default, -which would leak into the published artifact's runtime dependencies — every ably-java consumer would then -pull kotlin-stdlib. **This must be prevented.** Approach: - -- Disable the automatic stdlib dependency for this module and add stdlib to **test scope only**, e.g. set - `kotlin.stdlib.default.dependency=false` and declare `testImplementation(kotlin("stdlib"))` (verify the - flag's scope — if `gradle.properties` is project-wide, other Kotlin modules like `:liveobjects` that - *do* ship Kotlin still need stdlib in main, so prefer a per-module control or an explicit main-scope - stdlib there). -- **Acceptance check:** after wiring, inspect the published POM / `./gradlew :java:dependencies - --configuration runtimeClasspath` and confirm **no `kotlin-stdlib`** on `:java`'s main runtime classpath. -- Align the Kotlin `jvmTarget` with `:java`'s Java 8 target (`compileTestKotlin { compilerOptions.jvmTarget - = JVM_1_8 }`) so test bytecode matches. - -This guardrail is the main reason the `:java` change is more than a one-liner — treat the "stdlib stays out -of main" verification as a required gate of the step. - -## 4. Dependency & cycle analysis - -The infra's actual dependencies (from the current `testFixtures` block — keep these exact scopes): - -| Dep | Scope | Why | +# UTS shared test infra — decision record (supersedes the deferred extraction plan) + +**Status: DECIDED and IMPLEMENTED (Phases 1–6, 2026-08-24 — staged for local review) — plan and +per-phase records in [`UTS_REFACTORING_PLAN/`](UTS_REFACTORING_PLAN/README.md). Phase 7 (publishing) +remains gated/unscheduled.** + +This document originally proposed extracting the shared UTS infra out of `:uts`'s `testFixtures` +variant into a dedicated `:test-support` module, *deferred until a second consumer materialised*. +That trigger has been **deliberately pulled** (lead-dev decision, 2026-08-24), with one change of +target: instead of a new module, the infra is **promoted in place** to `:uts`'s main source set. +The full reasoning, phase breakdown, verification gates and CI re-pointing live in +`UTS_REFACTORING_PLAN/` — this file records what was decided, what changed versus the original +proposal, and where each original concern is addressed. + +## 1. The decision (target state) + +1. **Infra becomes a real source set:** `uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/` → + `uts/src/main/kotlin/io/ably/lib/uts/infra/`. Packages unchanged (`io.ably.lib.uts.infra.*` — + zero import churn). `:uts` drops `java-test-fixtures`; consumers use plain + `testImplementation(project(":uts"))`. This makes `:uts` importable from any module without + ceremony, and publishable later without restructuring (`UTS_REFACTORING_PLAN/PHASE_1`, `PHASE_7`). +2. **UTS suites move to their owning modules** (per the updated + `.claude/skills/uts-to-kotlin/uts-package-mapping.json` / `references/objects-mapping.md`): + - realtime (and future rest) unit/integration/proxy → `:java`, at + `lib/src/test/kotlin/io/ably/lib/uts/...` (packages unchanged) — `PHASE_4`; + - objects integration/proxy → `:liveobjects`, at + `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{integration,proxy}` (joining the + existing `uts/unit`) — `PHASE_3`. +3. **`:uts` keeps permanent, deep tier smoke tests** (one per tier — unit / integration / proxy), + modeled on ably-cocoa PR #2223's `IntegrationSmokeTest` / `ProxyInfraSmokeTests`. They are the + infra acceptance gate and the worked examples `uts/README.md` teaches from — `PHASE_2`. +4. **Mapping schema simplifies** to one full repo-root-relative path per tier (this doc's old §8, + adopted), with the resolver additionally emitting the owning Gradle module derived from the path's + first segment (`lib/` → `:java`) — `PHASE_5`. + +## 2. What changed versus the original proposal, and why + +| Original | Decided | Why | |---|---|---| -| `:java` | `api` | `AblyRealtime`/`AblyRest`/`ProtocolMessage`/`Clock`/`ConnectionDetails` appear in fixture signatures | -| `:network-client-core` | `api` | `HttpEngine`/`WebSocketEngine` SPI the mocks implement, in signatures | -| `libs.coroutine.core` | `implementation` | `suspend` helpers | -| `libs.ktor.client.core` + `libs.ktor.client.cio` | `implementation` | `SandboxApp` / `ProxySession` HTTP | - -**Cycle safety:** - -- `:test-support` main → `:java` main, `:network-client-core` main. Fine. -- **INVARIANT (carried over from `MOVE_COMMON_INFRA` Phase 1): `:test-support` must never depend on - `:liveobjects`.** That keeps `:liveobjects:test → :test-support → :java` acyclic against - `:uts`'s existing `testRuntimeOnly(:liveobjects)`. -- `:java:test → :test-support → :java:main` is **not** a cycle — a module's *test* compilation may depend - on a module that depends on its *main*. (Gradle treats `main` and `test` as separate nodes.) Confirm on - first `:java:compileTestKotlin`/`compileTestJava`. - -## 5. Migration steps (when triggered) - -1. **Create `:test-support`**: new dir, `settings.gradle.kts` `include("test-support")`, `build.gradle.kts` - applying `kotlin("jvm")` with the §4 deps as `api`/`implementation` (NOT `testFixtures*` scopes — this is - a normal module now). -2. **Move the tree**: `git mv uts/src/testFixtures/kotlin/io/ably/lib/uts/infra` → - `test-support/src/main/kotlin/io/ably/lib/testsupport` (rename package `io.ably.lib.uts.infra` → - `io.ably.lib.testsupport` — do this now, while there's one Kotlin consumer, not later). Update imports in - `:uts` tests and `:liveobjects`'s `uts/unit/Helpers.kt`. - - Alternative: keep the `io.ably.lib.uts.infra` package to avoid import churn. Weigh churn vs. a - misleading `uts` name in a shared module. Package rename is a mechanical find/replace across two - consumers today; it gets more expensive with every new consumer, so **prefer renaming now**. -3. **Rewire consumers**: - - `:uts` — remove `java-test-fixtures` plugin + `testFixtures*` block; add - `testImplementation(project(":test-support"))`. Restore the plain `plugins { alias(libs.plugins.kotlin.jvm) }`. - - `:liveobjects` — `testImplementation(testFixtures(project(":uts")))` → `testImplementation(project(":test-support"))`. -4. **JVM args parity**: consumers running the mock transport need the same - `--add-opens java.base/java.time=ALL-UNNAMED` / `java.base/java.lang=ALL-UNNAMED` flags that - `uts/build.gradle.kts` and `liveobjects/build.gradle.kts` already set. Add to any new consumer's - `tasks.withType` (needed by the coroutines/FakeClock machinery, not by the `ConnectionDetails` - reflection — that targets a plain classpath class and needs no `--add-opens`). -5. **Proxy system property**: the `uts.proxy.localPath` / `UTS_PROXY_LOCAL_PATH` forwarding in - `uts/build.gradle.kts`'s `tasks.withType` must be replicated by any module that runs proxy-tier - fixtures. Consider extracting it into a shared Gradle convention/snippet at that point. (`:liveobjects` - does not run proxy fixtures today, so it doesn't need this yet.) -6. **`:java` consumption** (when `lib/src/test` is a driver): follow §3.1 (apply `kotlin("jvm")`, add the - `../lib/src/test/kotlin` source dir) **and §3.2 (keep kotlin-stdlib out of the `:java` main artifact — - required gate)**. Existing Java tests are unaffected. -7. **Docs**: update the three places that describe infra location — `uts/README.md` §4/§4.2/Appendix B, - `.claude/skills/uts-to-kotlin/SKILL.md` (Step 3 infra paths, Step 3 objects/unit note, integration - Infrastructure section), and `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §13. All - currently say `uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/…`. - -## 6. Cross-repo caveat (Chat) - -If the Chat SDK lives in a **separate repository** (not a module in this monorepo), "sharing" is not a -`project(":test-support")` dependency — it requires **publishing** `:test-support` as a versioned artifact -(Maven coordinates, `maven-publish`, a release cadence). That is a materially bigger commitment than an -in-repo module and turns test infra into a maintained public-ish artifact. Confirm whether chat is: - -- **In-repo** (a future module here) → plain `project(":test-support")`, this doc's plan as written; or -- **Separate repo** → decide publish-and-version vs. source-copy vs. a shared git submodule. Do **not** - assume publishing without an explicit decision — it changes ownership, versioning, and CI. - -## 7. What NOT to do (rejected alternatives, for the record) - -- **Move infra to `src/main` of `:uts`** — makes it part of a publishable main artifact, drags - mockk/ktor into non-test scopes, and `:uts` stops being self-evidently test-only. -- **Move infra back to `src/test`** — invisible to other modules; breaks the `:liveobjects` consumption - that `MOVE_COMMON_INFRA` established. -- **Rename `src/testFixtures` → `src/testInfra` while keeping `java-test-fixtures`** — the plugin's source - set stays named `testFixtures` and the consumer accessor stays `testFixtures(project(":uts"))`, so you - get a directory/source-set name mismatch that is *more* confusing than the convention. A custom-named - consumable test source set means hand-rolling the Gradle variant + capability (fragile). The dedicated - module in §2 is the only clean way to get a chosen name. - -## 8. Related future work — simplify `uts-package-mapping.json` to one full path per tier - -**Motivation.** The skill's `.claude/skills/uts-to-kotlin/uts-package-mapping.json` today uses a **hybrid** -schema: a global relative `testRoot` (`uts/src/test/kotlin/io/ably/lib/uts`) with each tier a string -*relative to it*, **except** tiers that live in another module, which carry an explicit `{root, path}` -override (this is what `objects.unit` → `:liveobjects` needed). The resolver -(`scripts/resolve_uts.py`) branches on string-vs-object to handle both. - -That special-casing is fine for one out-of-`:uts` tier, but **it multiplies as tiers land in more -modules** — which is exactly what this doc's extraction (`:test-support`) and the `:java`/chat consumers -bring. Every future "this tier lives in module X" becomes another `{root, path}` object and keeps the -resolver's two-form branch alive. - -**Proposed change.** Drop the global `testRoot` and the `{root, path}` object form; make **every tier a -single full, repo-root-relative path string**. Uniform, no special-casing, self-describing: - -```jsonc -// before (hybrid) -{ - "testRoot": "uts/src/test/kotlin/io/ably/lib/uts", - "packages": { - "realtime": { "unit": "unit/realtime", "integration": "integration/standard/realtime", "proxy": "integration/proxy/realtime" }, - "objects": { "unit": { "root": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects", "path": "uts/unit" }, - "integration": "integration/standard/liveobjects", "proxy": "integration/proxy/liveobjects" } - } -} - -// after (one full path per tier) -{ - "packages": { - "realtime": { - "unit": "uts/src/test/kotlin/io/ably/lib/uts/unit/realtime", - "integration": "uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime", - "proxy": "uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime" - }, - "objects": { - "unit": "liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit", - "integration": "uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects", - "proxy": "uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects" - } - } -} -``` - -**Resolver change** (`scripts/resolve_uts.py`): delete the `testRoot` read and the `isinstance(dict)` -branch; `target_dir = entry[tier]` directly. `package_for()` is unchanged — it already derives the Kotlin -package by splitting each path on `src/test/kotlin/`, which every full path still contains. `--create` emits -full-path entries (its `unit`/`integration`/`proxy` template becomes the three full paths for the chosen -module). Update the `_comment` accordingly. - -> **⚠️ "Absolute" must mean repo-root-relative, NOT machine-absolute.** Do **not** put -> `/Users//IdeaProjects/ably-java/...` in this committed file — a machine-specific path breaks CI, -> every other developer, and the skill (which resolves paths from the ably-java repo root). "Full path" -> here = the complete path **from the repo root**, exactly as the values above. - -**Trade-off.** More verbose (each tier repeats the `uts/src/test/kotlin/io/ably/lib/uts/...` prefix; the -shared-`testRoot` DRY is lost) in exchange for a uniform, branch-free schema that scales cleanly to -tiers in arbitrary modules. Given the direction (infra + tiers spreading across `:uts` / `:liveobjects` / -`:java` / chat), the uniformity wins. Do this **together with** the §7 doc-path updates so the mapping, -`SKILL.md` Step B/2/5/6, and `objects-mapping.md` §13 all describe the single-form schema at once. - -## 9. References - -- `MOVE_COMMON_INFRA/` — the completed 5-phase consolidation this defers from (esp. Phase 1 fixtures - extraction and its cycle invariant). -- `uts/README.md` §4.2 "Cross-module exception" — current infra location + the objects-unit-in-`:liveobjects` split. -- `.claude/skills/uts-to-kotlin/SKILL.md` Step 3 + `references/objects-mapping.md` §13/§17 — how the skill - points at the infra and the objects/unit destination. -- Current infra deps: `uts/build.gradle.kts` (`testFixturesApi`/`testFixturesImplementation` block). +| Extract to a new `:test-support` module, package-renamed to `io.ably.lib.testsupport` | Promote to `:uts`'s own `src/main`, packages unchanged | After the suites move out, `:uts`'s identity *is* the shared UTS test-support library — promoting in place needs no settings.gradle change and zero import churn (~30 consumer files), which was the point of the ask. The name `:uts` stays accurate (it ships UTS infra + UTS smoke examples). Extraction/rename remains a cheap later step if ever wanted. | +| "Do not do this extraction speculatively — wait for the second consumer" | Done now | The second consumer is no longer speculative: `:java` becomes a consumer the moment the realtime suites move into it (same refactor), and chat is anticipated. Bundling the infra promotion with the suite redistribution avoids doing the consumer wiring twice. | +| §7 rejected "move infra to `src/main` of `:uts`" | That rejection is withdrawn **for the new context only** | It was rejected because `:uts` was a test-host module whose main artifact would be accidental. Audit facts that made it clean now: no infra file imports any test library (kotlin.test/JUnit/mockk — verified across all 16 files); mockk was only ever a `:uts` test-scope dep (and is unused). The other §7 rejections (infra back to `src/test`; renamed testFixtures source set) stand. | + +## 3. Original concerns → where addressed + +Every concern from the previous version of this document is carried into the plan; none were dropped: + +| Concern (old §) | Where addressed | +|---|---| +| Kotlin infra vs. pure-Java `lib/src/test` (§3) — Kotlin test sources for `:java`, no Java facade | `PHASE_4` steps 1–3. The no-facade decision stands; existing Java tests untouched. | +| Concrete `:java` wiring (§3.1) | `PHASE_4` step 1 (kotlin srcDirs on the existing `test` source set; `jvmTarget` 1.8), step 3 (dedicated Jupiter tasks — a refinement the original didn't have: `:java`'s 64 JUnit4 files and its `test-retry`/suite tasks stay on the JUnit4 runner untouched, no vintage engine). | +| ⚠️ kotlin-stdlib must not ship in `:java`'s main artifact (§3.2) | `PHASE_4` step 4 — **required merge gate** (runtimeClasspath + POM + jar-content checks), surgical per-module removal recommended, global `kotlin.stdlib.default.dependency=false` documented as fallback. Invariant I5 in the plan. | +| Dependency scopes & cycle analysis (§4) | `PHASE_1` step 2 preserves the exact scopes (`api(:java)`, `api(:network-client-core)`, `implementation(coroutines/ktor)`). Invariant I1: `:uts` **main** never depends on `:liveobjects`. The old test-runtime cycle concern dissolves in `PHASE_3` when `:uts` drops `testRuntimeOnly(:liveobjects)` entirely. | +| Migration steps §5.1–5.3 (module creation / tree move / consumer rewire) | `PHASE_1` steps 1–3 — no new module or `settings.gradle` entry needed; the move is `git mv` + two build files. | +| `:java` consumption (§5.6) | `PHASE_4` (the whole phase — wiring, deps, tasks, stdlib gate). | +| JVM-args parity (§5.4) and proxy sysprop replication (§5.5) | Plan invariant I6; applied in `PHASE_3` step 3 (`:liveobjects`) and `PHASE_4` step 3 (`:java`). The shared-convention idea is recorded (deferred) in `PHASE_4` §Notes. | +| Doc updates (§5.7) | `PHASE_5` (skill: SKILL.md, objects-mapping.md §13/§14, mapping json, resolver — audited line-by-line edit lists) + `PHASE_6` (uts/README.md rewrite, CLAUDE.md, module docs, repo-wide grep gates). | +| Cross-repo chat caveat (§6) — publishing is a commitment, not a default | `PHASE_7`, verbatim decision tree, explicitly gated and unscheduled. | +| Mapping simplification to one full path per tier (§8), incl. the "repo-root-relative, never machine-absolute" warning | `PHASE_5` §1–2, with the warning carried into the json `_comment`, plus the new `module` emission (`lib/` → `:java` being the non-obvious pair). | + +## 4. New concerns the plan adds (not in the original) + +- **No silent-green CI (I3):** `check.yml`'s `:uts:runUtsUnitTests` and `integration-test.yml`'s + `check-uts` would keep passing on smoke tests while real coverage moved — every move phase + re-points CI in the same PR (`PHASE_3` step 7, `PHASE_4` step 6). +- **JUnit Platform adoption:** `:liveobjects` flips wholesale (vintage engine for its 9 JUnit4 + files; count-parity gates), `:java` does **not** flip — isolation by task/engine instead + (`PHASE_3` step 2, `PHASE_4` step 3). +- **Test-ID parity (I8):** `@UTS(...)` id sets must be identical before/after every move. +- **Skill freeze (I9):** `/uts-to-kotlin` must not run between Phase 3 and Phase 5. + +## 5. References + +- `UTS_REFACTORING_PLAN/README.md` — decisions D1–D8, invariants I1–I9, phase table, audit fact base. +- `MOVE_COMMON_INFRA/` — the completed prior consolidation whose invariants (fixture package + stability, cycle rule, public-API test convention, one-green-PR-per-phase) this plan inherits. +- ably-cocoa PR #2223 — the smoke-test model for `PHASE_2`. +- `uts/README.md` — rewritten in `PHASE_6` to describe the target state. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e935db93..177bd0515 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } diff --git a/java/build.gradle.kts b/java/build.gradle.kts index bf1c48df9..e1eafc1a0 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -1,4 +1,5 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.build.config) @@ -6,6 +7,7 @@ plugins { alias(libs.plugins.test.retry) checkstyle `java-library` + alias(libs.plugins.kotlin.jvm) // NEW — test-only usage; see stdlib guardrail (step 4) } java { @@ -28,6 +30,29 @@ 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")) +} + +// 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. :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 { @@ -47,7 +72,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 { @@ -103,9 +136,47 @@ as it only contains the REST and Realtime suites. tasks.register("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 { 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("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 { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } +} + +tasks.register("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 { 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(""), + ) +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md b/lib/src/test/kotlin/io/ably/lib/uts/deviations.md similarity index 87% rename from uts/src/test/kotlin/io/ably/lib/uts/deviations.md rename to lib/src/test/kotlin/io/ably/lib/uts/deviations.md index 3a9339b69..c5a2c262b 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md +++ b/lib/src/test/kotlin/io/ably/lib/uts/deviations.md @@ -2,12 +2,13 @@ Deviations from the Ably spec identified during UTS test translation. Each entry records the spec point, what the spec requires, what the SDK actually does, and which test contains the deviation gate. -**Scope:** this file now holds deviations for the tiers hosted in the `:uts` module — **realtime/rest -(all tiers) and objects integration/proxy**. The **objects unit** tier moved to `:liveobjects`'s own -test source set; its deviations (the typed-SDK / language adaptations and the intentional RTO18d entry -that formerly lived here under groups 3 & 4) are in -`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. See "Objects unit-tier -deviations" at the bottom. +**Scope:** this file now lives alongside the realtime UTS suites in the `:java` module +(`lib/src/test/kotlin/io/ably/lib/uts/`) and holds deviations for the **realtime/rest tiers** it +hosts. All **objects** tiers (unit, integration and proxy) moved to `:liveobjects`'s own test source +set alongside the tests they document; their deviations (the typed-SDK / language adaptations, the +intentional RTO18d entry, and any objects integration/proxy entries) are in +`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. For the shared UTS infra these +suites consume, the tier smoke examples, and the `RUN_DEVIATIONS` mechanism, see `uts/README.md`. Entries are grouped by actionability (shared taxonomy across both files; only groups with entries in this file appear as sections below): @@ -89,9 +90,10 @@ this file appear as sections below): --- -# Objects unit-tier deviations — moved +# Objects deviations — moved -The objects **unit** suite (and its typed-SDK / language-adaptation deviation records, i.e. the former -groups 3 & 4 as they applied to objects) lives in `:liveobjects`'s own test source set: +All objects tiers (**unit, integration and proxy**) live in `:liveobjects`'s own test source set, +and their deviation records (the typed-SDK / language adaptations, the former groups 3 & 4 as they +applied to objects, and any objects integration/proxy entries) are in `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`. This file keeps only the -deviations for the tiers hosted in `:uts` (realtime/rest, and objects integration/proxy). +deviations for the realtime/rest tiers hosted in `:java`. diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt similarity index 98% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt index 613bd3b5a..9ae78cbd5 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt +++ b/lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/AuthReauthTest.kt @@ -120,7 +120,7 @@ class AuthReauthTest { it.type == "ws_frame" && it.direction == "client_to_server" && it.message?.get("action")?.asInt == 17 && - it.message.get("auth")?.isJsonNull == false + it.message?.get("auth")?.isJsonNull == false } assertTrue( diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/ChannelHistoryTest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/TokenRequestTest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt b/lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt similarity index 100% rename from uts/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt rename to lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/ConnectionRecoveryTest.kt diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts index db88d4d1d..58c19a9a7 100644 --- a/liveobjects/build.gradle.kts +++ b/liveobjects/build.gradle.kts @@ -16,22 +16,28 @@ dependencies { implementation(libs.coroutine.core) testImplementation(project(":java")) - testImplementation(kotlin("test")) - testImplementation(libs.bundles.kotlin.tests) - // Shared UTS test infra (mock transport, FakeClock, SandboxApp) from :uts's test-fixtures - // variant. Compile-safe: the fixtures depend only on :java/:network-client-core, never on - // this module (see the invariant note in uts/build.gradle.kts). - testImplementation(testFixtures(project(":uts"))) + testImplementation(libs.bundles.kotlin.tests) // keeps the JUnit4 API for the ~9 org.junit.* files + // Shared UTS test infra + the exported (`api`) test toolkit: JUnit 5 (api+params+engine via the + // aggregator), the kotlin.test Jupiter binding and coroutines all arrive transitively from :uts, + // so the wrapper kotlin.test / bom / jupiter-params lines are no longer declared here. The + // deterministic kotlin-test-junit5 binding (not the unpinned wrapper) also comes from :uts. + testImplementation(project(":uts")) + testRuntimeOnly(libs.junit.vintage.engine) // runs the ~9 legacy JUnit4 files under the platform } tasks.withType().configureEach { - testLogging { - exceptionFormat = TestExceptionFormat.FULL - } + useJUnitPlatform() // NEW + 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 } + systemProperty( // NEW (I6) — copied verbatim from uts/build.gradle.kts + "uts.proxy.localPath", + providers.systemProperty("uts.proxy.localPath") + .orElse(providers.environmentVariable("UTS_PROXY_LOCAL_PATH")) + .getOrElse(""), + ) } tasks.register("runLiveObjectsUnitTests") { @@ -44,6 +50,8 @@ tasks.register("runLiveObjectsUnitTests") { tasks.register("runLiveObjectsIntegrationTests") { filter { includeTestsMatching("io.ably.lib.liveobjects.integration.*") + includeTestsMatching("io.ably.lib.liveobjects.uts.integration.*") // NEW + includeTestsMatching("io.ably.lib.liveobjects.uts.proxy.*") // NEW // Exclude the base integration test class excludeTestsMatching("io.ably.lib.liveobjects.integration.setup.IntegrationTest") } diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md index 50570a791..415c605ef 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/README.md @@ -1,14 +1,24 @@ -# UTS objects unit suite +# UTS objects suites (`:liveobjects`) -Skill-generated tests for the UTS `objects/unit` specs (`/uts-to-kotlin`), one class per spec, -package `io.ably.lib.liveobjects.uts.unit`. Run: `./gradlew :liveobjects:runLiveObjectsUnitTests`. +Skill-generated tests for the UTS `objects` specs (`/uts-to-kotlin`), one class per spec. All three +objects tiers live here in `:liveobjects`'s own test source set: -- This suite lives in `:liveobjects`'s own test source set so the internal-graph specs can reach +| Tier | Package | Run with | +|---|---|---| +| unit | `io.ably.lib.liveobjects.uts.unit` | `./gradlew :liveobjects:runLiveObjectsUnitTests` | +| integration (direct sandbox) | `io.ably.lib.liveobjects.uts.integration` | `./gradlew :liveobjects:runLiveObjectsIntegrationTests` | +| proxy | `io.ably.lib.liveobjects.uts.proxy` | `./gradlew :liveobjects:runLiveObjectsIntegrationTests` | + +(`runLiveObjectsIntegrationTests` covers **both** the `integration` and `proxy` packages.) + +- These suites live in `:liveobjects`'s own test source set so the internal-graph specs can reach `internal` members — the symbol map is `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17. -- **Convention:** public-tier spec tests use only the public API + `unit/Helpers.kt`; only the five - internal-graph specs (`internal_live_counter`, `internal_live_map`, `object_id`, `objects_pool`, - `parent_references`) and documented deviations may reference `io.ably.lib.liveobjects` internals. -- Transport bootstrap comes from the shared `:uts` fixtures (`io.ably.lib.uts.infra.*`); message - builders are the typed `Wire*` constructions in `unit/Helpers.kt`. -- Deviations are recorded in [`deviations.md`](deviations.md) (same discipline as - `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`). +- **Convention:** public-tier spec tests use only the public API + the tier's `Helpers.kt`; only the + five internal-graph unit specs (`internal_live_counter`, `internal_live_map`, `object_id`, + `objects_pool`, `parent_references`) and documented deviations may reference + `io.ably.lib.liveobjects` internals. +- Transport bootstrap comes from the shared `:uts` infra (`io.ably.lib.uts.infra.*`, consumed via + `testImplementation(project(":uts"))`); message builders are the typed `Wire*` constructions in each + tier's `Helpers.kt` (`unit/Helpers.kt`, `integration/Helpers.kt`). +- Deviations for all three tiers are recorded in [`deviations.md`](deviations.md) (same discipline as + the realtime/rest tiers' `:java`-hosted `lib/src/test/kotlin/io/ably/lib/uts/deviations.md`). diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md index 3e5335b6e..3223ad755 100644 --- a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md @@ -1,9 +1,15 @@ -# Deviations — UTS objects unit suite (`io.ably.lib.liveobjects.uts.unit`) +# Deviations — UTS objects suites (`io.ably.lib.liveobjects.uts.*`) +> **Scope:** this file holds deviations for **all three objects tiers** now hosted in `:liveobjects` — +> objects **unit** (`io.ably.lib.liveobjects.uts.unit`), **integration** +> (`io.ably.lib.liveobjects.uts.integration`) and **proxy** (`io.ably.lib.liveobjects.uts.proxy`). +> The integration/proxy tiers moved here from `:uts` in Phase 3 alongside the tests they document. +> > Records every place a generated test deviates from its UTS spec, using the manual's -> **Recording deviations** entry format. Structural deviation vocabulary for this suite: +> **Recording deviations** entry format. Structural deviation vocabulary for these suites: > S-1…S-4 in `.claude/skills/uts-to-kotlin/references/objects-mapping.md` §17.9. -> (`:uts`-hosted tiers keep their own file at `uts/src/test/kotlin/io/ably/lib/uts/deviations.md`.) +> (The realtime/rest tiers keep their own file, `:java`-hosted, at +> `lib/src/test/kotlin/io/ably/lib/uts/deviations.md`.) ## UTS Spec Errors diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt index ebf568fe9..f90442881 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/Helpers.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/Helpers.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.standard.liveobjects +package io.ably.lib.liveobjects.uts.integration import com.google.gson.JsonArray import com.google.gson.JsonElement diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsLifecycleTest.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsLifecycleTest.kt index f2a7df73f..ea3456aa2 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsLifecycleTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.standard.liveobjects +package io.ably.lib.liveobjects.uts.integration import io.ably.lib.liveobjects.path.PathObject import io.ably.lib.liveobjects.path.PathObjectListener diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsSyncTest.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsSyncTest.kt index f3eecf7e9..8c7b90bff 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/integration/ObjectsSyncTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.standard.liveobjects +package io.ably.lib.liveobjects.uts.integration import io.ably.lib.liveobjects.path.PathObject import io.ably.lib.liveobjects.value.LiveMapValue diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy/ObjectsFaultsTest.kt similarity index 99% rename from uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy/ObjectsFaultsTest.kt index eaf408caf..a72bf50f5 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt +++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/proxy/ObjectsFaultsTest.kt @@ -1,4 +1,4 @@ -package io.ably.lib.uts.integration.proxy.liveobjects +package io.ably.lib.liveobjects.uts.proxy import io.ably.lib.liveobjects.path.PathObject import io.ably.lib.liveobjects.value.LiveMapValue diff --git a/uts/README.md b/uts/README.md index 253a97f95..0a0b1ef07 100644 --- a/uts/README.md +++ b/uts/README.md @@ -2,8 +2,9 @@ > A practical, end-to-end explanation of the **Universal Test Specification (UTS)** and how it is > realised in the `ably-java` repository. Written for a developer who has never touched UTS before -> and needs to understand *what it is*, *why it exists*, and *exactly how the Java/Kotlin code under -> `uts/` makes the unit, direct-sandbox, and proxy-integration tests work*. +> and needs to understand *what it is*, *why it exists*, and *exactly how the shared Kotlin +> infrastructure in the `:uts` module — plus the reference smoke tests that exercise it — makes the +> unit, direct-sandbox, and proxy-integration tiers work*. --- @@ -17,9 +18,9 @@ 6. [Unit-Test Infrastructure (mocked transports)](#6-unit-test-infrastructure-mocked-transports) 7. [Proxy-Integration Infrastructure (real backend + fault injection)](#7-proxy-integration-infrastructure-real-backend--fault-injection) 8. [Shared Async Helpers](#8-shared-async-helpers) -9. [Walkthrough: the Unit Test (`ConnectionRecoveryTest`)](#9-walkthrough-the-unit-test-connectionrecoverytest) -10. [Walkthrough: the Direct-Sandbox Integration Test (`ChannelHistoryTest`)](#10-walkthrough-the-direct-sandbox-integration-test-channelhistorytest) -11. [Walkthrough: the Proxy Test (`AuthReauthTest`)](#11-walkthrough-the-proxy-test-authreauthtest) +9. [Walkthrough: the Unit Smoke Test (`UnitInfraSmokeTest`)](#9-walkthrough-the-unit-smoke-test-unitinfrasmoketest) +10. [Walkthrough: the Direct-Sandbox Smoke Test (`IntegrationInfraSmokeTest`)](#10-walkthrough-the-direct-sandbox-smoke-test-integrationinfrasmoketest) +11. [Walkthrough: the Proxy Smoke Test (`ProxyInfraSmokeTest`)](#11-walkthrough-the-proxy-smoke-test-proxyinfrasmoketest) 12. [Deviations: when the SDK disagrees with the spec](#12-deviations-when-the-sdk-disagrees-with-the-spec) 13. [How to Run the Tests](#13-how-to-run-the-tests) 14. [Quick Reference / Cheat-Sheet](#14-quick-reference--cheat-sheet) @@ -54,8 +55,8 @@ UTS fixes this by separating **what to test** from **how to test it in a given l ▼ ┌──────────────────────────────┐ │ Derived tests │ ← concrete, runnable tests in the SDK's language - │ (this repo: Kotlin in uts/) │ e.g. ConnectionRecoveryTest.kt - └──────────────────────────────┘ + │ (Kotlin, in ably-java) │ spec suites: :java / :liveobjects; + └──────────────────────────────┘ shared infra + smoke examples: :uts ``` Three concepts you will see constantly: @@ -64,8 +65,8 @@ Three concepts you will see constantly: |------|---------| | **Spec point** | A tagged requirement in the features spec, e.g. `RTN16g`, `RTN22`, `RTL4f`. Test names embed these. | | **UTS spec** | A markdown file of portable pseudocode describing the setup, steps, and assertions for one feature. The *source of truth for what to test.* | -| **Derived test** | A faithful translation of a UTS spec into a real test in a specific SDK/language. This is what lives in `ably-java/uts/`. | -| **Deviation** | A documented case where the SDK's actual behaviour diverges from the spec. Recorded in `deviations.md`. | +| **Derived test** | A faithful translation of a UTS spec into a real test in a specific SDK/language. These live in the owning module's test source set (`:java` for realtime/rest, `:liveobjects` for objects); the shared infra they use lives in `:uts`. | +| **Deviation** | A documented case where the SDK's actual behaviour diverges from the spec. Recorded in the owning module's `deviations.md` (`:java` and `:liveobjects` — see §12). | The golden rule (from [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md)): **translate the UTS spec faithfully** — same structure, same assertions, same naming — don't optimise or skip steps. Every derived test carries a @@ -81,15 +82,16 @@ three example tests this guide walks through span all three tiers. | Tier | Transport | Backend | Purpose | Example in this repo | |------|-----------|---------|---------|----------------------| -| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/realtime/ConnectionRecoveryTest.kt` | -| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard/realtime/ChannelHistoryTest.kt` | -| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/realtime/AuthReauthTest.kt` | +| **Unit** | **Mocked** (`MockWebSocket`, `MockHttpClient`) | none | Client-side logic: state machines, request formation, response parsing, timer behaviour. Fast & deterministic. | `unit/UnitInfraSmokeTest.kt` | +| **Direct sandbox integration** | Real network | Real Ably sandbox | Happy-path interop: connect, publish, subscribe. No fault injection. | `integration/standard/IntegrationInfraSmokeTest.kt` | +| **Proxy integration** | Real network **through a programmable proxy** | Real Ably sandbox | Fault behaviour: dropped connections, injected errors, timeouts, re-auth. | `integration/proxy/ProxyInfraSmokeTest.kt` | -Each tier folder is further organised **by module** (`realtime`, `liveobjects`, …): `unit//`, -`integration/standard//`, and `integration/proxy//`. So a feature's tests sit together -by SDK area — the three example tests live at `unit/realtime/`, `integration/standard/realtime/`, and -`integration/proxy/realtime/`. (One exception: the **objects unit** tier lives in the `:liveobjects` -module, not here — see §4.2 "Cross-module exception".) +The three examples above are `:uts`'s own **tier smoke tests** (§9–§11) — the reference shapes this +guide walks through. The real, spec-derived suites live **by module**, in the module that owns the +code under test: realtime/rest under `:java` (`lib/src/test/kotlin/io/ably/lib/uts/…`), objects under +`:liveobjects` (`liveobjects/.../uts/…`). `:uts`'s own test tree holds only the tier smoke examples; +so a feature's tests always sit with the SDK code they exercise (see §4.2 "Where every module's UTS +tests live"). Key principles (from [`integration-testing.md`](https://github.com/ably/specification/blob/main/uts/docs/integration-testing.md)): @@ -157,11 +159,14 @@ segregation exists because proxy tests have different infra needs, CI cadence, a A big table mapping every features-spec group (`RSC`, `RTN`, `RTL`, `RTP`, …) to the UTS specs that cover it, with a per-tier summary (`unit:✓ proxy:✓`). This is the tracker for "what's done and what's missing". The reference tests this guide walks through correspond to these rows: -- `RTN16` (connection recovery) → unit spec `connection_recovery_test.md` → **`ConnectionRecoveryTest.kt`**. +- `RTN16` (connection recovery) → unit spec `connection_recovery_test.md` → + **`ConnectionRecoveryTest.kt`** (`:java`, `lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/`). - `RTL10d` (channel history) → direct-sandbox spec - `realtime/integration/channel_history_test.md` → **`ChannelHistoryTest.kt`**. + `realtime/integration/channel_history_test.md` → **`ChannelHistoryTest.kt`** + (`:java`, `.../integration/standard/realtime/`). - `RTN22` / `RTC8a` (server-initiated re-auth) → proxy spec - `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`**. + `realtime/integration/proxy/auth_reauth.md` → **`AuthReauthTest.kt`** + (`:java`, `.../integration/proxy/realtime/`). > There is also a fifth, *referenced* spec: > [`docs/proxy.md`](https://github.com/ably/specification/blob/main/uts/docs/proxy.md) @@ -173,73 +178,98 @@ what's missing". The reference tests this guide walks through correspond to thes ## 4. The Java Setup: the `uts/` module -The `uts/` directory is a **standalone Gradle module** (`include("uts")` in -`settings.gradle.kts`) whose only job is to host UTS-derived tests and the shared test -infrastructure. It contains *no production code* — the tests live under `uts/src/test/`, and the -infrastructure lives in the module's **test-fixtures** source set, `uts/src/testFixtures/`, so -that other Gradle modules can consume it via `testImplementation(testFixtures(project(":uts")))` -(this module's own tests see it automatically). +The `uts/` directory is a **standalone Gradle module** (`include("uts")` in `settings.gradle.kts`) +that is the repo's **shared UTS test-support library** plus a small set of reference examples. Its +**main** source set *is* the shared test infrastructure — `uts/src/main/kotlin/io/ably/lib/uts/infra/` +— so any other Gradle module consumes it with a plain `testImplementation(project(":uts"))` (this +module's own tests see it automatically). Its **test** source set holds only the three tier **smoke +tests** (§9–§11): the permanent acceptance gate for the infra and the worked examples this guide +teaches from. + +Two things this means — and a correction to how the module used to be described. First, the infra is +**this module's main/production code**, not a `testFixtures` variant and not test-only: `:uts`'s main +artifact *is* the infra. Second, `:uts` does **not** host the spec-derived UTS suites — those live in +their owning modules (`:java` for realtime/rest, `:liveobjects` for objects; see §4.2). `:uts` keeps +only the infra and the tier smoke examples. ### 4.1 `uts/build.gradle.kts` ```kotlin plugins { - alias(libs.plugins.kotlin.jvm) - `java-test-fixtures` // exports src/testFixtures (the infra) as a consumable variant + `java-library` // provides the `api` configuration (kotlin.jvm alone + // applies only the plain `java` plugin) + alias(libs.plugins.kotlin.jvm) // Kotlin/JVM — `java-test-fixtures` is REMOVED +} + +java { + // Declare Java-8 outgoing variants so :java's 8-requesting configurations can consume + // project(":uts"). + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } dependencies { - // fixtures (the shared infra) — MUST never depend on :liveobjects (keeps consumers acyclic) - testFixturesApi(project(":java")) // types in fixture signatures - testFixturesApi(project(":network-client-core")) // HttpEngine / WebSocketEngine interfaces - testFixturesImplementation(libs.coroutine.core) - testFixturesImplementation(libs.ktor.client.core) // sandbox/proxy control HTTP - testFixturesImplementation(libs.ktor.client.cio) - - testImplementation(project(":java")) // the SDK under test - testImplementation(project(":network-client-core")) + // The shared UTS infra (src/main/kotlin/io/ably/lib/uts/infra/**) — this module's main artifact, + // consumed elsewhere via testImplementation(project(":uts")). `api` for types that appear in + // infra signatures; `implementation` for internals. Invariant I1: :uts never depends on + // :liveobjects. + api(project(":java")) // the SDK + its types (DebugOptions, ProtocolMessage, …) + api(project(":network-client-core")) // HttpEngine / WebSocketEngine SPIs the mocks implement + implementation(libs.coroutine.core) + implementation(libs.ktor.client.core) // sandbox/proxy control HTTP + implementation(libs.ktor.client.cio) + + // :uts's own tests — the tier smoke tests. No :liveobjects edge (I1). testImplementation(kotlin("test")) - testImplementation("org.junit.jupiter:junit-jupiter-params") // @ParameterizedTest / @ValueSource (version from the JUnit BOM) - testImplementation(libs.mockk) - testImplementation(libs.coroutine.core) // kotlinx.coroutines - testImplementation(libs.coroutine.test) // runTest, virtual time + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter.params) // @ParameterizedTest / @ValueSource + testImplementation(libs.coroutine.core) + testImplementation(libs.coroutine.test) // runTest, virtual time testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.cio) } tasks.withType().configureEach { - useJUnitPlatform() // JUnit 5 + useJUnitPlatform() // JUnit 5 jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") - // Propagate a local proxy build override (see ProxyManager): - systemProperty("uts.proxy.localPath", /* -Duts.proxy.localPath=… or $UTS_PROXY_LOCAL_PATH */ …) + // Propagate a local proxy-build override (see ProxyManager): -Duts.proxy.localPath=… or + // $UTS_PROXY_LOCAL_PATH. + systemProperty("uts.proxy.localPath", …) } + +tasks.register("runUtsUnitTests") { filter { includeTestsMatching("io.ably.lib.uts.unit.*") } } +tasks.register("runUtsIntegrationTests") { filter { includeTestsMatching("io.ably.lib.uts.integration.*") } } + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } } ``` Takeaways: -- Tests are **Kotlin + JUnit 5**, using **kotlinx.coroutines** for async control and **Ktor** as the - HTTP client that talks to the sandbox REST API and the proxy control API. -- `junit-jupiter-params` adds **`@ParameterizedTest`** — used by integration specs to run their - `json` / `msgpack` protocol variants as a single test parameterised on `useBinaryProtocol` (see §10.3). - Its version is managed by the JUnit 5 BOM that `kotlin("test")` brings onto the test classpath, so no - explicit version is pinned. -- It depends on `:java` (the SDK) and `:network-client-core` (the pluggable transport interfaces the - mocks implement). -- The `--add-opens java.base/java.time` and `java.base/java.lang` flags grant reflective access into - those JDK packages for the test runtime. They mirror the same flags set in `java/build.gradle.kts` - for the SDK's own test module (which additionally opens `java.net` and `java.lang.reflect`). +- `:uts` is a `java-library` + `kotlin.jvm` module. `java-test-fixtures` is **gone** — the infra is + plain `src/main`, so consumers use `testImplementation(project(":uts"))` (no `testFixtures(...)` + wrapper). `java-library` is what now supplies the `api` configuration. +- The module compiles to **Java 8** bytecode (source/target + `jvmTarget = JVM_1_8`), so `:java` + (which requests Java-8 variants) can consume it. **mockk is not a dependency** — the infra imports + no test library at all. +- It depends on `:java` (the SDK under test) and `:network-client-core` (the pluggable transport SPIs + the mocks implement), both via `api` because they appear in infra signatures. +- Tests are **Kotlin + JUnit 5**, using **kotlinx.coroutines** for async control and **Ktor** for the + sandbox REST API and proxy control API. `junit-jupiter-params` (version from the JUnit BOM) adds + **`@ParameterizedTest`** for the protocol-variant integration tests (§10.3). +- `runUtsUnitTests` / `runUtsIntegrationTests` are package-filtered `Test` tasks (§13). The + `--add-opens java.base/java.time` and `java.base/java.lang` flags grant the test runtime reflective + access into those JDK packages, mirroring `java/build.gradle.kts`. - A system property carries an optional path to a **locally built** proxy binary (so you can test against an unreleased proxy). ### 4.2 Directory layout -Everything lives under the `io.ably.lib.uts` package, split cleanly into **infrastructure** -(`infra/`, no `@Test`s — in the `testFixtures` source set) and the **tests** themselves (in the -`test` source set). Tests are organised **by tier, then by module**: `unit/` for -mocked-transport tests, and `integration/` for real-backend tests — the latter splitting again into -`standard/` (direct sandbox, happy-path) and `proxy/` (sandbox through the fault-injecting proxy). Under -each, a per-module folder (`realtime`, `liveobjects`, …) holds the actual test classes: +Everything lives under the `io.ably.lib.uts` package, split cleanly between the **main** source set — +the shared **infrastructure** (`infra/`, no `@Test`s) — and the **test** source set — the three tier +**smoke tests**. The infra is organised by tier: `infra/unit/` for mocked transports, and +`infra/integration/` for real-backend helpers — the latter with an `infra/integration/proxy/` +sub-package for the fault-injecting proxy — plus one shared `infra/Utils.kt` serving every tier: ```text -uts/src/testFixtures/kotlin/io/ably/lib/uts/ # ── shared, consumable by other modules ── +uts/src/main/kotlin/io/ably/lib/uts/ # ── shared infra, consumed via testImplementation(project(":uts")) ── └── infra/ # ── TEST INFRASTRUCTURE (no @Test methods) ── ├── Utils.kt # awaitState / awaitChannelState / pollUntil (shared) │ @@ -263,41 +293,38 @@ uts/src/testFixtures/kotlin/io/ably/lib/uts/ # ── shared, consumable by ├── ProxyManager.kt # downloads/launches the uts-proxy binary └── ProxySession.kt # proxy session: rules, actions, log + connectThroughProxy -uts/src/test/kotlin/io/ably/lib/uts/ # ── the tests themselves ── -├── deviations.md # the catalogue of SDK-vs-spec divergences -│ -├── unit/ # ── UNIT TESTS (mock transport) ── · per module -│ └── realtime/ -│ └── ConnectionRecoveryTest.kt # ← the UNIT test (RTN16*) -│ # NOTE: objects unit tests are NOT here — the objects -│ # unit tier lives in the :liveobjects module (see below) -│ -└── integration/ # ── INTEGRATION TESTS (real backend) ── · per module - ├── standard/ # direct sandbox: happy-path, no fault injection - │ ├── realtime/ - │ │ └── ChannelHistoryTest.kt # ← the DIRECT-SANDBOX test (RTL10d) - │ └── liveobjects/ - └── proxy/ # sandbox through the fault-injecting uts-proxy - ├── realtime/ - │ └── AuthReauthTest.kt # ← the PROXY test (RTN22, RTC8a) - └── liveobjects/ +uts/src/test/kotlin/io/ably/lib/uts/ # ── the tier SMOKE TESTS (infra acceptance + worked examples) ── +├── unit/ +│ └── UnitInfraSmokeTest.kt # ← UNIT smoke: mock WS + HTTP + FakeClock (§9) +└── integration/ + ├── standard/ + │ └── IntegrationInfraSmokeTest.kt # ← DIRECT-SANDBOX smoke: SandboxApp (§10) + └── proxy/ + └── ProxyInfraSmokeTest.kt # ← PROXY smoke: ProxyManager + ProxySession (§11) ``` -The mental model: **`infra/unit/` powers the unit tests, `infra/integration/` powers both integration -kinds (`standard` + `proxy`), and `infra/Utils.kt` serves all of them.** Every tier is sub-divided **by -module** (`realtime`, `liveobjects`, …) so a feature's tests sit together regardless of SDK area. The -top-level `unit/` ↔ `infra/unit/` and `integration/` ↔ `infra/integration/` pairing is what the -`runUtsUnitTests` / `runUtsIntegrationTests` Gradle tasks key off (§13) — `runUtsIntegrationTests` -covers **both** `integration/standard/` and `integration/proxy/`. - -> **Cross-module exception — objects unit tier.** The `objects` module's **unit** specs assert on -> internal LiveObjects CRDT state (`InternalLiveMap`/`InternalLiveCounter`/`ObjectsPool`) that is only -> visible to `:liveobjects`'s own test source set, so that tier does **not** live here — it is in -> `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/` (package `io.ably.lib.liveobjects.uts.unit`, -> run by `:liveobjects:runLiveObjectsUnitTests`), consuming this module's infra via the test-fixtures -> variant. The objects **integration** and **proxy** tiers remain here under `integration/`. See -> `.claude/skills/uts-to-kotlin/uts-package-mapping.json` (the `objects.unit` `{root, path}` entry) and -> `MOVE_COMMON_INFRA/`. +The mental model: **`infra/unit/` powers the unit tier, `infra/integration/` powers both integration +kinds (`standard` + `proxy`), and `infra/Utils.kt` serves all of them.** The top-level `unit/` ↔ +`infra/unit/` and `integration/` ↔ `infra/integration/` pairing is what the `runUtsUnitTests` / +`runUtsIntegrationTests` Gradle tasks key off (§13) — `runUtsIntegrationTests` covers **both** +`integration/standard/` and `integration/proxy/`. + +#### Where every module's UTS tests live + +The infra is shared, but the actual UTS suites live **in the module that owns the code under test**; +`:uts` itself holds only the infra and one smoke example per tier: + +| Module | UTS tests | Location | Run with | +|---|---|---|---| +| `:java` | realtime (and future rest) — unit, integration, proxy | `lib/src/test/kotlin/io/ably/lib/uts/{unit, integration/standard, integration/proxy}/realtime/` | `:java:runUtsUnitTests` / `:java:runUtsIntegrationTests` | +| `:liveobjects` | objects — unit, integration, proxy | `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/{unit, integration, proxy}/` | `:liveobjects:runLiveObjectsUnitTests` / `:liveobjects:runLiveObjectsIntegrationTests` | +| `:uts` | **none** — shared infra + one smoke test per tier | `uts/src/main/.../infra/**` + `uts/src/test/.../{unit, integration/standard, integration/proxy}/` | `:uts:runUtsUnitTests` / `:uts:runUtsIntegrationTests` | + +Every consuming module gets the infra via `testImplementation(project(":uts"))`. The objects suites +additionally reach `:liveobjects`-internal CRDT state (`InternalLiveMap`/`InternalLiveCounter`/ +`ObjectsPool`), which is why they live in `:liveobjects`'s own test source set — including the objects +**unit** tier, whose internal-graph specs cannot be expressed from outside that module. See +`.claude/skills/uts-to-kotlin/uts-package-mapping.json` and `MOVE_COMMON_INFRA/`. --- @@ -555,92 +582,142 @@ technique used by `liveobjects/.../TestUtils.kt`). See Appendix B.1. --- -## 9. Walkthrough: the Unit Test (`ConnectionRecoveryTest`) - -**File:** `uts/.../uts/unit/realtime/ConnectionRecoveryTest.kt` (package `io.ably.lib.uts.unit.realtime`) -**Tier:** Unit (mocked WebSocket, no network). -**Spec area:** RTN16 — connection recovery via the `recover` option and `createRecoveryKey()`. - -It contains six tests; each carries an `@UTS realtime/unit/RTN16…/…` tag. Here's what each proves and -the technique it uses: - -### 9.1 `RTN16g, RTN16g1` — recovery-key structure (incl. Unicode) -Connects (mock returns CONNECTED with a known key), attaches two channels — one ASCII, one Unicode -(`channel-éàü-世界`) — feeding each an `ATTACHED` with a `channelSerial` via `sendToClient`. Then calls -`connection.createRecoveryKey()`, decodes it with `RecoveryKeyContext.decode`, and asserts the -connection key, `msgSerial == 0`, and both channel serials survive — including a full -**encode→decode round-trip** to prove the Unicode name isn't corrupted (RTN16g1). -*Technique: callback-style `onConnectionAttempt`, `sendToClient` for ATTACHED, `awaitChannelState`.* - -### 9.2 `RTN16g2` — `createRecoveryKey()` returns null in inactive states -The most elaborate test — it walks the connection through **five** states and asserts the key is null -in each inactive one: -- **INITIALIZED** (before connect) → null. -- **CONNECTED** → non-null (sanity). -- **CLOSING / CLOSED** → null (close nulls the key immediately). -- **FAILED** → null. *(Contains a documented **deviation** — see §12: the spec's fatal error - code 50000/500 isn't treated as fatal by the SDK, and `send_to_client_and_close` races the FAILED - transition; the test uses code 40000/400 and plain `sendToClient`.)* -- **SUSPENDED** → null. Built with a `FakeClock`: connect succeeds, then `simulateDisconnect()`, - then a coroutine **refuses every reconnection attempt** while `fakeClock.advance(2.seconds)` loops - until the short `connectionStateTtl` (800 ms) expires and the client gives up to SUSPENDED. -*Technique: this is the textbook example of **await-style** mocking — the first connection succeeds -via `awaitConnectionAttempt()`, but reconnections need the *refused* response, so a separate -`refuseJob` coroutine drives them; mixing this with fake timers gives deterministic SUSPENDED.* - -### 9.3 `RTN16k` — `recover` adds the `recover` query param -Constructs the client with `recover = `, captures `conn.queryParams` on each connection -attempt, then `simulateDisconnect()` and reconnect. Asserts the **first** attempt carries -`recover=` (and no `resume`), while the **second** (post-reconnect) carries `resume=` -(and no `recover`) — i.e. recover is a one-shot bootstrap, subsequent reconnections use resume. - -### 9.4 `RTN16f` — `recover` initialises `msgSerial` *(env-gated deviation)* -Asserts the recovered `msgSerial` (42) is preserved. The SDK resets it to 0, so the spec-correct -assertion `assertEquals(42L, …)` runs only under `RUN_DEVIATIONS`; otherwise a regression-guard -`assertEquals(0L, …)` runs. (See §12.) - -### 9.5 `RTN16f1` — malformed `recover` key degrades gracefully -`recover = "this-is-not-valid-json!!!"`. Asserts the client still connects normally with a fresh -identity, **no** `recover`/`resume` query params, and exactly one connection attempt — i.e. a bad key -is logged and ignored, not fatal. - -### 9.6 `RTN16j` — `recover` instantiates channels with their serials (RTN16i too) -Recovery key carries three channels (incl. Unicode). Asserts each `channels.get(name).properties. -channelSerial` matches the key, that the channels are **NOT auto-attached** (state INITIALIZED — -RTN16i), and that a manual `attach()` sends an ATTACH frame carrying the recovered serial (verified -via `awaitNextMessageFromClient()`). - -**What this test teaches about the infra:** callback vs await styles side by side, `FakeClock`-driven -SUSPENDED, `sendToClient` for server frames, `events`/`awaitNextMessageFromClient` for inspecting -client output, and the env-gated deviation pattern. +## 9. Walkthrough: the Unit Smoke Test (`UnitInfraSmokeTest`) + +**File:** `uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt` (package `io.ably.lib.uts.unit`) +**Tier:** Unit (mocked WebSocket + mocked HTTP, no network). +**Purpose:** the permanent acceptance test for the unit-tier infra — it drives a real SDK through +`MockWebSocket`, `MockHttpClient` and `FakeClock` end-to-end. It carries **no** `@UTS` marker (it is +not derived from a spec) and must never trip the spec-parity tooling; it is the reference shape a +future unit-tier UTS test should take. + +> The real spec-derived unit suites this pattern scales to live in `:java` +> (`lib/src/test/kotlin/io/ably/lib/uts/unit/realtime/`, e.g. `ConnectionRecoveryTest`) and +> `:liveobjects` — see §13. + +It has **two** `@Test` methods; between them they exercise every teaching point of §5–§8. + +### 9.1 `unit infra drives the full mock-WebSocket connection lifecycle` — await style throughout +One long **await-style** test that walks the SDK through the whole transport lifecycle: + +1. **Connect (await style).** A `launch`ed coroutine calls `awaitConnectionAttempt()`, captures the + `PendingConnection`, then answers `respondWithSuccess(CONNECTED_MESSAGE)`: + ```kotlin + val firstConnection = CompletableDeferred() + launch { + val conn = mock.awaitConnectionAttempt() + firstConnection.complete(conn) + conn.respondWithSuccess(CONNECTED_MESSAGE) + } + client.connect() + awaitState(client, ConnectionState.connected) + ``` + It then asserts the captured connection's **query params** (`format == "json"`, `key` present — + the same technique a `recover`/`resume` test uses), that `CONNECTED_MESSAGE`'s `test-connection-id` + reached `client.connection.id`, and the event ordering (`events[0] is ConnectionAttempt`, + `events[1] is ConnectionEstablished`). +2. **Server-initiated ATTACHED with a Unicode round-trip.** It attaches a channel whose name carries + Unicode (`smoke-üñîçöðé-…`), asserts the **outbound** ATTACH frame via `awaitNextMessageFromClient()`, + then feeds an ATTACHED back with `sendToClient` and checks the `channelSerial` round-tripped in: + ```kotlin + ch.attach() + val attachFrame = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.attach, attachFrame.action) + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = channelName + channelSerial = "serial-1" + }) + awaitChannelState(ch, ChannelState.attached) + ``` +3. **Publish**, asserting the full MESSAGE frame (`action`, `channel`, `messages[0].name`/`data`) again + via `awaitNextMessageFromClient()`. +4. **Disconnect + negative check.** `simulateDisconnect()`, await DISCONNECTED, then assert **no** + reconnect has happened yet — exactly one `ConnectionAttempt` is recorded — because the retry is + blocked in `FakeClock.waitOn` until the clock advances. +5. **FakeClock-driven reconnect.** A coroutine loops `fakeClock.advance(2.seconds)` then answers the + next attempt with a short-TTL CONNECTED; the test awaits CONNECTED again and asserts a second + `ConnectionAttempt`. +6. **Refuse → SUSPENDED (the centrepiece).** After another `simulateDisconnect()`, a `refuseJob` + coroutine advances the clock and `respondWithRefused()`s every reconnection attempt until the short + `connectionStateTtl` (800 ms, from the short-lived CONNECTED) expires and the client gives up to + SUSPENDED; the test asserts `connection.createRecoveryKey()` is null in SUSPENDED. + +*Why await-style throughout?* The initial connect, the FakeClock reconnect, and the refuse branch each +need a **different** answer per attempt — a single `onConnectionAttempt` callback answers every attempt +uniformly, and the two styles cannot be mixed on one mock. (This is the same reason `:java`'s +`ConnectionRecoveryTest` is await-style.) *Technique on show: await-style `awaitConnectionAttempt` / +`awaitNextMessageFromClient`, `sendToClient` for server frames, `events` for assertions, `FakeClock` +for deterministic backoff, and a Unicode channel-name round-trip.* + +### 9.2 `unit infra serves a token-auth HTTP request through the mock engine` — callback WS + HTTP mock +The second test finally gives §6.3's `MockHttpClient` a worked example, and demonstrates the +**callback style** on the WebSocket side (every attempt is answered identically, so a callback is the +right tool): +```kotlin +val mockWs = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } } +val mockHttp = MockHttpClient { onConnectionAttempt = { it.respondWithSuccess() } } +val client = TestRealtimeClient { + authUrl = "https://auth.example.test/token" + install(mockWs) + install(mockHttp) + autoConnect = false +} +``` +The auth HTTP request is then handled **await style** — a `launch`ed coroutine `awaitRequest()`s it, +asserts the outbound request shape, and feeds a canned `TokenDetails` JSON back: +```kotlin +launch { + val request = mockHttp.awaitRequest() + captured.complete(request.method to request.url.path) + request.respondWith(200, tokenJson, mapOf("Content-Type" to "application/json")) +} +client.connect() +awaitState(client, ConnectionState.connected) +``` +It asserts the request was a `GET /token` and that the SDK reached CONNECTED with the fetched token. + +> ⚠️ **Trap (documented inline in the test):** `MockEvent.HttpRequest` is declared in the `MockEvent` +> sealed class but is **never emitted** by the HTTP mock — asserting on +> `events.filterIsInstance()` would silently pass on an empty list. Assert via +> `MockHttpClient.awaitRequest()` / the `PendingRequest` instead (as this test does). + +**What these two tests teach about the infra:** callback vs await styles side by side (WS callback in +§9.2, WS await throughout §9.1), `FakeClock`-driven reconnect and SUSPENDED, `sendToClient` for server +frames, `events` / `awaitNextMessageFromClient` for inspecting client output, and the full HTTP-mock +connect→request two-phase flow. The `RUN_DEVIATIONS` env-gated deviation pattern is **not** here (the +smoke tests carry no deviations) — that teaching lives in §12. --- -## 10. Walkthrough: the Direct-Sandbox Integration Test (`ChannelHistoryTest`) +## 10. Walkthrough: the Direct-Sandbox Smoke Test (`IntegrationInfraSmokeTest`) -**File:** `uts/.../uts/integration/standard/realtime/ChannelHistoryTest.kt` (package `io.ably.lib.uts.integration.standard.realtime`) +**File:** `uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt` (package `io.ably.lib.uts.integration.standard`) **Tier:** Direct-sandbox integration (real network, real Ably sandbox, **no** proxy, **no** fault injection). -**Spec point:** RTL10d — messages published by one realtime client are retrievable from a *separate* -client's `history()`. +**Purpose:** the permanent acceptance test for the middle-tier infra — `SandboxApp` + +`TestRealtimeClient`/`TestRestClient` wired straight to the sandbox. No `@UTS` marker. -This is the reference for the **middle tier**. Like a proxy test it talks to the real backend, but it -connects *straight* to `SandboxApp.sandboxHost` — there is no `ProxyManager`, no `ProxySession`, and no -`connectThroughProxy` wiring. It's the shape every happy-path interop spec -(connect/publish/subscribe/presence) follows. +> The real spec-derived direct-sandbox suites this pattern scales to live in `:java` +> (`lib/src/test/kotlin/io/ably/lib/uts/integration/standard/realtime/`, e.g. `ChannelHistoryTest`) +> and `:liveobjects` — see §13. + +It talks to the real backend but connects *straight* to `SandboxApp.sandboxHost` — no `ProxyManager`, +no `ProxySession`, no `connectThroughProxy`. It's the shape every happy-path interop spec +(connect/publish/subscribe/history) follows. ### 10.1 Suite setup/teardown -Same `@TestInstance(PER_CLASS)` + `runBlocking` pattern as the proxy test, but provisioning **`SandboxApp` -only** — no `ProxyManager.ensureProxy()`: +`@TestInstance(PER_CLASS)` + `runBlocking`, provisioning **`SandboxApp` only** — no +`ProxyManager.ensureProxy()`: ```kotlin @BeforeAll fun setUpAll() = runBlocking { app = SandboxApp.create() } @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() } ``` -### 10.2 The client — wired straight to the sandbox -A tiny `newClient` helper points the **real** transport at the sandbox host (no proxy in between). Setting +### 10.2 The clients — wired straight to the sandbox +Two tiny helpers point the **real** transports at the sandbox host (no proxy in between). Setting explicit hosts auto-disables fallback hosts (REC2c2), so there's nothing else to configure: ```kotlin -private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { +private fun newRealtimeClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { key = app.defaultKey realtimeHost = SandboxApp.sandboxHost // sandbox.realtime.ably-nonprod.net restHost = SandboxApp.sandboxHost @@ -648,8 +725,8 @@ private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeCl autoConnect = false } ``` -(`TestRealtimeClient` is the same builder the unit tests use — it just isn't fed any mocks here, so it -drives the SDK's real network transport instead of a `MockWebSocket`.) +(`TestRealtimeClient`/`TestRestClient` are the same builders the unit tests use — here fed no mocks, so +they drive the SDK's real network transport instead of a `MockWebSocket`.) ### 10.3 Protocol variants — the `@ParameterizedTest` pattern The spec declares a `PROTOCOL` dimension (`json` / `msgpack`) and says *each test runs once per variant*. @@ -658,7 +735,7 @@ module depends on `junit-jupiter-params` (§4.1): ```kotlin @ParameterizedTest(name = "useBinaryProtocol={0}") @ValueSource(booleans = [false, true]) // false = JSON, true = msgpack -fun `RTL10d - history contains messages published by another client`(useBinaryProtocol: Boolean) = runTest { +fun `sandbox infra works end to end`(useBinaryProtocol: Boolean) = runTest { … } ``` @@ -666,17 +743,26 @@ A plain `@Test` test (no protocol dimension) stays a `@Test` — reach for `@Par spec actually declares variants. ### 10.4 The scenario — real publish, real history -Two independent clients on the same app: the publisher's *confirmed* messages must appear in the -subscriber's history. The integration-specific techniques on show: +One realtime client and one REST client on the same app: the publisher's *confirmed* messages must +appear in the REST `history()`. The integration-specific techniques on show: +- **A recorded state sequence, not just the final state.** `client.connection.on { states.add(it.current) }` + is registered *before* connect, then the test asserts `states.contains(connecting)` and + `states.last() == connected`. - **Awaiting a publish ack.** Realtime publish is fire-and-forget, so to honour the spec's `AWAIT publish` - the test wraps the (non-deprecated) `publish(name, data, Callback)` overload in a - `suspendCancellableCoroutine` (`awaitPublish`), resuming on `onSuccess` and failing on `onError`. This is - the integration analogue of the unit test's `awaitNextMessageFromClient()`. -- **`AWAIT attach()`** → `attach()` then `awaitChannelState(channel, ChannelState.attached)`. + an `awaitPublish` extension wraps the `publish(name, data, Callback)` overload in a + `suspendCancellableCoroutine`, resuming on `onSuccess` and failing on `onError`. The test publishes + **three** messages, each ack-awaited. This is the integration analogue of the unit test's + `awaitNextMessageFromClient()`. +- **`AWAIT attach()`** → `attach()` then `awaitChannelState(channel, ChannelState.attached, 15.seconds)`. - **Polling real REST state.** `history()` is a blocking REST call against the sandbox and the message - store is eventually-consistent, so the test - `pollUntil(10.seconds, 500.milliseconds) { subChannel.history(null).items().size == 3 }` — never a fixed - sleep (the same anti-flake rule as the other tiers). + store is eventually-consistent, so — never a fixed sleep (the same anti-flake rule as the other tiers): + ```kotlin + pollUntil(15.seconds, 500.milliseconds) { + val result = rest.channels.get(channelName).history(null) + history = result + result.items().size == 3 + } + ``` - **Order assertion.** History defaults to newest-first, so `items[0]` is `event3` … `items[2]` is `event1`. **What this test teaches about the infra:** `SandboxApp`-only provisioning, the direct-sandbox client @@ -686,18 +772,22 @@ REST `history()` call. --- -## 11. Walkthrough: the Proxy Test (`AuthReauthTest`) +## 11. Walkthrough: the Proxy Smoke Test (`ProxyInfraSmokeTest`) -**File:** `uts/.../uts/integration/proxy/realtime/AuthReauthTest.kt` (package `io.ably.lib.uts.integration.proxy.realtime`) +**File:** `uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt` (package `io.ably.lib.uts.integration.proxy`) **Tier:** Proxy integration (real sandbox + uts-proxy). -**Spec points:** RTN22 (server-initiated re-authentication) and RTC8a (the client sends an AUTH -frame with renewed auth details). Unit-test counterparts: `server_initiated_reauth_test.md`, -`realtime_authorize.md`. +**Purpose:** the permanent acceptance test for the proxy infra — `ProxyManager` + `ProxySession` + +`SandboxApp` + client wiring through the proxy, exercising **both** fault-injection styles. No `@UTS` +marker. + +> The real spec-derived proxy suites this pattern scales to live in `:java` +> (`lib/src/test/kotlin/io/ably/lib/uts/integration/proxy/realtime/`, e.g. `AuthReauthTest`) and +> `:liveobjects` — see §13. ### 11.1 Suite setup/teardown ```kotlin @TestInstance(TestInstance.Lifecycle.PER_CLASS) // one instance, so @BeforeAll can be non-static -class AuthReauthTest { +class ProxyInfraSmokeTest { @BeforeAll fun setUpAll() = runBlocking { ProxyManager.ensureProxy() // download+launch proxy if needed app = SandboxApp.create() // provision a real sandbox app @@ -705,81 +795,106 @@ class AuthReauthTest { @AfterAll fun tearDownAll() = runBlocking { if (::app.isInitialized) app.delete() } } ``` +It has **two** `@Test` methods, one per fault-injection style. -### 11.2 The test, step by step -1. **Create a session with no rules** — the fault will be injected *imperatively* later (late - injection — the connect handshake runs against the real server unmodified): - ```kotlin - val session = ProxySession.create(rules = emptyList()) - ``` -2. **Auth via `authCallback`** — the spec generates a JWT from the sandbox key; the idiomatic - ably-java equivalent is a locally-signed `TokenRequest` from the same key (no external JWT - library). A counter records how many times the callback is invoked: - ```kotlin - val tokenSigner = AblyRest(app.defaultKey) - val authCallback = Auth.TokenCallback { params -> - authCallbackCount.incrementAndGet() - tokenSigner.auth.createTokenRequest(params, null) - } - ``` -3. **Build the client through the proxy** and connect (JSON stays on so the proxy can inspect - frames): - ```kotlin - val client = TestRealtimeClient { - this.authCallback = authCallback - connectThroughProxy(session) - autoConnect = false - } - client.connect() - awaitState(client, ConnectionState.connected, 15.seconds) - ``` -4. **Snapshot identity** — `connection.id` and the callback count, and assert the callback already - ran ≥ 1 (initial auth). -5. **Start recording state changes**, then **inject a server-initiated AUTH** (protocol action 17) - imperatively — simulating Ably asking the client to re-authenticate: - ```kotlin - session.triggerAction(mapOf("type" to "inject_to_client", - "message" to mapOf("action" to 17))) - ``` -6. **Wait for the re-auth round-trip** with `pollUntil { stateChanges.size > 1 }` (real network, so - poll — don't sleep). -7. **Assertions** prove RTN22 + RTC8a: - - `authCallback` was invoked **again** (count incremented) → re-auth was triggered. - - Connection is still **CONNECTED** and `connection.id` is **unchanged** → re-auth does not - reconnect. - - **No** transitions away from CONNECTED were recorded. - - The **proxy event log** contains a client→server **AUTH frame (action 17) carrying non-null - `auth` details** (RTC8a) — verified by filtering `session.getLog()`. -8. **Nested teardown** in `finally`: close the client and wait for CLOSED, then always close the - session and the token signer. - -**What this test teaches about the infra:** `ProxyManager.ensureProxy` + `SandboxApp` setup, -`connectThroughProxy`, **late imperative fault injection** via `triggerAction`, real-network waiting -with `pollUntil`, and **proxy-log assertions** as the primary verification (`getLog()` → -filter by `type`/`direction`/`message.action`). +### 11.2 Late imperative injection — `triggerAction` +The first test creates a **rule-less pass-through** session, authenticates through the proxy (basic key +auth is TLS-only, so a token is signed locally by an `AblyRest(app.defaultKey)` in the `authCallback`), +and connects: +```kotlin +val session = ProxySession.create(rules = emptyList()) +``` +Once CONNECTED, it proves the **typed proxy log** — the handshake recorded a `ws_connect` and a +server→client CONNECTED frame (protocol action 4): +```kotlin +val log = session.getLog() +assertTrue(log.any { it.type == "ws_connect" }) +assertTrue( + log.any { + it.type == "ws_frame" && + it.direction == "server_to_client" && + it.message?.get("action")?.asInt == 4 + }, +) +``` +Only *after* the real handshake does it inject the fault — the **late imperative** way, firing an +action on the live connection right now, then observing DISCONNECTED and recovery: +```kotlin +session.triggerAction(mapOf("type" to "disconnect")) +pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } +awaitState(client, ConnectionState.connected, 20.seconds) +``` + +### 11.3 Declarative-rule injection — `wsFrameToClientRule` +The second test uses the *other* style — a **declarative rule** supplied at session creation that +rewrites the first ATTACHED frame (protocol action 11) into a disconnect, one-shot (`times = 1`): +```kotlin +val session = ProxySession.create( + rules = listOf( + wsFrameToClientRule(action = mapOf("type" to "disconnect"), messageAction = 11, times = 1), + ), +) +``` +It connects, attaches a channel, the rule fires on the ATTACHED so the client observes a DISCONNECTED +transition, then recovers (reconnects and the channel re-attaches once the one-shot rule is spent): +```kotlin +channel.attach() +pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } +awaitState(client, ConnectionState.connected, 20.seconds) +awaitChannelState(channel, ChannelState.attached, 20.seconds) +``` +Rules evaluate for *every* matching frame (until `times` is exhausted), so a declarative rule is the +right tool when the fault must land on a frame the test can't easily await; the imperative +`triggerAction` is the right tool for a fault at a precise moment on an already-live connection. + +### 11.4 Teardown +Both tests tear down in a nested `finally`: close the client, then always `session.close()` (`DELETE +/sessions/{id}`) and the token signer. + +**What these tests teach about the infra:** `ProxyManager.ensureProxy` + `SandboxApp` setup, +`connectThroughProxy`, **both** fault-injection styles (declarative `wsFrameToClientRule` at creation +and late imperative `triggerAction`), real-network waiting with `pollUntil`, and **proxy-log +assertions** as the primary verification (`getLog()` → filter by `type`/`direction`/`message.action`). --- ## 12. Deviations: when the SDK disagrees with the spec -`uts/.../io/ably/lib/uts/deviations.md` is the single catalogue of every place the ably-java SDK behaves -differently from the features spec, discovered during translation. Each entry records: the **spec -point**, **what the spec requires**, **what the SDK does**, the **root cause** (file/function, where -known), the **workaround in tests**, and the **affected tests**. +Deviations live **with their tests**, not in `:uts` (whose smoke tests carry none by design). There are +two catalogues: + +- `lib/src/test/kotlin/io/ably/lib/uts/deviations.md` — the **realtime/rest** tiers, hosted in `:java`. +- `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md` — **all three objects tiers** + (unit, integration, proxy), hosted in `:liveobjects`. + +Each entry records the **spec point**, **what the spec requires**, **what the SDK does**, the **root +cause** (file/function, where known), the **workaround in tests**, and the **affected tests**. The mechanism (from [`writing-derived-tests.md`](https://github.com/ably/specification/blob/main/uts/docs/writing-derived-tests.md)): the test keeps the **spec-correct** assertion but gates it behind the `RUN_DEVIATIONS` env var, with a regression-guard assertion for the SDK's actual behaviour running by default. Normal runs stay green; `RUN_DEVIATIONS=1` turns the failing assertions -on so the gap is reproducible and the test flips automatically once the SDK is fixed. +on so the gap is reproducible and the test flips automatically once the SDK is fixed. In code (from +`:java`'s `ConnectionRecoveryTest`, RTN16f): +```kotlin +if (System.getenv("RUN_DEVIATIONS") != null) { + assertEquals(42L, currentRecoveryKey.msgSerial) // spec-correct: recover preserves msgSerial +} else { + assertEquals(0L, currentRecoveryKey.msgSerial) // regression guard: the SDK's actual behaviour +} +``` +Run it with `RUN_DEVIATIONS=1 ./gradlew :java:runUtsUnitTests --tests "*ConnectionRecoveryTest*"` (§13). -Current entries relevant to the walkthrough tests: +Representative entries from the realtime catalogue (`lib/.../deviations.md`): | Spec point | Gist | Touches | |------------|------|---------| -| **RTN16f** | SDK resets `msgSerial` to 0 on connect even with `recover`; spec says preserve it (42). | `ConnectionRecoveryTest` (§9.4) — `assertEquals(42L,…)` gated, `assertEquals(0L,…)` default guard. | -| **RTN16g2** | Spec's fatal error 50000/500 isn't fatal to the SDK (`isFatalError()` needs code 40000–49999 or status < 500); also `send_to_client_and_close` races the FAILED transition. | `ConnectionRecoveryTest` (§9.2) — uses 40000/400 + plain `sendToClient`. | -| **RTL13b** | `ATTACHING → SUSPENDED` via `realtimeRequestTimeout` not implemented for channel attach. | various channel tests (not the walkthroughs here). | -| **RTL13c** | `channelRetryTimeout` not cancelled when the connection leaves CONNECTED. | various channel tests; assertions gated behind `RUN_DEVIATIONS`. | +| **RTN16f** | SDK resets `msgSerial` to 0 on connect even with `recover`; spec says preserve it (42). | `ConnectionRecoveryTest` (`:java`) — `assertEquals(42L,…)` gated, `assertEquals(0L,…)` default guard. | +| **RTN16g2** | Spec's fatal error 50000/500 isn't fatal to the SDK (`isFatalError()` needs code 40000–49999 or status < 500); also `send_to_client_and_close` races the FAILED transition. | `ConnectionRecoveryTest` (`:java`) — uses 40000/400 + plain `sendToClient`. | +| **RTL13b / RTL13c** | Channel-state reattach / `channelRetryTimeout` gaps. Retained as confirmed SDK gaps; the citing channel tests aren't in the suite yet (only `ConnectionRecoveryTest` is translated). | pending the channels-module translation. | + +The objects catalogue additionally records the typed-SDK / language adaptations (RTTS API +partitioning, compile-time-forbidden inputs, internal-wire visibility) and the intentional RTO18d +listener-dedup divergence — see `liveobjects/.../deviations.md`. > These deviations are **valuable output**, not failures — each one is a precise, reproducible bug > report the SDK team can act on, and the gated test becomes the acceptance test for the fix. @@ -788,44 +903,56 @@ Current entries relevant to the walkthrough tests: ## 13. How to Run the Tests -There are two custom Gradle tasks (registered in `uts/build.gradle.kts`), filtered by package — they -mirror `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests` in the `liveobjects` module: +Each module registers package-filtered Gradle tasks. `:uts` registers `runUtsUnitTests` / +`runUtsIntegrationTests` (in `uts/build.gradle.kts`); `:java` registers the same-named tasks for its +realtime suites; `:liveobjects` registers `runLiveObjectsUnitTests` / `runLiveObjectsIntegrationTests`. + +| What | Command | +|---|---| +| `:uts` smoke (unit, offline) | `./gradlew :uts:runUtsUnitTests` | +| `:uts` smoke (integration + proxy) | `./gradlew :uts:runUtsIntegrationTests` | +| realtime UTS unit | `./gradlew :java:runUtsUnitTests` | +| realtime UTS integration + proxy | `./gradlew :java:runUtsIntegrationTests` | +| objects UTS unit | `./gradlew :liveobjects:runLiveObjectsUnitTests` | +| objects UTS integration + proxy | `./gradlew :liveobjects:runLiveObjectsIntegrationTests` | + +Each `runUts*` / `runLiveObjects*` task is package-filtered (`io.ably.lib.uts.unit.*` / +`io.ably.lib.uts.integration.*` for `:uts` and `:java`; `io.ably.lib.liveobjects.uts.*` for +`:liveobjects`). The `…IntegrationTests` tasks cover **both** the direct-sandbox +(`integration/standard/`) and proxy (`integration/proxy/`) tiers — proxy tests additionally +download/launch the uts-proxy. ```bash -# Unit tests only — io.ably.lib.uts.unit.* (fast, no network). This is the PR gate. -./gradlew :uts:runUtsUnitTests - -# Integration tests only — io.ably.lib.uts.integration.* (real sandbox; covers both -# integration/standard/ and integration/proxy/ — proxy tests also download/launch the uts-proxy). -./gradlew :uts:runUtsIntegrationTests - -# Everything (the default Test task still runs both): +# All :uts smoke tests (every tier), or one class: ./gradlew :uts:test +./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.UnitInfraSmokeTest" +./gradlew :java:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime.AuthReauthTest" -# The objects UNIT tier lives in the :liveobjects module (it asserts on internal CRDT state) — run it -# there, not via :uts. See §4.2 "Cross-module exception". -./gradlew :liveobjects:runLiveObjectsUnitTests --tests "io.ably.lib.liveobjects.uts.unit.*" - -# Just one test class (works with any of the tasks above): -./gradlew :uts:runUtsUnitTests --tests "io.ably.lib.uts.unit.realtime.ConnectionRecoveryTest" -./gradlew :uts:runUtsIntegrationTests --tests "io.ably.lib.uts.integration.proxy.realtime.AuthReauthTest" - -# Turn on the spec-correct (currently failing) deviation assertions: -RUN_DEVIATIONS=1 ./gradlew :uts:runUtsUnitTests --tests "*ConnectionRecoveryTest*" +# Turn on the spec-correct (currently failing) deviation assertions (§12): +RUN_DEVIATIONS=1 ./gradlew :java:runUtsUnitTests --tests "*ConnectionRecoveryTest*" # Run proxy tests against a locally built proxy instead of a GitHub release: ./gradlew :uts:runUtsIntegrationTests -Duts.proxy.localPath=/path/to/uts-proxy # or .tar.gz # (equivalently: export UTS_PROXY_LOCAL_PATH=/path/to/uts-proxy) ``` -**Where CI runs them:** `runUtsUnitTests` is part of the `check.yml` gate (alongside -`runLiveObjectsUnitTests`); `runUtsIntegrationTests` runs in the `check-uts` job of -`integration-test.yml` (alongside `check-liveobjects`). +> **Unqualified task names collide — intentionally.** `./gradlew runUtsUnitTests` (no module prefix) +> runs the task in **both** `:uts` and `:java` (Gradle matches the task name across every project that +> declares it). That's intended and harmless — both unit tiers are offline — and mirrors the existing +> `runUnitTests` precedent (registered in both `:java` and `:pubsub-adapter`). Qualify with `:uts:` / +> `:java:` to target one. The `RUN_DEVIATIONS`, `-Duts.proxy.localPath` and `UTS_PROXY_LOCAL_PATH` +> knobs are valid for **all three** modules. + +**Where CI runs them:** `check.yml` runs +`… runUnitTests runLiveObjectsUnitTests :java:runUtsUnitTests :uts:runUtsUnitTests` as the PR gate +(all offline unit tiers); `integration-test.yml`'s `check-uts` job runs +`:java:runUtsIntegrationTests :uts:runUtsIntegrationTests`, and its `check-liveobjects` job runs +`runLiveObjectsIntegrationTests`. Notes: - `ProxyManager` **advises** running proxy suites single-fork (`maxParallelForks = 1`) because they share the control port (10100). This is not currently set in `uts/build.gradle.kts`; it isn't - exercised yet because there is only one proxy test class. + exercised yet because there is only one proxy smoke class. - Proxy/sandbox tests need outbound network (sandbox + GitHub releases on first run; the binary is then cached under `~/.cache/uts-proxy/`). - Before pushing, run the project's static-analysis gate (from `CLAUDE.md`): @@ -949,8 +1076,8 @@ sees the control plane; the test never speaks the data plane directly. ## 16. Appendix B: Per-File API Reference -A one-stop table of every Kotlin source file under `uts/src/test/` (tests) and -`uts/src/testFixtures/` (the `infra/` tree) and the SDK seams they use, so nothing is left +A one-stop table of every Kotlin source file under `uts/src/main/` (the `infra/` tree) and +`uts/src/test/` (the three tier smoke tests) and the SDK seams they use, so nothing is left implicit. ### B.1 Unit-test infrastructure — `io.ably.lib.uts.infra.unit` @@ -983,17 +1110,18 @@ implicit. | File | Key public surface | Role | |------|--------------------|------| | `infra/Utils.kt` | `awaitState(client,target,timeout=5s)`, `awaitChannelState(channel,target,timeout=5s)`, `pollUntil(timeout=15s,interval=100ms){ }` | Shared wall-clock coroutine waits (package `io.ably.lib.uts.infra`); listener registered before state check. | -| `unit/realtime/ConnectionRecoveryTest.kt` | 6 `@Test`s: RTN16g/g1, RTN16g2, RTN16k, RTN16f, RTN16f1, RTN16j | Unit tier (`io.ably.lib.uts.unit.realtime`) — connection recovery (mocked WS, FakeClock, env-gated deviations). | -| `integration/standard/realtime/ChannelHistoryTest.kt` | 1 `@ParameterizedTest` (RTL10d) × {JSON, msgpack} | Direct-sandbox tier (`io.ably.lib.uts.integration.standard.realtime`) — cross-client history durability (`SandboxApp`, no proxy; awaited publish + `pollUntil` on `history()`). | -| `integration/proxy/realtime/AuthReauthTest.kt` | 1 `@Test` (two `@UTS`: RTN22, RTC8a) | Integration tier (`io.ably.lib.uts.integration.proxy.realtime`) — server-initiated re-authentication. | -| `deviations.md` | RTN16f, RTN16g2, RTL13b, RTL13c | Catalogue of SDK-vs-spec divergences. | - -> **Coverage note:** this guide walks through **one reference test per tier** — `ConnectionRecoveryTest` -> (unit, §9), `ChannelHistoryTest` (direct-sandbox, §10), and `AuthReauthTest` (proxy, §11). The `uts/` -> module additionally carries LiveObjects tests across the same tiers, and the infrastructure under -> `infra/unit/` and `infra/integration/` is built out beyond what any single test exercises (full HTTP -> mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader UTS coverage -> catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md). +| `unit/UnitInfraSmokeTest.kt` | 2 `@Test`s: full mock-WS lifecycle (await style), token-auth via mock HTTP (callback WS) | Unit-tier infra acceptance (`io.ably.lib.uts.unit`) — MockWebSocket/MockHttpClient/FakeClock end-to-end. **No** `@UTS`. | +| `integration/standard/IntegrationInfraSmokeTest.kt` | 1 `@ParameterizedTest` × {JSON, msgpack} | Direct-sandbox infra acceptance (`io.ably.lib.uts.integration.standard`) — SandboxApp + realtime/REST round-trip, awaited publish + `pollUntil` on `history()`. **No** `@UTS`. | +| `integration/proxy/ProxyInfraSmokeTest.kt` | 2 `@Test`s: late imperative disconnect, declarative ws-frame rule | Proxy infra acceptance (`io.ably.lib.uts.integration.proxy`) — ProxyManager + ProxySession, both fault-injection styles, proxy-log asserts. **No** `@UTS`. | + +> **Coverage note:** this guide walks through the **three tier smoke tests** — `UnitInfraSmokeTest` +> (unit, §9), `IntegrationInfraSmokeTest` (direct-sandbox, §10), and `ProxyInfraSmokeTest` (proxy, §11) +> — the permanent acceptance gate for the shared infra and the worked examples the walkthroughs teach +> from. The infra under `infra/unit/` and `infra/integration/` is built out beyond what the smokes +> exercise (full HTTP mock, all four rule builders, REST proxy wiring, etc.), anticipating the broader +> UTS coverage catalogued in [`completion-status.md`](https://github.com/ably/specification/blob/main/uts/docs/completion-status.md). The real spec-derived suites that consume this +> infra live in `:java` (`lib/src/test/kotlin/io/ably/lib/uts/…`) and `:liveobjects` +> (`liveobjects/.../uts/…`) — see §13. --- @@ -1008,8 +1136,8 @@ implicit. | Proxy control API, rule format, action numbers | [`uts/docs/proxy.md`](https://github.com/ably/specification/blob/main/uts/docs/proxy.md) | | SDK seams | `lib/.../debug/DebugOptions.java`, `lib/.../util/Clock.java` | | Module wiring | `uts/build.gradle.kts`, `settings.gradle.kts` | -| Unit mocks | `uts/src/testFixtures/.../uts/infra/unit/*` | -| Integration helpers | `uts/src/testFixtures/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) | -| Async helpers | `uts/src/testFixtures/.../uts/infra/Utils.kt` (awaits), `…/infra/unit/Utils.kt` (ConnectionDetails builder) | -| The three example tests | `…/uts/unit/realtime/ConnectionRecoveryTest.kt`, `…/uts/integration/standard/realtime/ChannelHistoryTest.kt`, `…/uts/integration/proxy/realtime/AuthReauthTest.kt` | -| Deviations | `uts/.../io/ably/lib/uts/deviations.md` | +| Unit mocks | `uts/src/main/.../uts/infra/unit/*` | +| Integration helpers | `uts/src/main/.../uts/infra/integration/*` (+ `…/integration/proxy/*`) | +| Async helpers | `uts/src/main/.../uts/infra/Utils.kt` (awaits), `…/infra/unit/Utils.kt` (ConnectionDetails builder) | +| The three tier smoke tests | `uts/src/test/.../uts/unit/UnitInfraSmokeTest.kt`, `…/uts/integration/standard/IntegrationInfraSmokeTest.kt`, `…/uts/integration/proxy/ProxyInfraSmokeTest.kt` | +| Deviations | `lib/src/test/kotlin/io/ably/lib/uts/deviations.md` (realtime/rest, `:java`), `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md` (objects, `:liveobjects`) | diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 21a49fc57..5512c00e1 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -1,35 +1,48 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat plugins { - alias(libs.plugins.kotlin.jvm) - `java-test-fixtures` + `java-library` // NEW — provides the `api` configuration. Previously + // arrived transitively via `java-test-fixtures`; + // kotlin.jvm alone applies only the plain `java` plugin. + alias(libs.plugins.kotlin.jvm) // `java-test-fixtures` REMOVED +} + +java { + // Declare Java-8 outgoing variants (org.gradle.jvm.version=8) so :java's 8-requesting resolvable + // configurations can consume project(":uts"). Only possible now that Phase 3 removed the + // :liveobjects (Java-21) test edge that previously forced this module's classpaths back to 21. + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } dependencies { - // Shared UTS test infra (src/testFixtures/kotlin/io/ably/lib/uts/infra/**) — consumed by this - // module's tests automatically and by other modules via testFixtures(project(":uts")). - // INVARIANT: no testFixtures* configuration may ever depend on :liveobjects — that keeps - // ":liveobjects test -> :uts testFixtures -> :java" acyclic against the testRuntimeOnly below. - // `api` for types that appear in fixture signatures; `implementation` for internals. - testFixturesApi(project(":java")) - testFixturesApi(project(":network-client-core")) - testFixturesImplementation(libs.coroutine.core) - testFixturesImplementation(libs.ktor.client.core) - testFixturesImplementation(libs.ktor.client.cio) + // The shared UTS test infra (src/main/kotlin/io/ably/lib/uts/infra/**) — this module's main + // artifact. Consumed by other modules via testImplementation(project(":uts")). + // INVARIANT (I1): :uts main (and its test config) has no :liveobjects dependency at all — the + // objects tiers that needed the runtime plugin moved to :liveobjects. + // `api` for types that appear in infra signatures; `implementation` for internals. + api(project(":java")) + api(project(":network-client-core")) + // ktor stays implementation — the proxy infra uses it internally; it must NOT leak to consumers. + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + + // The UTS test toolkit — exported (api) so any module consuming this infra via + // testImplementation(project(":uts")) transitively gets the full stack needed to + // WRITE and RUN UTS tests: JUnit 5 (api+params+engine via the aggregator), the + // kotlin.test Jupiter binding, and coroutines (runTest etc.). Consumers declare + // only the project dependency. + api(platform(libs.junit.bom)) + api(libs.junit.jupiter) + api(libs.junit.jupiter.params) + api(kotlin("test-junit5")) + api(libs.coroutine.core) // promote from implementation (suspend-heavy public API anyway) + api(libs.coroutine.test) - testImplementation(project(":java")) - testImplementation(project(":network-client-core")) - // Runtime-only so compile-time stays decoupled from the plugin internals; the objects - // integration/proxy tests need the LiveObjects plugin on the runtime classpath. - testRuntimeOnly(project(":liveobjects")) - testImplementation(kotlin("test")) - // @ParameterizedTest / @ValueSource — version managed by the junit-bom on the test classpath. - testImplementation("org.junit.jupiter:junit-jupiter-params") - testImplementation(libs.mockk) - testImplementation(libs.coroutine.core) - testImplementation(libs.coroutine.test) - testImplementation(libs.ktor.client.core) - testImplementation(libs.ktor.client.cio) + // :uts's own smoke tests inherit the whole toolkit transitively from main's `api` above + // (a module's test classpath sees its own main api/implementation deps) — nothing to declare. + // Verified: the three smoke tests import only kotlin.test, JUnit Jupiter, coroutines and the + // infra itself; they use ktor only through the infra, never directly. } tasks.withType().configureEach { @@ -64,3 +77,7 @@ tasks.register("runUtsIntegrationTests") { includeTestsMatching("io.ably.lib.uts.integration.*") } } + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) } +} diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/Utils.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/Utils.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/Utils.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/Utils.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/integration/SandboxApp.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxyManager.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/integration/proxy/ProxySession.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/ClientFactories.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingConnection.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/DefaultPendingRequest.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/FakeClock.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockEvent.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpClient.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockHttpEngine.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocket.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/MockWebSocketEngineFactory.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/PendingConnection.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/PendingRequest.kt diff --git a/uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/Utils.kt b/uts/src/main/kotlin/io/ably/lib/uts/infra/unit/Utils.kt similarity index 100% rename from uts/src/testFixtures/kotlin/io/ably/lib/uts/infra/unit/Utils.kt rename to uts/src/main/kotlin/io/ably/lib/uts/infra/unit/Utils.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt new file mode 100644 index 000000000..ccb0aef82 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/ProxyInfraSmokeTest.kt @@ -0,0 +1,171 @@ +package io.ably.lib.uts.integration.proxy + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.rest.AblyRest +import io.ably.lib.rest.Auth +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.integration.proxy.ProxyManager +import io.ably.lib.uts.infra.integration.proxy.ProxySession +import io.ably.lib.uts.infra.integration.proxy.connectThroughProxy +import io.ably.lib.uts.infra.integration.proxy.wsFrameToClientRule +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.Collections +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Acceptance test for the **full proxy integration infrastructure itself** ([ProxyManager], + * [ProxySession], [SandboxApp] + client wiring through the proxy) — NOT derived from a UTS spec, so + * it carries no `@UTS` marker. It is the permanent teaching example for `uts/README.md` §11 and the + * reference shape a future proxy-tier UTS test should take. + * + * It proves the full chain end-to-end: binary download/launch → sandbox provisioning → a real client + * connecting through the proxy with token auth → typed proxy-log assertions → both fault-injection + * styles (a declarative `ws_frame` rule and a late imperative `triggerAction`) → recovery → teardown. + * + * Needs outbound network (GitHub releases on first run, then the Ably sandbox) and spawns the local + * `uts-proxy` process. Run with: + * ``` + * ./gradlew :uts:runUtsIntegrationTests + * ``` + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProxyInfraSmokeTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + ProxyManager.ensureProxy() + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * Pass-through session: proves the happy path and the typed proxy log, then injects a fault the + * *late imperative* way — [ProxySession.triggerAction] on the live connection. + */ + @Test + fun `proxy pass-through proves the log and a late imperative disconnect`() = runTest { + // Assert provisioning (cocoa parity). + assertTrue(app.defaultKey.startsWith(app.appId + ".")) + + val session = ProxySession.create(rules = emptyList()) + assertTrue(session.proxyPort > 0) + + val tokenSigner = AblyRest(app.defaultKey) + val authCallbackCount = AtomicInteger(0) + val client = TestRealtimeClient { + // Basic key auth is TLS-only, so authenticate through the proxy with a locally-signed + // TokenRequest (README §11 teaching point). + authCallback = Auth.TokenCallback { params -> + authCallbackCount.incrementAndGet() + tokenSigner.auth.createTokenRequest(params, null) + } + connectThroughProxy(session) + autoConnect = false + } + + try { + client.connect() + awaitState(client, ConnectionState.connected, 20.seconds) + assertNotNull(client.connection.id) + assertTrue(authCallbackCount.get() >= 1) + + // Typed proxy-log assertions: the handshake recorded a ws_connect and a server→client + // CONNECTED frame (protocol action 4). + val log = session.getLog() + assertTrue(log.any { it.type == "ws_connect" }) + assertTrue( + log.any { + it.type == "ws_frame" && + it.direction == "server_to_client" && + it.message?.get("action")?.asInt == 4 + }, + ) + + // Late imperative fault: disconnect the live connection, observe DISCONNECTED, recover. + val states = Collections.synchronizedList(mutableListOf()) + client.connection.on { states.add(it.current) } + session.triggerAction(mapOf("type" to "disconnect")) + pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } + awaitState(client, ConnectionState.connected, 20.seconds) + + // The proxy recorded the imperative action. + assertTrue(session.getLog().any { it.type == "action" || it.ruleMatched != null }) + } finally { + try { + client.close() + } finally { + session.close() + runCatching { tokenSigner.close() } + } + } + } + + /** + * Declarative-rule session: a `ws_frame_to_client` rule replaces the first ATTACHED (action 11) + * with a disconnect, so the client observes a DISCONNECTED transition when the rule fires, then + * recovers (reconnects and the channel re-attaches once the one-shot rule is spent). + */ + @Test + fun `proxy declarative rule injects a disconnect on ATTACHED then recovers`() = runTest { + val session = ProxySession.create( + rules = listOf( + wsFrameToClientRule(action = mapOf("type" to "disconnect"), messageAction = 11, times = 1), + ), + ) + + val tokenSigner = AblyRest(app.defaultKey) + val client = TestRealtimeClient { + authCallback = Auth.TokenCallback { params -> + tokenSigner.auth.createTokenRequest(params, null) + } + connectThroughProxy(session) + autoConnect = false + } + + try { + val states = Collections.synchronizedList(mutableListOf()) + client.connection.on { states.add(it.current) } + + client.connect() + awaitState(client, ConnectionState.connected, 20.seconds) + + val channel = client.channels.get("smoke-proxy-rule-${UUID.randomUUID()}") + channel.attach() + + // The rule fires on the ATTACHED frame and disconnects the transport. + pollUntil(20.seconds) { states.contains(ConnectionState.disconnected) } + + // Recovery: the connection re-establishes and the channel re-attaches (rule is spent). + awaitState(client, ConnectionState.connected, 20.seconds) + awaitChannelState(channel, ChannelState.attached, 20.seconds) + } finally { + try { + client.close() + } finally { + session.close() + runCatching { tokenSigner.close() } + } + } + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt new file mode 100644 index 000000000..166d913c3 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/IntegrationInfraSmokeTest.kt @@ -0,0 +1,157 @@ +package io.ably.lib.uts.integration.standard + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.rest.AblyRest +import io.ably.lib.types.AblyException +import io.ably.lib.types.Callback +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Message +import io.ably.lib.types.PaginatedResult +import io.ably.lib.types.PublishResult +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import io.ably.lib.uts.infra.unit.TestRestClient +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.Collections +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Acceptance test for the **direct-sandbox integration infrastructure itself** ([SandboxApp] + + * [TestRealtimeClient] / [TestRestClient] wired straight to the sandbox) — this is + * NOT derived from a UTS spec, so it carries no `@UTS` marker. It is the permanent teaching example + * for `uts/README.md` §10 and the reference shape a future integration-tier UTS test should take. + * + * It proves the middle tier end-to-end: sandbox provisioning → a real realtime client connecting + * straight to the sandbox over TLS (basic key auth, RSA1) → an attach/subscribe/publish round-trip + * → a REST `history()` read of the same messages → teardown. + * + * Runs once per protocol variant (the UTS `PROTOCOL` dimension): `false` = JSON, `true` = msgpack. + * + * Needs outbound network (the Ably sandbox). Run with: + * ``` + * ./gradlew :uts:runUtsIntegrationTests + * ``` + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class IntegrationInfraSmokeTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `sandbox infra works end to end`(useBinaryProtocol: Boolean) = runTest { + // Assert provisioning (cocoa parity). + assertTrue(app.defaultKey.startsWith(app.appId + ".")) + assertTrue(app.keys.isNotEmpty()) + + val channelName = "smoke-int-${UUID.randomUUID()}" + val client = newRealtimeClient(useBinaryProtocol) + val rest = newRestClient(useBinaryProtocol) + try { + // State-transition sequence, not just the final state: collect before connect. + val states = Collections.synchronizedList(mutableListOf()) + client.connection.on { states.add(it.current) } + + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + assertTrue(states.contains(ConnectionState.connecting)) + assertEquals(ConnectionState.connected, states.last()) + + val channel = client.channels.get(channelName) + channel.attach() + awaitChannelState(channel, ChannelState.attached, 15.seconds) + + val received = Collections.synchronizedList(mutableListOf()) + channel.subscribe { received.add(it) } + + // Three publishes, each ack-awaited. + channel.awaitPublish("event1", "data1") + channel.awaitPublish("event2", "data2") + channel.awaitPublish("event3", "data3") + + pollUntil(15.seconds) { received.size == 3 } + assertEquals(listOf("event1", "event2", "event3"), received.map { it.name }) + assertEquals(listOf("data1", "data2", "data3"), received.map { it.data }) + + // REST half of the infra: read history until all three appear, assert newest-first. + var history: PaginatedResult? = null + pollUntil(15.seconds, 500.milliseconds) { + val result = rest.channels.get(channelName).history(null) + history = result + result.items().size == 3 + } + val items = history!!.items() + assertEquals(3, items.size) + assertEquals("event3", items[0].name) + assertEquals("data3", items[0].data) + assertEquals("event2", items[1].name) + assertEquals("data2", items[1].data) + assertEquals("event1", items[2].name) + assertEquals("data1", items[2].data) + } finally { + client.close() + rest.close() + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** A realtime client wired straight to the nonprod sandbox (no proxy). */ + private fun newRealtimeClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + realtimeHost = SandboxApp.sandboxHost + restHost = SandboxApp.sandboxHost + this.useBinaryProtocol = useBinaryProtocol + autoConnect = false + } + + /** A REST client wired straight to the nonprod sandbox (the REST half of the infra). */ + private fun newRestClient(useBinaryProtocol: Boolean): AblyRest = TestRestClient { + key = app.defaultKey + restHost = SandboxApp.sandboxHost + this.useBinaryProtocol = useBinaryProtocol + } + + /** Publishes a message and suspends until the server confirms delivery (or errors). */ + private suspend fun Channel.awaitPublish(name: String, data: Any?): PublishResult = + suspendCancellableCoroutine { cont -> + publish(name, data, object : Callback { + override fun onSuccess(result: PublishResult) { + if (cont.isActive) cont.resume(result) + } + + override fun onError(reason: ErrorInfo) { + if (cont.isActive) cont.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt new file mode 100644 index 000000000..6c795626f --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/UnitInfraSmokeTest.kt @@ -0,0 +1,206 @@ +package io.ably.lib.uts.unit + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.unit.CONNECTED_MESSAGE +import io.ably.lib.uts.infra.unit.ConnectionDetails +import io.ably.lib.uts.infra.unit.FakeClock +import io.ably.lib.uts.infra.unit.MockEvent +import io.ably.lib.uts.infra.unit.MockHttpClient +import io.ably.lib.uts.infra.unit.MockWebSocket +import io.ably.lib.uts.infra.unit.PendingConnection +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Acceptance test for the **unit-tier mock-transport infrastructure itself** ([MockWebSocket], + * [MockHttpClient], [FakeClock], [TestRealtimeClient]) — NOT derived from a UTS spec, so it carries + * no `@UTS` marker and must never trip the spec-parity tooling. It is the permanent teaching example + * for `uts/README.md` §9 and the reference shape a future unit-tier UTS test should take. + * + * It proves the mock chain end-to-end: driving a real SDK through a fake WebSocket + * (connect → attach → publish → disconnect → FakeClock-driven reconnect → refuse → SUSPENDED) and a + * fake HTTP engine (token auth over `authUrl`). + * + * Hermetic — no network. Run with: + * ``` + * ./gradlew :uts:runUtsUnitTests + * ``` + */ +class UnitInfraSmokeTest { + + /** + * Full transport lifecycle over the fake WebSocket. Uses the **await style** + * ([MockWebSocket.awaitConnectionAttempt]) throughout: the initial connect, the FakeClock-driven + * reconnect and the refuse→SUSPENDED branch all need per-attempt control, which the callback + * style cannot provide (a single `onConnectionAttempt` handler answers every attempt uniformly, + * and the two styles cannot be mixed on one mock). The callback style is demonstrated in the + * HTTP-auth test below. + */ + @Test + fun `unit infra drives the full mock-WebSocket connection lifecycle`() = runTest { + val fakeClock = FakeClock() + // Unicode channel name for the round-trip (out on the ATTACH frame, in on ATTACHED). + val channelName = "smoke-üñîçöðé-${UUID.randomUUID()}" + val mock = MockWebSocket() + val client = TestRealtimeClient { + autoConnect = false + disconnectedRetryTimeout = 300 + fallbackHosts = emptyArray() + install(mock) + enableFakeTimers(fakeClock) + } + + try { + // 2. Connect (await style). Capture the PendingConnection so we can assert query params. + val firstConnection = CompletableDeferred() + launch { + val conn = mock.awaitConnectionAttempt() + firstConnection.complete(conn) + conn.respondWithSuccess(CONNECTED_MESSAGE) + } + client.connect() + awaitState(client, ConnectionState.connected) + + // Query params on the captured connection (README §9.3's technique). + val conn = firstConnection.await() + assertEquals("json", conn.queryParams["format"]) + assertNotNull(conn.queryParams["key"]) + // ConnectionDetails fixture round-tripped: CONNECTED_MESSAGE's id reached the client. + assertEquals("test-connection-id", client.connection.id) + // Event ordering: attempt, then established. + assertTrue(mock.events[0] is MockEvent.ConnectionAttempt) + assertTrue(mock.events[1] is MockEvent.ConnectionEstablished) + + // 3. Server-initiated ATTACHED, with the outbound ATTACH frame asserted (Unicode out). + val ch = client.channels.get(channelName) + ch.attach() + val attachFrame = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.attach, attachFrame.action) + assertEquals(channelName, attachFrame.channel) + mock.sendToClient(ProtocolMessage().apply { + action = ProtocolMessage.Action.attached + channel = channelName + channelSerial = "serial-1" + }) + awaitChannelState(ch, ChannelState.attached) + // channelSerial round-tripped in. + assertEquals("serial-1", ch.properties.channelSerial) + + // 4. Publish — assert the full MESSAGE protocol frame, not just its arrival. + ch.publish("event", "payload") + val publishFrame = mock.awaitNextMessageFromClient() + assertEquals(ProtocolMessage.Action.message, publishFrame.action) + assertEquals(channelName, publishFrame.channel) + assertEquals("event", publishFrame.messages[0].name) + assertEquals("payload", publishFrame.messages[0].data) + + // 5. Disconnect + negative check: no reconnect until the clock advances. Event count is + // the honest probe (the retry blocks in FakeClock.waitOn, not a named timer). + mock.simulateDisconnect() + awaitState(client, ConnectionState.disconnected) + assertEquals(1, mock.events.filterIsInstance().size) + + // 6. FakeClock-driven reconnect: the advance demonstrably drives the transition. Respond + // with a short-TTL CONNECTED so the SUSPENDED branch below suspends quickly. + val reconnectJob = launch { + repeat(20) { + fakeClock.advance(2.seconds) + mock.awaitConnectionAttempt().respondWithSuccess(shortLivedConnected()) + return@launch + } + } + awaitState(client, ConnectionState.connected) + reconnectJob.join() + assertEquals(2, mock.events.filterIsInstance().size) + + // 7. Await-style refuse branch → SUSPENDED (README §9.2's centerpiece). + mock.simulateDisconnect() + val refuseJob = launch { + repeat(20) { + fakeClock.advance(2.seconds) + mock.awaitConnectionAttempt().respondWithRefused() + if (client.connection.state == ConnectionState.suspended) return@launch + } + } + awaitState(client, ConnectionState.suspended) + refuseJob.cancel() + assertNull(client.connection.createRecoveryKey()) + } finally { + // 8. Teardown always runs. + client.close() + } + } + + /** + * The fake HTTP engine exercised for real via token auth. Uses the **callback style** + * ([io.ably.lib.uts.infra.unit.WebSocketMockConfig.onConnectionAttempt]) for the WebSocket, and + * the **await style** ([MockHttpClient.awaitRequest]) for the auth HTTP request. + * + * Trap: [MockEvent.HttpRequest] is declared but never emitted by the HTTP mock — asserting on + * `events.filterIsInstance()` would silently pass on an empty list. We + * assert via [MockHttpClient.awaitRequest] / the [io.ably.lib.uts.infra.unit.PendingRequest] + * instead. + */ + @Test + fun `unit infra serves a token-auth HTTP request through the mock engine`() = runTest { + val now = System.currentTimeMillis() + // A TokenDetails JSON (the "issued" field makes the SDK treat it as TokenDetails, so no + // second HTTP round-trip to exchange a TokenRequest is needed). + val tokenJson = + """{"token":"fake-token-abc","keyName":"appId.keyId","issued":$now,""" + + """"expires":${now + 3_600_000L},"capability":"{\"*\":[\"*\"]}"}""" + + val mockWs = MockWebSocket { onConnectionAttempt = { it.respondWithSuccess(CONNECTED_MESSAGE) } } + // Callback answers the HTTP TCP connect; the request itself is handled await-style below. + val mockHttp = MockHttpClient { onConnectionAttempt = { it.respondWithSuccess() } } + val client = TestRealtimeClient { + authUrl = "https://auth.example.test/token" + install(mockWs) + install(mockHttp) + autoConnect = false + } + + try { + val captured = CompletableDeferred>() + launch { + val request = mockHttp.awaitRequest() + captured.complete(request.method to request.url.path) + request.respondWith(200, tokenJson, mapOf("Content-Type" to "application/json")) + } + + client.connect() + awaitState(client, ConnectionState.connected) + + val (method, path) = captured.await() + assertEquals("GET", method) + assertEquals("/token", path) + assertEquals(ConnectionState.connected, client.connection.state) + } finally { + client.close() + } + } + + /** A CONNECTED with a short connectionStateTtl so a subsequent disconnect suspends quickly. */ + private fun shortLivedConnected(): ProtocolMessage = ProtocolMessage().apply { + action = ProtocolMessage.Action.connected + connectionId = "reconnected-id" + connectionDetails = ConnectionDetails { + connectionKey = "reconnected-key" + connectionStateTtl = 800L + maxIdleInterval = 15_000L + } + } +}