Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a77f75a
Add UTS test specs for LiveObjects path-based API (~330 tests)
paddybyers May 13, 2026
3de4a4f
Delegate proxy port assignment to uts-proxy in all test specs
paddybyers May 14, 2026
2fba05e
Update UTS test specs to match LiveObjects path-based API spec (a397e34)
paddybyers May 27, 2026
86e9636
UTS: add missing assertions
paddybyers Jun 5, 2026
e57d340
UTS: correct sandbox endpoint domain name
paddybyers Jun 5, 2026
add9398
UTS: remove spurious Map.clear() test
paddybyers Jun 5, 2026
3ad5a41
UTS: remove unnecessary test
paddybyers Jun 5, 2026
e77bdb8
UTS: delete integration GC test — duplicates unit tests
paddybyers Jun 5, 2026
eaa6983
UTS: add Protocol Variants to LiveObjects integration tests
paddybyers Jun 5, 2026
99421b7
UTS: fix root.increment() → root.get("score").increment()
paddybyers Jun 5, 2026
8f52447
UTS: fix InstanceSubscriptionEvent assertions in live_object_subscribe
paddybyers Jun 5, 2026
0b15af8
UTS: move appliedOnAckSerials/bufferedObjectOperations to RealtimeObject
paddybyers Jun 5, 2026
f7ace4c
UTS: fix RTPO19b subscribe-on-detached test
paddybyers Jun 5, 2026
d7d75e7
UTS: rewrite RTO20f to use observable behaviour
paddybyers Jun 5, 2026
e3614cd
UTS: rewrite RTO5c9 re-sync test to use observable behaviour
paddybyers Jun 5, 2026
6bcf593
UTS: add OBJECT, OBJECT_SYNC, ANNOTATION to proxy action numbers table
paddybyers Jun 5, 2026
051bb95
fix(uts/objects): align REST fixture provisioning with V2 objects API
sacOO7 Jun 26, 2026
9883347
fix(uts): align provision_objects_via_rest host with the nonprod sandbox
sacOO7 Jun 26, 2026
356b57f
fix(uts): route objects integration clients to the nonprod sandbox
sacOO7 Jun 26, 2026
4eb5e0d
Removed non-required `objects_batch_spec.md` from the liveobjects uts…
sacOO7 Jun 26, 2026
e10eefb
Added missing endpoint and autoConnect to objects_faults spec
sacOO7 Jun 26, 2026
61f16f8
Merge pull request #497 from ably/fix/uts-liveobjects-rest-provisioning
sacOO7 Jun 29, 2026
36db239
fix(uts): correct objects proxy specs and harden proxy rule/timeout g…
sacOO7 Jul 4, 2026
ffcd875
docs(uts): move proxy infrastructure spec to uts/docs/proxy.md
sacOO7 Jul 4, 2026
61b5afd
Added missing objects_gc_test.md for garbage collecting tombstoned ob…
sacOO7 Jul 6, 2026
f64479c
Fixed relative paths pointing to `proxy.md`
sacOO7 Jul 8, 2026
a1ac5dd
chore(uts): reconcile LiveObjects (objects) test specs with main spec
sacOO7 Jun 30, 2026
e0fd856
fix(uts): sort-safe ack_serial and serial-ordering fixes for objects …
sacOO7 Jul 7, 2026
7fd3e17
fix(uts/objects): fix double-count & stale-serial fixtures, add seria…
sacOO7 Jul 9, 2026
f894640
fix(uts/objects): wire-valid createOps via builder auto-fill; accurat…
sacOO7 Jul 10, 2026
0a531c7
fix(uts/objects): review fixes + naming/behavior audit corrections
sacOO7 Jul 10, 2026
c8ab1b5
fix(uts/objects): synchronize on outbound publish before injecting AC…
sacOO7 Jul 13, 2026
2c30ccd
Deprecate RTPO8d: PathObject#instance now wraps primitives (RTPO8f)
sacOO7 Jul 10, 2026
701a51f
RTPO8f: enumerate primitive types, reorder before failure clause RTPO8e
sacOO7 Jul 16, 2026
2a9af17
fix(uts/objects): spec-side corrections from the LiveObjects deviatio…
sacOO7 Jul 16, 2026
c0a7779
fix(uts/objects): strengthen RTO15 shape assertions and RTO20e1 wait …
sacOO7 Jul 16, 2026
204f647
fix(uts/objects): assert map semantics as the symbolic enum value LWW
sacOO7 Jul 19, 2026
e1e22d4
fix(uts/realtime): make the bulk-enter presence test resilient to re-…
sacOO7 Jul 19, 2026
b9163fc
fix(uts): define polling/flush conventions and remove nondeterminism …
sacOO7 Jul 21, 2026
d0f33fb
docs(uts): rename flush_async to process_pending_events and refine ti…
sacOO7 Jul 21, 2026
e21ffca
Merge remote-tracking branch 'origin/main' into uts-liveobjects
sacOO7 Jul 21, 2026
9256593
Fixed spec inconsistency for RTPO8c
sacOO7 Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion specifications/objects-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,8 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re
- `(RTPO8a)` Checks the access API preconditions per [RTO25](#RTO25)
- `(RTPO8b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3))
- `(RTPO8c)` If the resolved value is a `LiveObject` (i.e. an `InternalLiveMap` or `InternalLiveCounter`), returns a new `Instance` ([RTINS1](#RTINS1)) wrapping that `LiveObject`
- `(RTPO8d)` If the resolved value is a primitive, returns undefined/null
- `(RTPO8d)` This clause has been replaced by [RTPO8f](#RTPO8f).
- `(RTPO8f)` If the resolved value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns a new `Instance` ([RTINS1](#RTINS1)) wrapping the primitive value
- `(RTPO8e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1)
- `(RTPO9)` `PathObject#entries` function:
- `(RTPO9a)` Checks the access API preconditions per [RTO25](#RTO25)
Expand Down
42 changes: 39 additions & 3 deletions uts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ uts/
│ │ ├── connection/ # RTN — connection management
│ │ └── presence/ # RTP — realtime presence
│ └── integration/ # Realtime integration tests
│ ├── helpers/
│ │ └── proxy.md # Proxy infrastructure spec
│ ├── proxy/ # Proxy-based fault injection tests
│ └── *.md # Direct sandbox tests
├── docs/ # Guides and reference
│ ├── writing-test-specs.md # How to write UTS specs
│ ├── writing-derived-tests.md # How to translate specs into SDK tests
│ ├── integration-testing.md # Integration testing policy
│ ├── proxy.md # Proxy infrastructure spec (cross-module)
│ └── completion-status.md # Spec coverage matrix
└── README.md # This file
```
Expand Down Expand Up @@ -88,6 +87,43 @@ ASSERT error.code == 40160
AWAIT_STATE client.connection.state == ConnectionState.connected
```

Pseudocode maps to language idioms rather than prescribing exact syntax:

- **Absent values**: `== null` means the language-appropriate "absent" value — `undefined` in
JavaScript, `null` in Java/Kotlin/Swift. Assertions like `ASSERT x.value() == null` are
satisfied by `undefined` in SDKs where that is the idiomatic absent value.
- **Property access**: member access written as a call (e.g. `instance.id()`) is satisfied by a
property or getter (`instance.id`) where the SDK's feature spec defines the member as a
property (e.g. `Instance#id`, RTINS3).
- **Enum values**: a symbolic enum value in pseudo-code (e.g. `"LWW"` for
`ObjectsMapSemantics.LWW`, wire-encoded as an integer per OMP2) is satisfied by the SDK's
idiomatic public rendering of that enum member — the enum member itself in typed SDKs
(`MapSemantics.LWW` in ably-java), or a string-literal union value in ably-js (`'lww'`).
- **`poll_until_success(condition)`**: a `poll_until` (interval 500ms, timeout 10s) that keeps
polling until the condition succeeds — returns a truthy result without raising. Any error raised by
the condition — not-found for a not-yet-visible write, or a transient service/network error —
means "keep polling" rather than failure; if the timeout expires, the most recent error is
raised so the failure stays diagnosable (a plain timeout error if the condition never raised).
Use only where an error genuinely means "not ready yet", e.g. reads of the eventually-consistent
message store (`getMessage`, `getMessageVersions`, `annotations.get`), or LiveObjects value
reads polled across a fault-injection/recovery window, where the channel is transiently
DETACHED and reads raise by design; where an error should fail the test, use `poll_until`.
Implementations typically provide this as a shared helper wrapping their `poll_until`
(e.g. `pollUntilSuccess` in ably-js); the reference definition lives in
[docs/writing-test-specs.md](docs/writing-test-specs.md).
- **`process_pending_events()`**: let the client finish processing all asynchronous work that
is already queued (mock events, promise continuations) before the test continues — used at
unit tier before asserting, especially for negative assertions ("nothing happened"). Involves
no real delay and is never rendered as a timed sleep; implementations define a shared helper
using the platform's zero-delay yield (e.g. `flushAsync()` awaiting a `setImmediate` in
ably-js, or `runCurrent()` on a kotlinx-coroutines test scheduler) — see the timer guidance
in [docs/writing-derived-tests.md](docs/writing-derived-tests.md).
- **Language-inapplicable inputs**: a test input that cannot be constructed in a given language
(e.g. a non-string map key in JavaScript, where object keys are always coerced to strings; or a
`null` argument where the SDK's signature makes null indistinguishable from "omitted") makes
that test — or that table row — not applicable to that SDK. Such omissions are sanctioned and
should be noted in the derived test file rather than counted as coverage gaps.

See [docs/writing-test-specs.md](docs/writing-test-specs.md) for the full pseudocode reference, mock patterns, and conventions.

## Guides
Expand All @@ -101,4 +137,4 @@ See [docs/writing-test-specs.md](docs/writing-test-specs.md) for the full pseudo

The programmable proxy for integration testing lives in a separate repository: [ably/uts-proxy](https://github.com/ably/uts-proxy). It sits between the SDK and the Ably sandbox, transparently forwarding traffic while allowing rule-based fault injection.

See `realtime/integration/helpers/proxy.md` for the proxy infrastructure specification used by test specs in this repository.
See `uts/docs/proxy.md` for the proxy infrastructure specification used by test specs in this repository.
15 changes: 11 additions & 4 deletions uts/docs/integration-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ realtime/
presence/
presence_lifecycle_test.md
...
helpers/
proxy.md # Proxy infrastructure spec
proxy/ # Proxy-based tests (sandbox + proxy)
connection_open_failures.md
connection_resume.md
Expand Down Expand Up @@ -151,13 +149,12 @@ AFTER ALL TESTS:

### Proxy Setup (integration-proxy only)

Proxy tests additionally set up a proxy session per test or group of tests. See `realtime/integration/helpers/proxy.md` for the proxy infrastructure API.
Proxy tests additionally set up a proxy session per test or group of tests. See `uts/docs/proxy.md` for the proxy infrastructure API.

```pseudo
BEFORE EACH TEST:
session = create_proxy_session(
endpoint: "nonprod:sandbox",
Comment thread
paddybyers marked this conversation as resolved.
port: allocated_port,
rules: [ ...initial rules... ]
)

Expand Down Expand Up @@ -228,6 +225,12 @@ Guidelines:

The goal is: every await in the test is bounded, and the suite timeout is generous enough that it only fires if something truly unexpected happens. When a test fails, the error should say *what* timed out, not just "suite timeout exceeded."

**All timeouts are wall-clock time.** Every `WITH timeout`, `poll_until` and `WAIT` in an
integration spec measures real elapsed time — the test is waiting on a real server over a real
network. Derived tests in frameworks that virtualise time by default must run these waits
against the real clock; see the *Integration timeouts are wall-clock* section in
`writing-derived-tests.md` for the failure mode and recommended helpers.

### Avoiding Flaky Tests

- Use polling with timeouts instead of fixed waits (see `README.md` polling conventions)
Expand Down Expand Up @@ -296,6 +299,10 @@ The following integration test specs are annotated with `## Protocol Variants`:
- `realtime/integration/mutable_messages_test.md`
- `realtime/integration/delta_decoding_test.md`

**LiveObjects:**
- `objects/integration/objects_lifecycle_test.md`
- `objects/integration/objects_sync_test.md`

## Writing Proxy Tests

The proxy mediates between the SDK and the real Ably server. It is not a mock server. Tests should be written to rely on actual server responses as much as possible, with the proxy intervening only where necessary to create the specific fault or error condition under test.
Expand Down
19 changes: 16 additions & 3 deletions uts/realtime/integration/helpers/proxy.md → uts/docs/proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ Proxy integration tests use this to verify fault-handling behaviour against the
# 1. Create a proxy session with rules
session = create_proxy_session(
endpoint: "nonprod:sandbox",
Comment thread
paddybyers marked this conversation as resolved.
port: allocated_port,
rules: [ ...rules... ]
)

Expand Down Expand Up @@ -58,7 +57,7 @@ session.close()
interface ProxySession:
session_id: String
proxy_host: String # Always "localhost"
proxy_port: Int # Assigned from port pool
proxy_port: Int # Auto-assigned by proxy, or explicit if specified

add_rules(rules: List<Rule>, position?: "append"|"prepend")
trigger_action(action: ActionRequest)
Expand All @@ -67,7 +66,7 @@ interface ProxySession:

function create_proxy_session(
endpoint: String, # e.g. "nonprod:sandbox" → resolves to sandbox.realtime.ably-nonprod.net
port: Int,
port?: Int, # Optional; proxy auto-assigns a free port if omitted
rules?: List<Rule>,
timeoutMs?: Int # Session auto-cleanup timeout (default 30000)
): ProxySession
Expand Down Expand Up @@ -113,6 +112,14 @@ Rules are evaluated in order. First matching rule wins. Unmatched traffic passes

**`count`**: 1-based occurrence counter. `count: 2` matches only the 2nd occurrence.

**`action`** (frame matches): must be a **string** — either a protocol action *name*
(e.g. `"ATTACHED"`) or a *numeric string* (e.g. `"20"`). A JSON number is rejected at
session creation with HTTP 400 (`cannot unmarshal number into Go struct field
MatchConfig.match.action of type string`). Note: uts-proxy v0.3.0 resolves action
*names* only up to `AUTH` (17) — an unresolvable name (e.g. `"OBJECT_SYNC"`) makes the
rule silently never match, so use numeric strings for `OBJECT` (`"19"`),
`OBJECT_SYNC` (`"20"`) and `ANNOTATION` (`"21"`) until the proxy's name table is extended.

### Actions

```json
Expand Down Expand Up @@ -203,6 +210,9 @@ ASSERT attach_frames.length == 1
| MESSAGE | 15 | Both |
| SYNC | 16 | Server → Client |
| AUTH | 17 | Client → Server |
| OBJECT | 19 | Both |
| OBJECT_SYNC | 20 | Server → Client |
| ANNOTATION | 21 | Both |

## SDK ClientOptions for Proxy Tests

Expand Down Expand Up @@ -231,3 +241,6 @@ ClientOptions(
7. Timeouts are generous (10-30s) since real network is involved
8. Each test file provisions a sandbox app in `BEFORE ALL TESTS` and cleans up in `AFTER ALL TESTS`
9. Each test creates its own proxy session and cleans it up after
10. All `WITH timeout` / `poll_until` / `WAIT` durations are **wall-clock (real) time** — see
the *Integration timeouts are wall-clock* section in `writing-derived-tests.md` for
the virtual-time trap in derived tests
47 changes: 46 additions & 1 deletion uts/docs/writing-derived-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ UTS specs use generic pseudocode. You need to map this onto the SDK's actual API
| `enable_fake_timers()` | Timer control mechanism |
| `ADVANCE_TIME(ms)` | Fake timer tick method |
| `AWAIT_STATE(connection, "connected")` | State waiting helper |
| `poll_until(condition, ...)` | Shared polling helper (wall-clock deadline — see below) |
| `poll_until_success(condition)` | Error-tolerant polling helper (see the pseudocode conventions in `uts/README.md`) |

Check the SDK's existing test infrastructure and conventions before writing anything. Reuse existing helpers, mock classes, and patterns.

Expand Down Expand Up @@ -271,7 +273,7 @@ These casts are an SDK wart, not a test problem — apply them as needed and mov
Unit tests must not use real timers (`setTimeout`, `setInterval`, `sleep`, `delay`) to wait for asynchronous events. Real timers make tests slow, flaky, and prevent the process from exiting cleanly.

- **For time-dependent SDK behaviour** (timeouts, retries, heartbeats): use fake timers that replace the SDK's timer API and can be advanced deterministically.
- **For waiting on async event delivery** (mock message propagation, promise settlement): yield to the event loop with a zero-delay mechanism like `setImmediate`, `process.nextTick`, or equivalent. Define a `flushAsync()` helper and use it everywhere instead of `setTimeout(resolve, N)`.
- **For waiting on async event delivery** (mock message propagation, promise settlement): yield to the event loop with a zero-delay mechanism like `setImmediate`, `process.nextTick`, or equivalent. Define a `flushAsync()` helper and use it everywhere instead of `setTimeout(resolve, N)`. This is the rendering of the spec's `process_pending_events()` convention (see `uts/README.md`).
- **For "prove a negative" assertions** (confirming something did NOT happen): a single event-loop yield is sufficient — if the event hasn't fired after one pass through the macrotask queue, it won't fire from the current stimulus.

The only acceptable use of a real timer is a **safety timeout on test execution** — a long deadline (e.g. 5 seconds) that fails the test if an expected event never arrives, preventing the test from hanging indefinitely. This is a test-level safeguard, not a delay mechanism.
Expand All @@ -288,6 +290,49 @@ const timer = setTimeout(() => reject(new Error('Timed out')), 5000);
connection.once('connected', () => { clearTimeout(timer); resolve(); });
```

**Fake time and poll deadlines collide.** A safety timeout or a `poll_until` helper whose
deadline reads a clock the test's fake timers stub will never expire. This is easy to hit,
because mainstream fake-timer tools fake the wall clock by default (Jest's modern fake timers
and sinon's `useFakeTimers` both mock `Date`): a deadline computed from `Date.now()` freezes
while fake time is installed, so the poll spins until the runner's own timeout kills it with a
generic error instead of the poll's informative one. Two remedies, in order of preference:

1. **Restructure the test so the wall clock never needs stubbing**: backdate a fixture
timestamp past the period under test instead of advancing a fake "now". For example,
ably-js's derived RTO10/RTO10b1 GC tests render the spec's `ADVANCE_TIME`
(`objects/unit/realtime_object.md`) by stamping the tombstone's `serialTimestamp` in the
past (RTLO6a makes it `tombstonedAt`), so the object is already GC-eligible under the real
clock and nothing is stubbed.
2. **Have the polling helper's deadline read a monotonic clock** that time stubbing cannot
touch: `performance.now()` in JavaScript, `System.nanoTime()` on the JVM.

A *synchronous* stub window is exempt: stubbing the clock around a single non-awaiting call and
restoring it in a `finally` never overlaps a poll, so the trap cannot fire. Sometimes it is the
only option — the SDK reads the clock internally and the spec fixture is a fixed epoch, as in
RTLM19's GC-boundary test — and it should then be recorded as a deviation in the test file
header.

### Integration timeouts are wall-clock (beware virtual-time frameworks)

The rule above is inverted for **integration and proxy tests**: every `WITH timeout`,
`poll_until` and `WAIT` in an integration spec is **wall-clock (real) time**, because the test
is waiting on a real server or proxy over a real network.

This is a trap in test frameworks that virtualise time by default. For example,
kotlinx-coroutines' `runTest` runs the test body on a virtual clock: a bare `withTimeout(15s)`
wrapping a real network await measures *virtual* time, which fast-forwards the moment the test
coroutine idles — the timeout fires almost instantly, long before the server can respond, with
a misleading "Timed out after 15s" error. The same applies to a bare `delay()`, which skips
instead of waiting.

Derived integration tests in such frameworks must run their waits against the real clock —
e.g. dispatch onto a real-thread dispatcher before applying the timeout
(`withContext(Dispatchers.Default.limitedParallelism(1)) { withTimeout(...) { ... } }` in
Kotlin), or use the framework's escape hatch for real time. Define shared helpers
(`awaitState`, `pollUntil`, `withRealTimeout`, ...) that encapsulate this once, and use them for
every wait in integration test bodies. Unit tests are unaffected — there, fake/virtual timers
remain the preferred mechanism.

### Cleanup with afterEach

Always restore mocks in `afterEach`, not just at the end of each test. If a test throws before its cleanup code, the next test inherits dirty state. Use the SDK's mock restoration mechanism (e.g. `restoreAll()`) in an `afterEach` hook.
Expand Down
Loading
Loading