diff --git a/test/uts/objects/integration/objects_gc.test.ts b/test/uts/objects/integration/objects_gc.test.ts index 9ff3c8e96..ec0e989b6 100644 --- a/test/uts/objects/integration/objects_gc.test.ts +++ b/test/uts/objects/integration/objects_gc.test.ts @@ -1,31 +1,22 @@ /** * UTS Integration: Objects GC Tests * - * Spec points: RTO10, RTLM19 + * Spec points: RTO10, RTLM19, RTLM5d2h, RTLM7 * Source: specification/uts/objects/integration/objects_gc_test.md * - * Behavioral verification of garbage collection for tombstoned objects and - * tombstoned map entries. The UTS spec uses ADVANCE_TIME (fake timers) to - * control timing. However, ably-js's ObjectsPool uses native setInterval - * (not Platform.Config.setTimeout), so FakeClock cannot intercept GC timers - * in an integration context with real server connections. + * Behavioral verification of tombstone semantics at the integration tier: + * removing a map entry tombstones it (RTLM7), tombstoned entries read back + * as undefined/null (RTLM5d2h), and the key is recreatable with a fresh + * server-assigned objectId — safe because tombstoned state is retained for + * the GC grace period (RTO10). * - * Deviations: - * - RTO10/tombstoned-object-gc-recreate-0: Cannot use fake timers with real - * server connections because ably-js GC uses native setInterval. Instead, - * we test the observable behavior: remove a counter, then re-set the same - * key with a new counter. The server assigns a new objectId, confirming - * the old object is gone and a new one was created. The GC grace period - * advancement is omitted; we rely on the server accepting the new create. - * - RTO10/tombstoned-object-gc-recreate-0: UTS spec asserts value() == null - * after removal. ably-js PathObject#value() returns undefined for - * tombstoned entries. Assertion adapted accordingly. - * - RTLM19/tombstoned-entry-gc-reset-0: Same fake timer deviation as above. - * We test remove + re-set without time advancement. The server accepts the - * new MAP_SET because the entry was removed (tombstoned) and a new serial - * is assigned. GC of the tombstone entry is not directly observable in - * this integration context. - * - RTLM19/tombstoned-entry-gc-reset-0: Same undefined vs null deviation. + * The timer-based GC sweep itself (RTO10a-c, RTLM19) is verified at the + * unit tier (RTO10/RTO10b1 in test/uts/objects/unit/realtime_object.test.ts, + * RTLM19 in test/uts/objects/unit/live_map.test.ts): integration + * tests run on wall-clock time against a real server, and the sweep's + * cadence (RTO10a, ~5 minutes) with the server-provided grace period + * (RTO10b, default 24h) is not observable within test timeouts. The + * spec's undefined/null (RTLM5d2h) maps to `undefined` in ably-js. */ import { expect } from 'chai'; @@ -42,8 +33,9 @@ import { pollUntil, } from '../../realtime/integration/sandbox'; import * as LiveObjectsPlugin from '../../../../src/plugins/liveobjects'; +import { describeWithProtocols } from './protocol_variants'; -describe('uts/objects/integration/objects_gc', function () { +describeWithProtocols('uts/objects/integration/objects_gc', function (useBinaryProtocol) { this.timeout(30000); before(async function () { @@ -55,13 +47,11 @@ describe('uts/objects/integration/objects_gc', function () { }); /** - * RTO10 - Tombstoned object is GC'd and recreatable + * RTO10 - Tombstoned object is recreatable with new objectId * * After an object is tombstoned (removed from map), a new object can be - * created at the same map key. The new object gets a different objectId. - * - * Deviation: GC grace period advancement omitted (see file header). - * We test remove + re-create and verify new objectId differs from old. + * created at the same map key. The new object gets a different + * server-assigned objectId, confirming the old object is gone. */ // UTS: objects/integration/RTO10/tombstoned-object-gc-recreate-0 it('RTO10 - tombstoned object is recreatable with new objectId', async function () { @@ -71,7 +61,7 @@ describe('uts/objects/integration/objects_gc', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(client); @@ -95,7 +85,7 @@ describe('uts/objects/integration/objects_gc', function () { // Remove it (tombstones the entry and the object) await root.remove('counter'); - // Deviation: ably-js returns undefined for tombstoned entries, not null + // RTLM5d2h: tombstoned entries read back as undefined/null (undefined in ably-js) await pollUntil(() => (root.get('counter').value() === undefined ? true : null), { interval: 500, timeout: 10000, @@ -119,14 +109,11 @@ describe('uts/objects/integration/objects_gc', function () { }); /** - * RTLM19 - Tombstoned map entry is GC'd, re-settable - * - * After a map entry is tombstoned (removed), the entry can be re-set. - * A subsequent MAP_SET succeeds because the server processes the new - * operation against the tombstoned entry. + * RTLM19 - Tombstoned map entry is re-settable * - * Deviation: GC grace period advancement omitted (see file header). - * We test remove + re-set and verify the value is correctly restored. + * After a map entry is tombstoned (removed, RTLM7), the entry can be + * re-set. The subsequent MAP_SET succeeds because the server assigns a + * newer serial than the removal's. */ // UTS: objects/integration/RTLM19/tombstoned-entry-gc-reset-0 it('RTLM19 - tombstoned map entry is re-settable', async function () { @@ -136,7 +123,7 @@ describe('uts/objects/integration/objects_gc', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(client); @@ -158,7 +145,7 @@ describe('uts/objects/integration/objects_gc', function () { await root.remove('ephemeral'); - // Deviation: ably-js returns undefined for tombstoned entries, not null + // RTLM5d2h: tombstoned entries read back as undefined/null (undefined in ably-js) await pollUntil(() => (root.get('ephemeral').value() === undefined ? true : null), { interval: 500, timeout: 10000, diff --git a/test/uts/objects/integration/objects_lifecycle.test.ts b/test/uts/objects/integration/objects_lifecycle.test.ts index c8956dc13..09f0126a6 100644 --- a/test/uts/objects/integration/objects_lifecycle.test.ts +++ b/test/uts/objects/integration/objects_lifecycle.test.ts @@ -23,8 +23,9 @@ import { pollUntil, } from '../../realtime/integration/sandbox'; import * as LiveObjectsPlugin from '../../../../src/plugins/liveobjects'; +import { describeWithProtocols } from './protocol_variants'; -describe('uts/objects/integration/objects_lifecycle', function () { +describeWithProtocols('uts/objects/integration/objects_lifecycle', function (useBinaryProtocol) { this.timeout(30000); before(async function () { @@ -49,7 +50,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(client); @@ -83,7 +84,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientA); @@ -92,7 +93,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientB); @@ -148,7 +149,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientA); @@ -157,7 +158,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientB); @@ -210,7 +211,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientA); @@ -219,7 +220,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientB); @@ -287,7 +288,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientA); @@ -296,7 +297,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(clientB); @@ -351,7 +352,9 @@ describe('uts/objects/integration/objects_lifecycle', function () { it('RTPO15 - client syncs pre-existing data provisioned via REST', async function () { const channelName = uniqueChannelName('objects-rest-provision'); - // Provision data via REST before any realtime client connects + // Provision data via REST before any realtime client connects. The spec's + // provision_objects_via_rest helper is a raw JSON POST, so the provisioning + // client stays on json regardless of the protocol variant under test. const restClient = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, @@ -370,7 +373,7 @@ describe('uts/objects/integration/objects_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, }); trackClient(client); diff --git a/test/uts/objects/integration/objects_sync.test.ts b/test/uts/objects/integration/objects_sync.test.ts index b519155ec..64f75329f 100644 --- a/test/uts/objects/integration/objects_sync.test.ts +++ b/test/uts/objects/integration/objects_sync.test.ts @@ -23,8 +23,9 @@ import { uniqueChannelName, pollUntil, } from '../../realtime/integration/sandbox'; +import { describeWithProtocols } from './protocol_variants'; -describe('uts/objects/integration/objects_sync', function () { +describeWithProtocols('uts/objects/integration/objects_sync', function (useBinaryProtocol) { this.timeout(30000); before(async function () { @@ -49,7 +50,7 @@ describe('uts/objects/integration/objects_sync', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, } as any); trackClient(client); @@ -83,7 +84,7 @@ describe('uts/objects/integration/objects_sync', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, } as any); trackClient(clientA); @@ -92,7 +93,7 @@ describe('uts/objects/integration/objects_sync', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, } as any); trackClient(clientB); @@ -145,7 +146,7 @@ describe('uts/objects/integration/objects_sync', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, } as any); trackClient(client); @@ -205,21 +206,16 @@ describe('uts/objects/integration/objects_sync', function () { * Channel attached with only OBJECT_SUBSCRIBE mode. Server * sends HAS_OBJECTS, sync completes, root is an empty LiveMap. * - * DEVIATION: The UTS spec title says "without OBJECT_SUBSCRIBE" but the - * pseudocode uses modes: ["OBJECT_SUBSCRIBE"]. We follow the pseudocode - * since OBJECT_SUBSCRIBE is required for channel.object.get() to work - * (RTO2a). The test verifies that get() resolves even without - * OBJECT_PUBLISH mode, and the root has no entries. */ // UTS: objects/integration/RTO4/attach-subscribe-only-0 - it('RTO4 - attach with OBJECT_SUBSCRIBE only resolves get() with empty pool', async function () { + it('RTO4 - Attach without OBJECT_PUBLISH still resolves get() with empty pool', async function () { const channelName = uniqueChannelName('objects-subscribe-only'); const client = new Ably.Realtime({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol, plugins: { LiveObjects: LiveObjectsPlugin }, } as any); trackClient(client); diff --git a/test/uts/objects/integration/protocol_variants.ts b/test/uts/objects/integration/protocol_variants.ts new file mode 100644 index 000000000..99c561fec --- /dev/null +++ b/test/uts/objects/integration/protocol_variants.ts @@ -0,0 +1,30 @@ +/** + * Protocol variant helper for UTS integration tests. + * + * Integration spec files declare "Protocol Variants: json, msgpack" — every + * test in the file runs once per variant with useBinaryProtocol set + * accordingly. This mirrors the legacy suite's SharedHelper.testOnJsonMsgpack + * pattern (test/common/modules/shared_helper.js) at describe level, so both + * variants always run without any command-line or environment parameter. + */ + +export type Protocol = 'json' | 'msgpack'; + +// Structural stand-ins for mocha's Suite and global describe so this file +// needs no mocha type definitions. mocha provides describe at runtime. +interface SuiteContext { + timeout(ms: number): unknown; +} + +declare function describe(title: string, fn: (this: SuiteContext) => void): unknown; + +export function describeWithProtocols( + title: string, + body: (this: SuiteContext, useBinaryProtocol: boolean, protocol: Protocol) => void, +): void { + for (const protocol of ['json', 'msgpack'] as const) { + describe(`${title} (${protocol})`, function (this: SuiteContext) { + body.call(this, protocol === 'msgpack', protocol); + }); + } +} diff --git a/test/uts/objects/integration/proxy/objects_faults.test.ts b/test/uts/objects/integration/proxy/objects_faults.test.ts index 42ded8a7e..88726bfb6 100644 --- a/test/uts/objects/integration/proxy/objects_faults.test.ts +++ b/test/uts/objects/integration/proxy/objects_faults.test.ts @@ -1,13 +1,13 @@ /** * UTS Proxy Integration: Objects Fault Tests * - * Spec points: RTO5a2, RTO7, RTO8, RTO17, RTO20e, RTO5c6 + * Spec points: RTO5a2, RTO7, RTO8, RTO17, RTO20e, RTO20e1, RTO5c6 * Source: specification/uts/objects/integration/proxy/objects_faults.md * * Tests LiveObjects fault tolerance: sync interrupted by disconnect, * mutations buffered during re-sync, server-initiated detach triggers - * re-sync, publishAndApply fails when channel enters FAILED, and - * publish during sync with delayed OBJECT_SYNC. + * re-sync, in-flight publish fails when channel enters FAILED during + * the sync wait, and publish during sync with delayed OBJECT_SYNC. */ import { expect } from 'chai'; @@ -337,21 +337,25 @@ describe('uts/objects/integration/proxy/objects_faults', function () { }); /** - * RTO20e - publishAndApply fails when channel enters FAILED during SYNCING + * RTO20e, RTO20e1 - publishAndApply fails when channel enters FAILED during SYNCING * - * Client sets up a channel with objects, then the proxy injects a channel - * ERROR (action 9) to transition to FAILED. A PathObject mutation (which - * uses publishAndApply internally) should fail. + * The client syncs the channel, is forced back into SYNCING by an injected + * ATTACHED carrying the HAS_OBJECTS flag (RTO4c), then issues a mutation + * *while* SYNCING. The publish and its ACK complete against the real + * server, so publishAndApply parks in the RTO20e wait for SYNCED. The + * proxy then injects a channel ERROR so the channel enters FAILED whilst + * the operation is waiting; the pending mutation must fail with 92008, + * statusCode 400, cause = channel errorReason (RTO20e1). * - * DEVIATION: UTS spec expects error code 92008, but the ably-js implementation - * throws 90001 (invalid channel state) because set() calls - * throwIfInvalidWriteApiConfiguration() before reaching publishAndApply's - * sync-wait logic. The 92008 path is only reachable when the channel - * transitions to FAILED *during* the sync wait, but in this test sync has - * already completed before the channel enters FAILED. We assert 90001 instead. + * Note: the mutation must be in flight *before* the channel fails. A + * mutation issued on a channel already in DETACHED/FAILED/SUSPENDED fails + * the RTO26b write precondition with 90001 and never reaches + * publishAndApply -- that is different behaviour, not this test. The + * unit-tier test with UTS ID objects/unit/RTO20e1/fails-on-channel-failed-0 + * (in test/uts/objects/unit/realtime_object.test.ts) uses the same sequence. */ // UTS: objects/proxy/RTO20e/publish-fails-on-channel-failed-0 - it('RTO20e - publishAndApply fails when channel enters FAILED', async function () { + it('RTO20e, RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait', async function () { const channelName = uniqueChannelName('objects-publish-failed'); session = await createProxySession({ @@ -381,7 +385,42 @@ describe('uts/objects/integration/proxy/objects_faults', function () { const root = await channel.object.get(); - // Inject channel ERROR (action 9) to transition to FAILED + // Force the objects back into SYNCING: inject an ATTACHED (action 11) carrying the + // HAS_OBJECTS flag (bit 7, i.e. flags: 128). RTO4c starts a new sync sequence on + // every ATTACHED protocol message; the server never sent this ATTACHED, so no + // OBJECT_SYNC follows and the objects remain SYNCING. The channel stays ATTACHED. + await session.triggerAction({ + type: 'inject_to_client', + message: { action: 11, channel: channelName, flags: 128 }, + }); + + // Mutate WHILE SYNCING: the channel is ATTACHED so the write preconditions (RTO26) + // pass and the publish + ACK complete against the real server; publishAndApply then + // waits for a SYNCED that will never arrive (RTO20e). Do not await yet -- attach a + // handler immediately so the later rejection is never unhandled. + const pending = root.set('key', 'value'); + const pendingError = pending.then( + () => null, + (err: any) => err, + ); + + // Ensure the operation is in the RTO20e sync-wait, not still publishing: wait until + // the proxy log shows the server's ACK (action 1) for the OBJECT publish, then allow + // a brief real-time yield for the client to move the ACKed operation into the wait. + await pollUntil( + async () => { + const log = await session!.getLog(); + return log.some( + (event) => event.type === 'ws_frame' && event.direction === 'server_to_client' && event.message?.action === 1, + ) + ? true + : null; + }, + { interval: 200, timeout: 10000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + + // The channel enters FAILED whilst the operation waits for SYNCED (RTO20e1) await session.triggerAction({ type: 'inject_to_client', message: { @@ -393,18 +432,14 @@ describe('uts/objects/integration/proxy/objects_faults', function () { await waitForChannelState(channel, 'failed', 15000); - // Attempt a mutation -- should fail since channel is FAILED - let mutationError: any = null; - try { - await root.set('key', 'value'); - expect.fail('set() should have failed on FAILED channel'); - } catch (err: any) { - mutationError = err; - } + const mutationError = await pendingError; - expect(mutationError).to.not.be.null; - // DEVIATION: See comment above -- 90001 (invalid channel state) instead of 92008 - expect(mutationError.code).to.equal(90001); + expect(mutationError, 'set() should have failed when channel entered FAILED during sync wait').to.not.be.null; + expect(mutationError.code).to.equal(92008); + expect(mutationError.statusCode).to.equal(400); + // RTO20e1: cause is set to RealtimeChannel.errorReason -- the injected channel ERROR + expect(mutationError.cause).to.exist; + expect(mutationError.cause.code).to.equal(90000); await closeAndWait(client); });