diff --git a/src/commands/spaces/create.ts b/src/commands/spaces/create.ts index 9d4af4ac..e56f7cbd 100644 --- a/src/commands/spaces/create.ts +++ b/src/commands/spaces/create.ts @@ -6,6 +6,7 @@ import { formatProgress, formatResource, formatSuccess, + formatWarning, } from "../../utils/output.js"; export default class SpacesCreate extends SpacesBaseCommand { @@ -54,6 +55,14 @@ export default class SpacesCreate extends SpacesBaseCommand { ), ); } + + const ephemeralSpaceWarning = `Space: ${spaceName} is backed by ably channel '${spaceName}::$space' and is ephemeral — it will become active when at least one member enters. This command initializes the space without entering it. To add a member to the space, use 'ably spaces members enter ${spaceName}'`; + + if (this.shouldOutputJson(flags)) { + this.logJsonStatus("warning", ephemeralSpaceWarning, flags); + } else { + this.log(formatWarning(ephemeralSpaceWarning)); + } } catch (error) { this.fail(error, flags, "spaceCreate"); } diff --git a/src/commands/spaces/cursors/set.ts b/src/commands/spaces/cursors/set.ts index 54855a6c..2ba4336b 100644 --- a/src/commands/spaces/cursors/set.ts +++ b/src/commands/spaces/cursors/set.ts @@ -237,10 +237,33 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { { position: { x: simulatedX, y: simulatedY } }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - `${formatLabel("Simulated")} cursor at (${simulatedX}, ${simulatedY})`, + if (this.shouldOutputJson(flags)) { + this.logJsonEvent( + { + cursor: { + clientId: this.realtimeClient!.auth.clientId, + connectionId: this.realtimeClient!.connection.id, + position: { x: simulatedX, y: simulatedY }, + data: (cursorData.data as CursorData) ?? null, + }, + }, + flags, + ); + this.logJsonStatus( + "holding", + "Holding cursor. Press Ctrl+C to exit.", + flags, ); + } else { + const simLines = [ + `${formatLabel("Simulated")} cursor at (${simulatedX}, ${simulatedY})`, + ]; + if (cursorData.data) { + simLines.push( + ` ${formatLabel("Data")} ${JSON.stringify(cursorData.data)}`, + ); + } + this.log(simLines.join("\n")); } } catch (error) { this.logCliEvent( diff --git a/src/commands/spaces/get.ts b/src/commands/spaces/get.ts index b6647463..5044ee35 100644 --- a/src/commands/spaces/get.ts +++ b/src/commands/spaces/get.ts @@ -101,7 +101,7 @@ export default class SpacesGet extends SpacesBaseCommand { if (items.length === 0) { this.fail( - `Space ${spaceName} doesn't have any members currently present. Spaces only exist while members are present. Please enter at least one member using "ably spaces members enter".`, + `Space ${spaceName} doesn't have any members currently present. Spaces only exist while members are present. Please enter at least one member using 'ably spaces members enter ${spaceName}'.`, flags, "spaceGet", { spaceName }, diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index 608cd82b..2f3372c8 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -56,62 +56,56 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { "Subscribing to location updates", ); - try { - const locationHandler = (update: LocationsEvents.UpdateEvent) => { - try { - const timestamp = new Date().toISOString(); - this.logCliEvent( - flags, - "location", - "updateReceived", - "Location update received", - { - clientId: update.member.clientId, - connectionId: update.member.connectionId, - timestamp, - }, - ); + const locationHandler = (update: LocationsEvents.UpdateEvent) => { + try { + const timestamp = new Date().toISOString(); + this.logCliEvent( + flags, + "location", + "updateReceived", + "Location update received", + { + clientId: update.member.clientId, + connectionId: update.member.connectionId, + timestamp, + }, + ); - if (this.shouldOutputJson(flags)) { - this.logJsonEvent( - { - location: { - member: { - clientId: update.member.clientId, - connectionId: update.member.connectionId, - }, - currentLocation: update.currentLocation, - previousLocation: update.previousLocation, - timestamp, + if (this.shouldOutputJson(flags)) { + this.logJsonEvent( + { + location: { + member: { + clientId: update.member.clientId, + connectionId: update.member.connectionId, }, + currentLocation: update.currentLocation, + previousLocation: update.previousLocation, + timestamp, }, - flags, - ); - } else { - this.log(formatTimestamp(timestamp)); - this.log(formatLocationUpdateBlock(update)); - this.log(""); - } - } catch (error) { - this.fail(error, flags, "locationSubscribe", { - spaceName, - }); + }, + flags, + ); + } else { + this.log(formatTimestamp(timestamp)); + this.log(formatLocationUpdateBlock(update)); + this.log(""); } - }; + } catch (error) { + this.fail(error, flags, "locationSubscribe", { + spaceName, + }); + } + }; - this.space!.locations.subscribe("update", locationHandler); + this.space!.locations.subscribe("update", locationHandler); - this.logCliEvent( - flags, - "location", - "subscribed", - "Successfully subscribed to location updates", - ); - } catch (error) { - this.fail(error, flags, "locationSubscribe", { - spaceName, - }); - } + this.logCliEvent( + flags, + "location", + "subscribed", + "Successfully subscribed to location updates", + ); await this.waitAndTrackCleanup(flags, "location", flags.duration); } catch (error) { diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 67a786a9..62bcc31b 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -38,8 +38,6 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { ...durationFlag, }; - private listener: ((member: SpaceMember) => void) | null = null; - async run(): Promise { const { args, flags } = await this.parse(SpacesMembersSubscribe); const { space_name: spaceName } = args; @@ -69,69 +67,69 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { "subscribing", "Subscribing to member updates", ); - // Define the listener function - this.listener = (member: SpaceMember) => { - const now = Date.now(); - - // Determine the action from the member's lastEvent - const action = member.lastEvent?.name || "unknown"; - const clientId = member.clientId || "Unknown"; - const connectionId = member.connectionId || "Unknown"; - - // Skip self events - check connection ID - const selfConnectionId = this.realtimeClient!.connection.id; - if (member.connectionId === selfConnectionId) { - return; - } - - // Create a unique key for this client+connection combination - const clientKey = `${clientId}:${connectionId}`; - // Check if we've seen this exact event recently (within 500ms) - const lastEvent = lastSeenEvents.get(clientKey); + const memberListener = (member: SpaceMember) => { + try { + const now = Date.now(); + + // Determine the action from the member's lastEvent + const action = member.lastEvent?.name || "unknown"; + const clientId = member.clientId || "Unknown"; + const connectionId = member.connectionId || "Unknown"; + + // Create a unique key for this client+connection combination + const clientKey = `${clientId}:${connectionId}`; + + // Check if we've seen this exact event recently (within 500ms) + const lastEvent = lastSeenEvents.get(clientKey); + + if ( + lastEvent && + lastEvent.action === action && + now - lastEvent.timestamp < 500 + ) { + this.logCliEvent( + flags, + "member", + "duplicateEventSkipped", + `Skipping duplicate event '${action}' for ${clientId}`, + { action, clientId }, + ); + return; // Skip duplicate events within 500ms window + } + + // Update the last seen event for this client+connection + lastSeenEvents.set(clientKey, { + action, + timestamp: now, + }); - if ( - lastEvent && - lastEvent.action === action && - now - lastEvent.timestamp < 500 - ) { this.logCliEvent( flags, "member", - "duplicateEventSkipped", - `Skipping duplicate event '${action}' for ${clientId}`, - { action, clientId }, + `update-${action}`, + `Member event '${action}' received`, + { action, clientId, connectionId }, ); - return; // Skip duplicate events within 500ms window - } - // Update the last seen event for this client+connection - lastSeenEvents.set(clientKey, { - action, - timestamp: now, - }); - - this.logCliEvent( - flags, - "member", - `update-${action}`, - `Member event '${action}' received`, - { action, clientId, connectionId }, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonEvent({ member: formatMemberOutput(member) }, flags); - } else { - this.log( - formatTimestamp(formatMessageTimestamp(member.lastEvent.timestamp)), - ); - this.log(formatMemberEventBlock(member, action)); - this.log(""); + if (this.shouldOutputJson(flags)) { + this.logJsonEvent({ member: formatMemberOutput(member) }, flags); + } else { + this.log( + formatTimestamp( + formatMessageTimestamp(member.lastEvent.timestamp), + ), + ); + this.log(formatMemberEventBlock(member, action)); + this.log(""); + } + } catch (error) { + this.fail(error, flags, "memberSubscribe", { spaceName }); } }; - // Subscribe using the stored listener - await this.space!.members.subscribe("update", this.listener); + // Subscribe using the listener + await this.space!.members.subscribe("update", memberListener); this.logCliEvent( flags, @@ -140,13 +138,6 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { "Subscribed to member updates", ); - this.logCliEvent( - flags, - "member", - "listening", - "Listening for member updates...", - ); - // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "member", flags.duration); } catch (error) { diff --git a/src/commands/spaces/subscribe.ts b/src/commands/spaces/subscribe.ts index 32f471fa..3ac721f8 100644 --- a/src/commands/spaces/subscribe.ts +++ b/src/commands/spaces/subscribe.ts @@ -1,19 +1,20 @@ -import type { SpaceMember } from "@ably/spaces"; +import type { SpaceMember, LocationsEvents } from "@ably/spaces"; import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../flags.js"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; import { - formatCountLabel, + formatEventType, + formatLabel, formatListening, + formatMessageTimestamp, formatProgress, - formatResource, - formatSuccess, formatTimestamp, } from "../../utils/output.js"; import { - formatMemberBlock, + formatMemberEventBlock, formatMemberOutput, + formatLocationUpdateBlock, } from "../../utils/spaces-output.js"; export default class SpacesSubscribe extends SpacesBaseCommand { @@ -40,25 +41,16 @@ export default class SpacesSubscribe extends SpacesBaseCommand { ...durationFlag, }; - private listener: ((spaceState: { members: SpaceMember[] }) => void) | null = - null; - - async finally(error: Error | undefined): Promise { - if (this.space && this.listener) { - try { - this.space.unsubscribe("update", this.listener); - } catch (error_) { - this.debug(`Failed to unsubscribe from space update: ${error_}`); - } - } - - await super.finally(error); - } - async run(): Promise { const { args, flags } = await this.parse(SpacesSubscribe); const { space_name: spaceName } = args; + // Keep track of the last event we've seen for each client to avoid duplicates + const lastSeenEvents = new Map< + string, + { action: string; timestamp: number } + >(); + try { if (!this.shouldOutputJson(flags)) { this.log(formatProgress("Subscribing to space updates")); @@ -68,53 +60,129 @@ export default class SpacesSubscribe extends SpacesBaseCommand { this.logCliEvent( flags, - "space", + "spaceSubscribe", "subscribing", "Subscribing to space updates", ); - this.listener = (spaceState: { members: SpaceMember[] }) => { - const { members } = spaceState; + // --- Member listener (from members/subscribe pattern) --- + const memberListener = (member: SpaceMember) => { + try { + const now = Date.now(); + + const action = member.lastEvent?.name || "unknown"; + const clientId = member.clientId || "Unknown"; + const connectionId = member.connectionId || "Unknown"; + + // Dedup within 500ms window + const clientKey = `${clientId}:${connectionId}`; + const lastEvent = lastSeenEvents.get(clientKey); + + if ( + lastEvent && + lastEvent.action === action && + now - lastEvent.timestamp < 500 + ) { + this.logCliEvent( + flags, + "spaceSubscribe", + "duplicateEventSkipped", + `Skipping duplicate event '${action}' for ${clientId}`, + { action, clientId }, + ); + return; + } - if (this.shouldOutputJson(flags)) { - this.logJsonEvent( - { - space: { - members: members.map((m) => formatMemberOutput(m)), - }, - }, + lastSeenEvents.set(clientKey, { action, timestamp: now }); + + this.logCliEvent( flags, + "spaceSubscribe", + `memberUpdate-${action}`, + `Member event '${action}' received`, + { action, clientId, connectionId }, ); - } else { - this.log( - `${formatTimestamp(new Date().toISOString())} Found ${formatCountLabel(members.length, "member")} on space: ${formatResource(spaceName)}`, + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent( + { eventType: "member", member: formatMemberOutput(member) }, + flags, + ); + } else { + this.log( + formatTimestamp( + formatMessageTimestamp(member.lastEvent.timestamp), + ), + ); + this.log(`${formatLabel("Type")} ${formatEventType("member")}`); + this.log(formatMemberEventBlock(member, action)); + this.log(""); + } + } catch (error) { + this.fail(error, flags, "spaceSubscribe", { spaceName }); + } + }; + + // --- Location listener (from locations/subscribe pattern) --- + const locationListener = (update: LocationsEvents.UpdateEvent) => { + try { + const timestamp = new Date().toISOString(); + + this.logCliEvent( + flags, + "spaceSubscribe", + "locationUpdateReceived", + "Location update received", + { + clientId: update.member.clientId, + connectionId: update.member.connectionId, + timestamp, + }, ); - for (const member of members) { - this.log(formatMemberBlock(member)); + if (this.shouldOutputJson(flags)) { + this.logJsonEvent( + { + eventType: "location", + location: { + member: { + clientId: update.member.clientId, + connectionId: update.member.connectionId, + }, + currentLocation: update.currentLocation, + previousLocation: update.previousLocation, + timestamp, + }, + }, + flags, + ); + } else { + this.log(formatTimestamp(timestamp)); + this.log(`${formatLabel("Type")} ${formatEventType("location")}`); + this.log(formatLocationUpdateBlock(update)); this.log(""); } + } catch (error) { + this.fail(error, flags, "spaceSubscribe", { spaceName }); } }; - // space.subscribe() is synchronous (calls super.on()), no await needed - this.space!.subscribe("update", this.listener); + // Subscribe to both member and location events + await this.space!.members.subscribe("update", memberListener); + this.space!.locations.subscribe("update", locationListener); if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), - ); this.log(formatListening("Listening for space updates.")); } this.logCliEvent( flags, - "space", + "spaceSubscribe", "subscribed", "Subscribed to space updates", ); - await this.waitAndTrackCleanup(flags, "space", flags.duration); + await this.waitAndTrackCleanup(flags, "spaceSubscribe", flags.duration); } catch (error) { this.fail(error, flags, "spaceSubscribe"); } diff --git a/test/unit/commands/spaces/cursors/set.test.ts b/test/unit/commands/spaces/cursors/set.test.ts index 55c84157..7481b37a 100644 --- a/test/unit/commands/spaces/cursors/set.test.ts +++ b/test/unit/commands/spaces/cursors/set.test.ts @@ -131,6 +131,26 @@ describe("spaces:cursors:set command", () => { expect(stdout).toContain("Press Ctrl+C to exit."); }); + it("should include data in simulated cursor output", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + [ + "spaces:cursors:set", + "test-space", + "--simulate", + "--data", + '{"team":"red"}', + ], + import.meta.url, + ); + + expect(stdout).toContain("Simulated"); + expect(stdout).toContain("team"); + expect(stdout).toContain("red"); + }); + it("should merge --data with --x/--y as additional cursor data", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); @@ -194,6 +214,41 @@ describe("spaces:cursors:set command", () => { expect(status).toHaveProperty("status", "holding"); expect(status!.message).toContain("Holding cursor"); }); + + it("should emit JSON events for simulated cursor updates", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:cursors:set", "test-space", "--simulate", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("command", "spaces:cursors:set"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("cursor"); + + const events = records.filter((r) => r.type === "event"); + expect(events.length).toBeGreaterThan(0); + + const event = events[0]; + expect(event).toHaveProperty("command", "spaces:cursors:set"); + expect(event).toHaveProperty("cursor"); + const cursor = event!.cursor as Record; + expect(cursor).toHaveProperty("position"); + expect(cursor).toHaveProperty("clientId"); + expect(cursor).toHaveProperty("connectionId"); + expect(cursor).toHaveProperty("data"); + + // Each simulation event should be followed by a holding status + const holdingStatuses = records.filter( + (r) => r.type === "status" && r.status === "holding", + ); + expect(holdingStatuses.length).toBeGreaterThan(1); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/spaces/subscribe.test.ts b/test/unit/commands/spaces/subscribe.test.ts index 6aacc796..c878b0cb 100644 --- a/test/unit/commands/spaces/subscribe.test.ts +++ b/test/unit/commands/spaces/subscribe.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblySpaces } from "../../../helpers/mock-ably-spaces.js"; import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -21,26 +22,40 @@ describe("spaces:subscribe command", () => { standardFlagTests("spaces:subscribe", import.meta.url, ["--json"]); describe("functionality", () => { - it("should subscribe to space updates and output events", async () => { + it("should subscribe to both members and locations", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.subscribe.mockImplementation( - (event: string, cb: (state: unknown) => void) => { + await runCommand(["spaces:subscribe", "test-space"], import.meta.url); + + expect(space.enter).not.toHaveBeenCalled(); + expect(space.members.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + expect(space.locations.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + }); + + it("should output member events with Type label", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + space.members.subscribe.mockImplementation( + (event: string, cb: (member: unknown) => void) => { setTimeout(() => { cb({ - members: [ - { - clientId: "user-1", - connectionId: "other-conn-1", - isConnected: true, - profileData: { name: "Alice" }, - location: null, - lastEvent: { name: "enter", timestamp: Date.now() }, - }, - ], + clientId: "user-1", + connectionId: "other-conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, }); }, 10); + return Promise.resolve(); }, ); @@ -49,45 +64,72 @@ describe("spaces:subscribe command", () => { import.meta.url, ); + expect(stdout).toContain("Type:"); + expect(stdout).toContain("member"); expect(stdout).toContain("Client ID:"); expect(stdout).toContain("user-1"); expect(stdout).toContain("Connection ID:"); expect(stdout).toContain("other-conn-1"); + expect(stdout).toContain("Action:"); + expect(stdout).toContain("enter"); }); - it("should subscribe to space 'update' event and not enter", async () => { + it("should output location events with Type label", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - await runCommand(["spaces:subscribe", "test-space"], import.meta.url); + let locationHandler: ((update: unknown) => void) | undefined; + space.locations.subscribe.mockImplementation( + (_event: string, handler: (update: unknown) => void) => { + locationHandler = handler; + }, + ); - expect(space.enter).not.toHaveBeenCalled(); - expect(space.subscribe).toHaveBeenCalledWith( - "update", - expect.any(Function), + const runPromise = runCommand( + ["spaces:subscribe", "test-space"], + import.meta.url, ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (locationHandler) { + locationHandler({ + member: { + clientId: "user-2", + connectionId: "conn-2", + }, + currentLocation: { room: "lobby" }, + previousLocation: { room: "entrance" }, + }); + } + + const { stdout } = await runPromise; + + expect(stdout).toContain("Type:"); + expect(stdout).toContain("location"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-2"); + expect(stdout).toContain("Current Location:"); + expect(stdout).toContain("Previous Location:"); }); - it("should output JSON event with members array", async () => { + it("should include eventType in JSON for member events", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.subscribe.mockImplementation( - (event: string, cb: (state: unknown) => void) => { + space.members.subscribe.mockImplementation( + (event: string, cb: (member: unknown) => void) => { setTimeout(() => { cb({ - members: [ - { - clientId: "user-1", - connectionId: "other-conn-1", - isConnected: true, - profileData: { name: "Alice" }, - location: null, - lastEvent: { name: "enter", timestamp: Date.now() }, - }, - ], + clientId: "user-1", + connectionId: "other-conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, }); }, 10); + return Promise.resolve(); }, ); @@ -96,12 +138,56 @@ describe("spaces:subscribe command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result.type).toBe("event"); - expect(result.space).toBeDefined(); - expect(result.space.members).toBeDefined(); - expect(result.space.members).toBeInstanceOf(Array); - expect(result.space.members[0].clientId).toBe("user-1"); + const records = parseNdjsonLines(stdout); + const eventRecord = records.find( + (r) => r.type === "event" && r.eventType === "member", + ); + expect(eventRecord).toBeDefined(); + expect(eventRecord!.member).toBeDefined(); + expect(eventRecord!.member.clientId).toBe("user-1"); + }); + + it("should include eventType in JSON for location events", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + let locationHandler: ((update: unknown) => void) | undefined; + space.locations.subscribe.mockImplementation( + (_event: string, handler: (update: unknown) => void) => { + locationHandler = handler; + }, + ); + + const runPromise = runCommand( + ["spaces:subscribe", "test-space", "--json"], + import.meta.url, + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (locationHandler) { + locationHandler({ + member: { + clientId: "user-1", + connectionId: "conn-1", + }, + currentLocation: { room: "lobby" }, + previousLocation: null, + }); + } + + const { stdout } = await runPromise; + + const records = parseNdjsonLines(stdout); + const eventRecord = records.find( + (r) => r.type === "event" && r.eventType === "location", + ); + expect(eventRecord).toBeDefined(); + expect(eventRecord!.location).toBeDefined(); + expect(eventRecord!.location.member.clientId).toBe("user-1"); + expect(eventRecord!.location).toHaveProperty("currentLocation"); + expect(eventRecord!.location).toHaveProperty("previousLocation"); + expect(eventRecord!.location).toHaveProperty("timestamp"); }); }); @@ -109,9 +195,7 @@ describe("spaces:subscribe command", () => { it("should handle errors gracefully", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.subscribe.mockImplementation(() => { - throw new Error("Subscribe failed"); - }); + space.members.subscribe.mockRejectedValue(new Error("Subscribe failed")); const { error } = await runCommand( ["spaces:subscribe", "test-space"],