From 0d4446d418c93ba1776dc451bdfcf0cd4c07f5df Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 19 Mar 2026 19:53:55 +0530 Subject: [PATCH 1/3] [DX-964] Added missing spaces create, get and subscribe --- README.md | 138 +++++++++++++ src/commands/spaces/create.ts | 55 ++++++ src/commands/spaces/get.ts | 164 ++++++++++++++++ src/commands/spaces/index.ts | 3 + src/commands/spaces/members/get-all.ts | 84 ++++++++ src/commands/spaces/subscribe.ts | 110 +++++++++++ src/utils/spaces-output.ts | 3 +- test/helpers/mock-ably-spaces.ts | 23 +++ test/unit/commands/spaces/create.test.ts | 77 ++++++++ test/unit/commands/spaces/get.test.ts | 182 ++++++++++++++++++ .../commands/spaces/members/get-all.test.ts | 136 +++++++++++++ test/unit/commands/spaces/subscribe.test.ts | 128 ++++++++++++ 12 files changed, 1102 insertions(+), 1 deletion(-) create mode 100644 src/commands/spaces/create.ts create mode 100644 src/commands/spaces/get.ts create mode 100644 src/commands/spaces/members/get-all.ts create mode 100644 src/commands/spaces/subscribe.ts create mode 100644 test/unit/commands/spaces/create.test.ts create mode 100644 test/unit/commands/spaces/get.test.ts create mode 100644 test/unit/commands/spaces/members/get-all.test.ts create mode 100644 test/unit/commands/spaces/subscribe.test.ts diff --git a/README.md b/README.md index b4e1a102a..5969d6ff9 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,12 @@ $ ably-interactive * [`ably rooms typing keystroke ROOM`](#ably-rooms-typing-keystroke-room) * [`ably rooms typing subscribe ROOM`](#ably-rooms-typing-subscribe-room) * [`ably spaces`](#ably-spaces) +* [`ably spaces create SPACE_NAME`](#ably-spaces-create-space_name) * [`ably spaces cursors`](#ably-spaces-cursors) * [`ably spaces cursors get-all SPACE_NAME`](#ably-spaces-cursors-get-all-space_name) * [`ably spaces cursors set SPACE_NAME`](#ably-spaces-cursors-set-space_name) * [`ably spaces cursors subscribe SPACE_NAME`](#ably-spaces-cursors-subscribe-space_name) +* [`ably spaces get SPACE_NAME`](#ably-spaces-get-space_name) * [`ably spaces list`](#ably-spaces-list) * [`ably spaces locations`](#ably-spaces-locations) * [`ably spaces locations get-all SPACE_NAME`](#ably-spaces-locations-get-all-space_name) @@ -217,10 +219,12 @@ $ ably-interactive * [`ably spaces locks subscribe SPACE_NAME`](#ably-spaces-locks-subscribe-space_name) * [`ably spaces members`](#ably-spaces-members) * [`ably spaces members enter SPACE_NAME`](#ably-spaces-members-enter-space_name) +* [`ably spaces members get-all SPACE_NAME`](#ably-spaces-members-get-all-space_name) * [`ably spaces members subscribe SPACE_NAME`](#ably-spaces-members-subscribe-space_name) * [`ably spaces occupancy`](#ably-spaces-occupancy) * [`ably spaces occupancy get SPACE_NAME`](#ably-spaces-occupancy-get-space_name) * [`ably spaces occupancy subscribe SPACE_NAME`](#ably-spaces-occupancy-subscribe-space_name) +* [`ably spaces subscribe SPACE_NAME`](#ably-spaces-subscribe-space_name) * [`ably stats`](#ably-stats) * [`ably stats account`](#ably-stats-account) * [`ably stats app [ID]`](#ably-stats-app-id) @@ -4463,21 +4467,61 @@ DESCRIPTION EXAMPLES $ ably spaces list + $ ably spaces get my-space + + $ ably spaces create my-space + + $ ably spaces subscribe my-space + $ ably spaces members enter my-space $ ably spaces locations set my-space COMMANDS + ably spaces create Create a new space ably spaces cursors Commands for interacting with Cursors in Ably Spaces + ably spaces get Get the current state of a space ably spaces list List active spaces ably spaces locations Commands for location management in Ably Spaces ably spaces locks Commands for component locking in Ably Spaces ably spaces members Commands for managing members in Ably Spaces ably spaces occupancy Commands for working with occupancy in Ably Spaces + ably spaces subscribe Subscribe to space update events ``` _See code: [src/commands/spaces/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/index.ts)_ +## `ably spaces create SPACE_NAME` + +Create a new space + +``` +USAGE + $ ably spaces create SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] + +ARGUMENTS + SPACE_NAME Name of the space to create + +FLAGS + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Create a new space + +EXAMPLES + $ ably spaces create my-space + + $ ably spaces create my-space --json + + $ ably spaces create my-space --client-id my-client +``` + +_See code: [src/commands/spaces/create.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/create.ts)_ + ## `ably spaces cursors` Commands for interacting with Cursors in Ably Spaces @@ -4613,6 +4657,35 @@ EXAMPLES _See code: [src/commands/spaces/cursors/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/cursors/subscribe.ts)_ +## `ably spaces get SPACE_NAME` + +Get the current state of a space + +``` +USAGE + $ ably spaces get SPACE_NAME [-v] [--json | --pretty-json] + +ARGUMENTS + SPACE_NAME Name of the space to get + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get the current state of a space + +EXAMPLES + $ ably spaces get my-space + + $ ably spaces get my-space --json + + $ ably spaces get my-space --pretty-json +``` + +_See code: [src/commands/spaces/get.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/get.ts)_ + ## `ably spaces list` List active spaces @@ -4977,6 +5050,37 @@ EXAMPLES _See code: [src/commands/spaces/members/enter.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/enter.ts)_ +## `ably spaces members get-all SPACE_NAME` + +Get all members in a space + +``` +USAGE + $ ably spaces members get-all SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] + +ARGUMENTS + SPACE_NAME Name of the space to get members from + +FLAGS + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get all members in a space + +EXAMPLES + $ ably spaces members get-all my-space + + $ ably spaces members get-all my-space --json + + $ ably spaces members get-all my-space --pretty-json +``` + +_See code: [src/commands/spaces/members/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/get-all.ts)_ + ## `ably spaces members subscribe SPACE_NAME` Subscribe to member presence events in a space @@ -5091,6 +5195,40 @@ EXAMPLES _See code: [src/commands/spaces/occupancy/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/occupancy/subscribe.ts)_ +## `ably spaces subscribe SPACE_NAME` + +Subscribe to space update events + +``` +USAGE + $ ably spaces subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] + +ARGUMENTS + SPACE_NAME Name of the space to subscribe to + +FLAGS + -D, --duration= Automatically exit after N seconds + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Subscribe to space update events + +EXAMPLES + $ ably spaces subscribe my-space + + $ ably spaces subscribe my-space --json + + $ ably spaces subscribe my-space --pretty-json + + $ ably spaces subscribe my-space --duration 30 +``` + +_See code: [src/commands/spaces/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/subscribe.ts)_ + ## `ably stats` View statistics for your Ably account or apps diff --git a/src/commands/spaces/create.ts b/src/commands/spaces/create.ts new file mode 100644 index 000000000..0ae9e0b07 --- /dev/null +++ b/src/commands/spaces/create.ts @@ -0,0 +1,55 @@ +import { Args } from "@oclif/core"; + +import { productApiFlags, clientIdFlag } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; + +export default class SpacesCreate extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to create", + required: true, + }), + }; + + static override description = "Create a new space"; + + static override examples = [ + "$ ably spaces create my-space", + "$ ably spaces create my-space --json", + "$ ably spaces create my-space --client-id my-client", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesCreate); + const spaceName = args.space_name; + + try { + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress(`Creating space ${formatResource(spaceName)}`)); + } + + await this.initializeSpace(flags, spaceName, { + enterSpace: false, + setupConnectionLogging: false, + }); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ space: { name: spaceName } }, flags); + } else { + this.log(formatSuccess(`Space ${formatResource(spaceName)} created.`)); + } + } catch (error) { + this.fail(error, flags, "spaceCreate"); + } + } +} diff --git a/src/commands/spaces/get.ts b/src/commands/spaces/get.ts new file mode 100644 index 000000000..a4478c688 --- /dev/null +++ b/src/commands/spaces/get.ts @@ -0,0 +1,164 @@ +import { Args } from "@oclif/core"; + +import { productApiFlags } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { + formatClientId, + formatCountLabel, + formatEventType, + formatHeading, + formatIndex, + formatLabel, + formatMessageTimestamp, + formatProgress, + formatResource, +} from "../../utils/output.js"; + +const SPACE_CHANNEL_TAG = "::$space"; + +interface PresenceItem { + clientId: string; + connectionId: string; + action: string; + timestamp: number; + data?: { + profileUpdate?: { current?: Record }; + locationUpdate?: { current?: unknown }; + }; +} + +interface MemberInfo { + clientId: string; + connectionId: string; + isConnected: boolean; + profileData: Record | null; + location: unknown | null; + lastEvent: { name: string; timestamp: number }; +} + +export default class SpacesGet extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to get", + required: true, + }), + }; + + static override description = "Get the current state of a space"; + + static override examples = [ + "$ ably spaces get my-space", + "$ ably spaces get my-space --json", + "$ ably spaces get my-space --pretty-json", + ]; + + static override flags = { + ...productApiFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesGet); + const spaceName = args.space_name; + + try { + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Fetching state for space ${formatResource(spaceName)}`, + ), + ); + } + + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channelName = `${spaceName}${SPACE_CHANNEL_TAG}`; + const response = await rest.request( + "get", + `/channels/${encodeURIComponent(channelName)}/presence`, + 2, + {}, + null, + ); + + if (response.statusCode !== 200) { + this.fail( + `Failed to fetch space: ${response.statusCode}`, + flags, + "spaceGet", + ); + } + + const items: PresenceItem[] = response.items || []; + + 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".`, + flags, + "spaceGet", + { spaceName }, + ); + } + + const members: MemberInfo[] = items.map((item) => ({ + clientId: item.clientId, + connectionId: item.connectionId, + isConnected: item.action !== "leave" && item.action !== "absent", + profileData: item.data?.profileUpdate?.current ?? null, + location: item.data?.locationUpdate?.current ?? null, + lastEvent: { name: item.action, timestamp: item.timestamp }, + })); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + space: { + name: spaceName, + members, + }, + }, + flags, + ); + } else { + this.log(`\n${formatHeading("Space")} ${formatResource(spaceName)}\n`); + this.log( + `${formatHeading("Members")} (${formatCountLabel(members.length, "member")}):\n`, + ); + + for (let i = 0; i < members.length; i++) { + const member = members[i]; + this.log(`${formatIndex(i + 1)}`); + this.log( + ` ${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, + ); + this.log(` ${formatLabel("Connection ID")} ${member.connectionId}`); + this.log(` ${formatLabel("Connected")} ${member.isConnected}`); + if ( + member.profileData && + Object.keys(member.profileData).length > 0 + ) { + this.log( + ` ${formatLabel("Profile")} ${JSON.stringify(member.profileData)}`, + ); + } + + if (member.location != null) { + this.log( + ` ${formatLabel("Location")} ${JSON.stringify(member.location)}`, + ); + } + + this.log( + ` ${formatLabel("Last Event")} ${formatEventType(member.lastEvent.name)}`, + ); + this.log( + ` ${formatLabel("Event Timestamp")} ${formatMessageTimestamp(member.lastEvent.timestamp)}`, + ); + this.log(""); + } + } + } catch (error) { + this.fail(error, flags, "spaceGet", { spaceName }); + } + } +} diff --git a/src/commands/spaces/index.ts b/src/commands/spaces/index.ts index 5acb0807b..b9b771dc9 100644 --- a/src/commands/spaces/index.ts +++ b/src/commands/spaces/index.ts @@ -8,6 +8,9 @@ export default class SpacesIndex extends BaseTopicCommand { static override examples = [ "<%= config.bin %> <%= command.id %> list", + "<%= config.bin %> <%= command.id %> get my-space", + "<%= config.bin %> <%= command.id %> create my-space", + "<%= config.bin %> <%= command.id %> subscribe my-space", "<%= config.bin %> <%= command.id %> members enter my-space", "<%= config.bin %> <%= command.id %> locations set my-space", ]; diff --git a/src/commands/spaces/members/get-all.ts b/src/commands/spaces/members/get-all.ts new file mode 100644 index 000000000..3271b2d82 --- /dev/null +++ b/src/commands/spaces/members/get-all.ts @@ -0,0 +1,84 @@ +import type { SpaceMember } from "@ably/spaces"; +import { Args } from "@oclif/core"; + +import { productApiFlags, clientIdFlag } from "../../../flags.js"; +import { SpacesBaseCommand } from "../../../spaces-base-command.js"; +import { + formatCountLabel, + formatHeading, + formatIndex, + formatProgress, + formatResource, + formatWarning, +} from "../../../utils/output.js"; +import { + formatMemberBlock, + formatMemberOutput, +} from "../../../utils/spaces-output.js"; + +export default class SpacesMembersGetAll extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to get members from", + required: true, + }), + }; + + static override description = "Get all members in a space"; + + static override examples = [ + "$ ably spaces members get-all my-space", + "$ ably spaces members get-all my-space --json", + "$ ably spaces members get-all my-space --pretty-json", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesMembersGetAll); + const { space_name: spaceName } = args; + + try { + await this.initializeSpace(flags, spaceName, { + enterSpace: false, + setupConnectionLogging: false, + }); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Fetching members for space ${formatResource(spaceName)}`, + ), + ); + } + + const members: SpaceMember[] = await this.space!.members.getAll(); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + members: members.map((member) => formatMemberOutput(member)), + }, + flags, + ); + } else if (members.length === 0) { + this.logToStderr(formatWarning("No members currently in this space.")); + } else { + this.log( + `\n${formatHeading("Current members")} (${formatCountLabel(members.length, "member")}):\n`, + ); + + for (let i = 0; i < members.length; i++) { + this.log(`${formatIndex(i + 1)}`); + this.log(formatMemberBlock(members[i], { indent: " " })); + this.log(""); + } + } + } catch (error) { + this.fail(error, flags, "memberGetAll", { spaceName }); + } + } +} diff --git a/src/commands/spaces/subscribe.ts b/src/commands/spaces/subscribe.ts new file mode 100644 index 000000000..392097f01 --- /dev/null +++ b/src/commands/spaces/subscribe.ts @@ -0,0 +1,110 @@ +import type { SpaceMember } from "@ably/spaces"; +import { Args } from "@oclif/core"; + +import { productApiFlags, clientIdFlag, durationFlag } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { + formatCountLabel, + formatListening, + formatProgress, + formatResource, + formatSuccess, + formatTimestamp, +} from "../../utils/output.js"; +import { + formatMemberBlock, + formatMemberOutput, +} from "../../utils/spaces-output.js"; + +export default class SpacesSubscribe extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to subscribe to", + required: true, + }), + }; + + static override description = "Subscribe to space update events"; + + static override examples = [ + "$ ably spaces subscribe my-space", + "$ ably spaces subscribe my-space --json", + "$ ably spaces subscribe my-space --pretty-json", + "$ ably spaces subscribe my-space --duration 30", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + ...durationFlag, + }; + + private listener: ((spaceState: { members: SpaceMember[] }) => void) | null = + null; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesSubscribe); + const { space_name: spaceName } = args; + + try { + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Subscribing to space updates")); + } + + await this.initializeSpace(flags, spaceName, { enterSpace: false }); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), + ); + this.log(formatListening("Listening for space updates.")); + } + + this.logCliEvent( + flags, + "space", + "subscribing", + "Subscribing to space updates", + ); + + this.listener = (spaceState: { members: SpaceMember[] }) => { + const { members } = spaceState; + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent( + { + space: { + members: members.map((m) => formatMemberOutput(m)), + }, + }, + flags, + ); + } else { + this.log( + `Found ${formatCountLabel(members.length, "member")} on space: ${formatResource(spaceName)}`, + ); + + for (const member of members) { + this.log(formatTimestamp(new Date().toISOString())); + this.log(formatMemberBlock(member)); + this.log(""); + } + } + }; + + // space.subscribe() is synchronous (calls super.on()), no await needed + this.space!.subscribe("update", this.listener); + + this.logCliEvent( + flags, + "space", + "subscribed", + "Subscribed to space updates", + ); + + await this.waitAndTrackCleanup(flags, "space", flags.duration); + } catch (error) { + this.fail(error, flags, "spaceSubscribe"); + } + } +} diff --git a/src/utils/spaces-output.ts b/src/utils/spaces-output.ts index c54a698d8..46029afa5 100644 --- a/src/utils/spaces-output.ts +++ b/src/utils/spaces-output.ts @@ -112,7 +112,8 @@ export function formatMemberBlock( } lines.push( - `${indent}${formatLabel("Last Event")} ${member.lastEvent.name} at ${formatMessageTimestamp(member.lastEvent.timestamp)}`, + `${indent}${formatLabel("Last Event")} ${formatEventType(member.lastEvent.name)}`, + `${indent}${formatLabel("Event Timestamp")} ${formatMessageTimestamp(member.lastEvent.timestamp)}`, ); return lines.join("\n"); diff --git a/test/helpers/mock-ably-spaces.ts b/test/helpers/mock-ably-spaces.ts index 661d0a3aa..13fe1782d 100644 --- a/test/helpers/mock-ably-spaces.ts +++ b/test/helpers/mock-ably-spaces.ts @@ -110,10 +110,15 @@ export interface MockSpace { enter: Mock; leave: Mock; updateProfileData: Mock; + subscribe: Mock; + unsubscribe: Mock; + getState: Mock; members: MockSpaceMembers; locations: MockSpaceLocations; locks: MockSpaceLocks; cursors: MockSpaceCursors; + // Internal emitter for simulating space-level events + _emitter: AblyEventEmitter; } /** @@ -284,15 +289,33 @@ function createMockSpaceCursors(): MockSpaceCursors { * Create a mock space object. */ function createMockSpace(name: string): MockSpace { + const emitter = new EventEmitter(); + return { name, enter: vi.fn().mockImplementation(async () => {}), leave: vi.fn().mockImplementation(async () => {}), updateProfileData: vi.fn().mockImplementation(async () => {}), + subscribe: vi.fn((eventOrCallback, callback?) => { + const cb = callback ?? eventOrCallback; + const event = callback ? eventOrCallback : null; + emitter.on(event, cb); + }), + unsubscribe: vi.fn((eventOrCallback?, callback?) => { + if (!eventOrCallback) { + emitter.off(); + } else if (typeof eventOrCallback === "function") { + emitter.off(null, eventOrCallback); + } else if (callback) { + emitter.off(eventOrCallback, callback); + } + }), + getState: vi.fn().mockResolvedValue({ members: [] }), members: createMockSpaceMembers(), locations: createMockSpaceLocations(), locks: createMockSpaceLocks(), cursors: createMockSpaceCursors(), + _emitter: emitter, }; } diff --git a/test/unit/commands/spaces/create.test.ts b/test/unit/commands/spaces/create.test.ts new file mode 100644 index 000000000..24dbbc2e6 --- /dev/null +++ b/test/unit/commands/spaces/create.test.ts @@ -0,0 +1,77 @@ +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, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("spaces:create command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + standardHelpTests("spaces:create", import.meta.url); + standardArgValidationTests("spaces:create", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:create", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should create space and display success", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:create", "test-space"], + import.meta.url, + ); + + expect(space.enter).not.toHaveBeenCalled(); + expect(stdout).toContain("created"); + expect(stdout).toContain("test-space"); + }); + + it("should output JSON envelope with space name", async () => { + const { stdout } = await runCommand( + ["spaces:create", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "spaces:create"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("space"); + expect(result!.space).toHaveProperty("name", "test-space"); + }); + + it("should not enter the space", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand(["spaces:create", "test-space"], import.meta.url); + + expect(space.enter).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle connection errors gracefully", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock.get.mockRejectedValue(new Error("Connection failed")); + + const { error } = await runCommand( + ["spaces:create", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/spaces/get.test.ts b/test/unit/commands/spaces/get.test.ts new file mode 100644 index 000000000..f07b293f1 --- /dev/null +++ b/test/unit/commands/spaces/get.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("spaces:get command", () => { + beforeEach(() => { + const mock = getMockAblyRest(); + mock.request.mockClear(); + mock.request.mockResolvedValue({ + items: [ + { + clientId: "user-1", + connectionId: "conn-1", + action: "present", + timestamp: 1710000000000, + data: { + profileUpdate: { current: { name: "Alice" } }, + locationUpdate: { current: { slide: 1 } }, + }, + }, + ], + statusCode: 200, + }); + }); + + standardHelpTests("spaces:get", import.meta.url); + standardArgValidationTests("spaces:get", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:get", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should fetch space state and display members", async () => { + const mock = getMockAblyRest(); + + const { stdout } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledOnce(); + const [method, path, version, , body] = mock.request.mock.calls[0]; + expect(method).toBe("get"); + expect(path).toBe( + `/channels/${encodeURIComponent("test-space::$space")}/presence`, + ); + expect(version).toBe(2); + expect(body).toBeNull(); + + expect(stdout).toContain("test-space"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("Connection ID:"); + }); + + it("should output JSON envelope with space and members", async () => { + const { stdout } = await runCommand( + ["spaces:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "spaces:get"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("space"); + const space = result!.space as { name: string; members: unknown[] }; + expect(space).toHaveProperty("name", "test-space"); + expect(space.members).toBeInstanceOf(Array); + expect(space.members[0]).toHaveProperty("clientId", "user-1"); + expect(space.members[0]).toHaveProperty("profileData", { name: "Alice" }); + expect(space.members[0]).toHaveProperty("location", { slide: 1 }); + }); + + it("should correctly parse SDK internal data format", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + clientId: "user-2", + connectionId: "conn-2", + action: "present", + timestamp: 1710000000000, + data: { + profileUpdate: { current: { role: "admin" } }, + }, + }, + ], + statusCode: 200, + }); + + const { stdout } = await runCommand( + ["spaces:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + const space = result!.space as { + members: Array<{ profileData: unknown; location: unknown }>; + }; + expect(space.members[0].profileData).toEqual({ role: "admin" }); + expect(space.members[0].location).toBeNull(); + }); + + it("should fail when space has no members (empty presence)", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ items: [], statusCode: 200 }); + + const { error } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + expect(error?.message).toContain("doesn't have any members"); + }); + + it("should mark leave/absent members as not connected", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + clientId: "user-1", + connectionId: "conn-1", + action: "leave", + timestamp: 1710000000000, + data: {}, + }, + ], + statusCode: 200, + }); + + const { stdout } = await runCommand( + ["spaces:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + const space = result!.space as { + members: Array<{ isConnected: boolean }>; + }; + expect(space.members[0].isConnected).toBe(false); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + mock.request.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + + it("should handle non-200 status codes", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [], + statusCode: 500, + }); + + const { error } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + expect(error?.message).toContain("500"); + }); + }); +}); diff --git a/test/unit/commands/spaces/members/get-all.test.ts b/test/unit/commands/spaces/members/get-all.test.ts new file mode 100644 index 000000000..6abda8e2b --- /dev/null +++ b/test/unit/commands/spaces/members/get-all.test.ts @@ -0,0 +1,136 @@ +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, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("spaces:members:get-all command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + standardHelpTests("spaces:members:get-all", import.meta.url); + standardArgValidationTests("spaces:members:get-all", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:members:get-all", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should get all members from a space", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([ + { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(space.enter).not.toHaveBeenCalled(); + expect(space.members.getAll).toHaveBeenCalled(); + expect(stdout).toContain("members"); + }); + + it("should output JSON envelope with type and command for member results", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([ + { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const resultRecord = records.find( + (r) => r.type === "result" && Array.isArray(r.members), + ); + expect(resultRecord).toBeDefined(); + expect(resultRecord).toHaveProperty("type", "result"); + expect(resultRecord).toHaveProperty("command"); + expect(resultRecord).toHaveProperty("success", true); + expect(resultRecord!.members).toBeInstanceOf(Array); + expect(resultRecord!.members[0]).toHaveProperty("clientId", "user-1"); + expect(resultRecord!.members[0]).toHaveProperty("connectionId", "conn-1"); + }); + + it("should handle no members found", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(stdout).toContain("members"); + }); + + it("should display non-JSON output with member blocks", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([ + { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Current members"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Connection ID:"); + expect(stdout).toContain("conn-1"); + }); + }); + + describe("error handling", () => { + it("should handle errors gracefully", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockRejectedValue( + new Error("Failed to get members"), + ); + + const { error } = await runCommand( + ["spaces:members:get-all", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/spaces/subscribe.test.ts b/test/unit/commands/spaces/subscribe.test.ts new file mode 100644 index 000000000..2dc17d6c1 --- /dev/null +++ b/test/unit/commands/spaces/subscribe.test.ts @@ -0,0 +1,128 @@ +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 { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("spaces:subscribe command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + standardHelpTests("spaces:subscribe", import.meta.url); + standardArgValidationTests("spaces:subscribe", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:subscribe", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should subscribe to space updates and output events", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + space.subscribe.mockImplementation( + (event: string, cb: (state: 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() }, + }, + ], + }); + }, 10); + }, + ); + + const { stdout } = await runCommand( + ["spaces:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Connection ID:"); + expect(stdout).toContain("other-conn-1"); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to space 'update' event and not enter", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand(["spaces:subscribe", "test-space"], import.meta.url); + + expect(space.enter).not.toHaveBeenCalled(); + expect(space.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + }); + }); + + describe("JSON output", () => { + it("should output JSON event with members array", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + space.subscribe.mockImplementation( + (event: string, cb: (state: 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() }, + }, + ], + }); + }, 10); + }, + ); + + const { stdout } = await runCommand( + ["spaces:subscribe", "test-space", "--json"], + 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"); + }); + }); + + describe("error handling", () => { + it("should handle errors gracefully", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.subscribe.mockImplementation(() => { + throw new Error("Subscribe failed"); + }); + + const { error } = await runCommand( + ["spaces:subscribe", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + expect(error?.message).toContain("Subscribe failed"); + }); + }); +}); From 09f0316ca661b9b9277e799d1f9f28aaf36de0d9 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 20 Mar 2026 14:01:31 +0530 Subject: [PATCH 2/3] Addressed review comments on the PR --- README.md | 22 +++++++++--------- src/commands/spaces/create.ts | 14 ++++++++---- src/commands/spaces/members/get-all.ts | 3 +-- src/commands/spaces/subscribe.ts | 29 +++++++++++++++++------- test/unit/commands/spaces/create.test.ts | 2 +- 5 files changed, 44 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5969d6ff9..1972856d2 100644 --- a/README.md +++ b/README.md @@ -4478,7 +4478,7 @@ EXAMPLES $ ably spaces locations set my-space COMMANDS - ably spaces create Create a new space + ably spaces create Initialize a space ably spaces cursors Commands for interacting with Cursors in Ably Spaces ably spaces get Get the current state of a space ably spaces list List active spaces @@ -4493,14 +4493,14 @@ _See code: [src/commands/spaces/index.ts](https://github.com/ably/ably-cli/blob/ ## `ably spaces create SPACE_NAME` -Create a new space +Initialize a space ``` USAGE $ ably spaces create SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] ARGUMENTS - SPACE_NAME Name of the space to create + SPACE_NAME Name of the space to initialize FLAGS -v, --verbose Output verbose logs @@ -4510,7 +4510,9 @@ FLAGS --pretty-json Output in colorized JSON format DESCRIPTION - Create a new space + Initializes a space. Spaces are backed by Ably channel '{spaceName}::$space' and are ephemeral — + they become active when members enter. This command initializes the space without entering it. + Use 'ably spaces members enter SPACE_NAME' to add a member to the space. EXAMPLES $ ably spaces create my-space @@ -5056,17 +5058,15 @@ Get all members in a space ``` USAGE - $ ably spaces members get-all SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] + $ ably spaces members get-all SPACE_NAME [-v] [--json | --pretty-json] ARGUMENTS SPACE_NAME Name of the space to get members from FLAGS - -v, --verbose Output verbose logs - --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set - no client ID. Not applicable when using token authentication. - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format DESCRIPTION Get all members in a space @@ -5215,7 +5215,7 @@ FLAGS --pretty-json Output in colorized JSON format DESCRIPTION - Subscribe to space update events + Subscribe to both spaces members and location update events EXAMPLES $ ably spaces subscribe my-space diff --git a/src/commands/spaces/create.ts b/src/commands/spaces/create.ts index 0ae9e0b07..cb3f6a8d7 100644 --- a/src/commands/spaces/create.ts +++ b/src/commands/spaces/create.ts @@ -11,12 +11,12 @@ import { export default class SpacesCreate extends SpacesBaseCommand { static override args = { space_name: Args.string({ - description: "Name of the space to create", + description: "Name of the space to initialize", required: true, }), }; - static override description = "Create a new space"; + static override description = `Initializes a space. Spaces are backed by Ably channel '{spaceName}::$space' and are ephemeral — they become active when members enter. This command initializes the space without entering it. Use 'ably spaces members enter SPACE_NAME' to add a member to the space.`; static override examples = [ "$ ably spaces create my-space", @@ -35,7 +35,9 @@ export default class SpacesCreate extends SpacesBaseCommand { try { if (!this.shouldOutputJson(flags)) { - this.log(formatProgress(`Creating space ${formatResource(spaceName)}`)); + this.log( + formatProgress(`Initializing space ${formatResource(spaceName)}`), + ); } await this.initializeSpace(flags, spaceName, { @@ -46,7 +48,11 @@ export default class SpacesCreate extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ space: { name: spaceName } }, flags); } else { - this.log(formatSuccess(`Space ${formatResource(spaceName)} created.`)); + this.log( + formatSuccess( + `Space ${formatResource(spaceName)} initialized. Use ${formatResource("ably spaces members enter")} to activate it.`, + ), + ); } } catch (error) { this.fail(error, flags, "spaceCreate"); diff --git a/src/commands/spaces/members/get-all.ts b/src/commands/spaces/members/get-all.ts index 3271b2d82..4d7f45e3e 100644 --- a/src/commands/spaces/members/get-all.ts +++ b/src/commands/spaces/members/get-all.ts @@ -1,7 +1,7 @@ import type { SpaceMember } from "@ably/spaces"; import { Args } from "@oclif/core"; -import { productApiFlags, clientIdFlag } from "../../../flags.js"; +import { productApiFlags } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { formatCountLabel, @@ -34,7 +34,6 @@ export default class SpacesMembersGetAll extends SpacesBaseCommand { static override flags = { ...productApiFlags, - ...clientIdFlag, }; async run(): Promise { diff --git a/src/commands/spaces/subscribe.ts b/src/commands/spaces/subscribe.ts index 392097f01..6b8024e86 100644 --- a/src/commands/spaces/subscribe.ts +++ b/src/commands/spaces/subscribe.ts @@ -24,7 +24,8 @@ export default class SpacesSubscribe extends SpacesBaseCommand { }), }; - static override description = "Subscribe to space update events"; + static override description = + "Subscribe to both spaces members and location update events"; static override examples = [ "$ ably spaces subscribe my-space", @@ -42,6 +43,18 @@ export default class SpacesSubscribe extends SpacesBaseCommand { 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; @@ -53,13 +66,6 @@ export default class SpacesSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: false }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), - ); - this.log(formatListening("Listening for space updates.")); - } - this.logCliEvent( flags, "space", @@ -95,6 +101,13 @@ export default class SpacesSubscribe extends SpacesBaseCommand { // space.subscribe() is synchronous (calls super.on()), no await needed this.space!.subscribe("update", this.listener); + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), + ); + this.log(formatListening("Listening for space updates.")); + } + this.logCliEvent( flags, "space", diff --git a/test/unit/commands/spaces/create.test.ts b/test/unit/commands/spaces/create.test.ts index 24dbbc2e6..7d59458f9 100644 --- a/test/unit/commands/spaces/create.test.ts +++ b/test/unit/commands/spaces/create.test.ts @@ -32,7 +32,7 @@ describe("spaces:create command", () => { ); expect(space.enter).not.toHaveBeenCalled(); - expect(stdout).toContain("created"); + expect(stdout).toContain("initialized"); expect(stdout).toContain("test-space"); }); From dfd93603cd92da9880e022f494282e33539109ba Mon Sep 17 00:00:00 2001 From: umair Date: Fri, 20 Mar 2026 12:37:07 +0000 Subject: [PATCH 3/3] Fix spaces get numeric action codes, subscribe timestamp placement, and create description verbosity --- README.md | 12 +++--- src/commands/spaces/create.ts | 4 +- src/commands/spaces/get.ts | 47 +++++++++++++-------- src/commands/spaces/subscribe.ts | 3 +- test/unit/commands/spaces/subscribe.test.ts | 4 -- 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1972856d2..22f37a3c7 100644 --- a/README.md +++ b/README.md @@ -4478,7 +4478,7 @@ EXAMPLES $ ably spaces locations set my-space COMMANDS - ably spaces create Initialize a space + ably spaces create Initialize a space without entering it ably spaces cursors Commands for interacting with Cursors in Ably Spaces ably spaces get Get the current state of a space ably spaces list List active spaces @@ -4486,14 +4486,14 @@ COMMANDS ably spaces locks Commands for component locking in Ably Spaces ably spaces members Commands for managing members in Ably Spaces ably spaces occupancy Commands for working with occupancy in Ably Spaces - ably spaces subscribe Subscribe to space update events + ably spaces subscribe Subscribe to both spaces members and location update events ``` _See code: [src/commands/spaces/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/index.ts)_ ## `ably spaces create SPACE_NAME` -Initialize a space +Initialize a space without entering it ``` USAGE @@ -4510,9 +4510,7 @@ FLAGS --pretty-json Output in colorized JSON format DESCRIPTION - Initializes a space. Spaces are backed by Ably channel '{spaceName}::$space' and are ephemeral — - they become active when members enter. This command initializes the space without entering it. - Use 'ably spaces members enter SPACE_NAME' to add a member to the space. + Initialize a space without entering it EXAMPLES $ ably spaces create my-space @@ -5197,7 +5195,7 @@ _See code: [src/commands/spaces/occupancy/subscribe.ts](https://github.com/ably/ ## `ably spaces subscribe SPACE_NAME` -Subscribe to space update events +Subscribe to both spaces members and location update events ``` USAGE diff --git a/src/commands/spaces/create.ts b/src/commands/spaces/create.ts index cb3f6a8d7..9d4af4ace 100644 --- a/src/commands/spaces/create.ts +++ b/src/commands/spaces/create.ts @@ -16,7 +16,7 @@ export default class SpacesCreate extends SpacesBaseCommand { }), }; - static override description = `Initializes a space. Spaces are backed by Ably channel '{spaceName}::$space' and are ephemeral — they become active when members enter. This command initializes the space without entering it. Use 'ably spaces members enter SPACE_NAME' to add a member to the space.`; + static override description = "Initialize a space without entering it"; static override examples = [ "$ ably spaces create my-space", @@ -50,7 +50,7 @@ export default class SpacesCreate extends SpacesBaseCommand { } else { this.log( formatSuccess( - `Space ${formatResource(spaceName)} initialized. Use ${formatResource("ably spaces members enter")} to activate it.`, + `Space ${formatResource(spaceName)} initialized. Use "ably spaces members enter" to activate it.`, ), ); } diff --git a/src/commands/spaces/get.ts b/src/commands/spaces/get.ts index a4478c688..b66474638 100644 --- a/src/commands/spaces/get.ts +++ b/src/commands/spaces/get.ts @@ -13,13 +13,30 @@ import { formatProgress, formatResource, } from "../../utils/output.js"; +import type { MemberOutput } from "../../utils/spaces-output.js"; const SPACE_CHANNEL_TAG = "::$space"; +const PRESENCE_ACTION_MAP: Record = { + 0: "absent", + 1: "present", + 2: "enter", + 3: "leave", + 4: "update", +}; + +function resolvePresenceAction(action: number | string): string { + if (typeof action === "string") { + return action; + } + + return PRESENCE_ACTION_MAP[action] ?? String(action); +} + interface PresenceItem { clientId: string; connectionId: string; - action: string; + action: number | string; timestamp: number; data?: { profileUpdate?: { current?: Record }; @@ -27,15 +44,6 @@ interface PresenceItem { }; } -interface MemberInfo { - clientId: string; - connectionId: string; - isConnected: boolean; - profileData: Record | null; - location: unknown | null; - lastEvent: { name: string; timestamp: number }; -} - export default class SpacesGet extends SpacesBaseCommand { static override args = { space_name: Args.string({ @@ -100,14 +108,17 @@ export default class SpacesGet extends SpacesBaseCommand { ); } - const members: MemberInfo[] = items.map((item) => ({ - clientId: item.clientId, - connectionId: item.connectionId, - isConnected: item.action !== "leave" && item.action !== "absent", - profileData: item.data?.profileUpdate?.current ?? null, - location: item.data?.locationUpdate?.current ?? null, - lastEvent: { name: item.action, timestamp: item.timestamp }, - })); + const members: MemberOutput[] = items.map((item) => { + const action = resolvePresenceAction(item.action); + return { + clientId: item.clientId, + connectionId: item.connectionId, + isConnected: action !== "leave" && action !== "absent", + profileData: item.data?.profileUpdate?.current ?? null, + location: item.data?.locationUpdate?.current ?? null, + lastEvent: { name: action, timestamp: item.timestamp }, + }; + }); if (this.shouldOutputJson(flags)) { this.logJsonResult( diff --git a/src/commands/spaces/subscribe.ts b/src/commands/spaces/subscribe.ts index 6b8024e86..32f471fa3 100644 --- a/src/commands/spaces/subscribe.ts +++ b/src/commands/spaces/subscribe.ts @@ -87,11 +87,10 @@ export default class SpacesSubscribe extends SpacesBaseCommand { ); } else { this.log( - `Found ${formatCountLabel(members.length, "member")} on space: ${formatResource(spaceName)}`, + `${formatTimestamp(new Date().toISOString())} Found ${formatCountLabel(members.length, "member")} on space: ${formatResource(spaceName)}`, ); for (const member of members) { - this.log(formatTimestamp(new Date().toISOString())); this.log(formatMemberBlock(member)); this.log(""); } diff --git a/test/unit/commands/spaces/subscribe.test.ts b/test/unit/commands/spaces/subscribe.test.ts index 2dc17d6c1..6aacc7968 100644 --- a/test/unit/commands/spaces/subscribe.test.ts +++ b/test/unit/commands/spaces/subscribe.test.ts @@ -54,9 +54,7 @@ describe("spaces:subscribe command", () => { expect(stdout).toContain("Connection ID:"); expect(stdout).toContain("other-conn-1"); }); - }); - describe("subscription behavior", () => { it("should subscribe to space 'update' event and not enter", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); @@ -69,9 +67,7 @@ describe("spaces:subscribe command", () => { expect.any(Function), ); }); - }); - describe("JSON output", () => { it("should output JSON event with members array", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space");