From eecc67a631d66085112ce927814d9b3f24b155f4 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 20 Mar 2026 17:02:14 +0530 Subject: [PATCH 1/5] Implemented rooms presence get-all, rooms presence update commands --- README.md | 71 +++++ src/commands/rooms/presence/enter.ts | 76 ++--- src/commands/rooms/presence/get-all.ts | 173 +++++++++++ src/commands/rooms/presence/update.ts | 162 ++++++++++ .../commands/rooms/presence/enter.test.ts | 286 ++++++++++-------- .../commands/rooms/presence/get-all.test.ts | 203 +++++++++++++ .../commands/rooms/presence/update.test.ts | 158 ++++++++++ 7 files changed, 967 insertions(+), 162 deletions(-) create mode 100644 src/commands/rooms/presence/get-all.ts create mode 100644 src/commands/rooms/presence/update.ts create mode 100644 test/unit/commands/rooms/presence/get-all.test.ts create mode 100644 test/unit/commands/rooms/presence/update.test.ts diff --git a/README.md b/README.md index b4e1a102..0411c268 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ $ ably-interactive * [`ably rooms occupancy subscribe ROOM`](#ably-rooms-occupancy-subscribe-room) * [`ably rooms presence`](#ably-rooms-presence) * [`ably rooms presence enter ROOM`](#ably-rooms-presence-enter-room) +* [`ably rooms presence get-all ROOM`](#ably-rooms-presence-get-all-room) * [`ably rooms presence subscribe ROOM`](#ably-rooms-presence-subscribe-room) +* [`ably rooms presence update ROOM`](#ably-rooms-presence-update-room) * [`ably rooms reactions`](#ably-rooms-reactions) * [`ably rooms reactions send ROOM EMOJI`](#ably-rooms-reactions-send-room-emoji) * [`ably rooms reactions subscribe ROOM`](#ably-rooms-reactions-subscribe-room) @@ -2033,6 +2035,8 @@ EXAMPLES $ ably channels presence update my-channel --data '{"status":"busy"}' --json + $ ably channels presence update my-channel --data '{"status":"busy"}' --pretty-json + $ ably channels presence update my-channel --data '{"status":"online"}' --duration 60 ``` @@ -4241,6 +4245,38 @@ EXAMPLES _See code: [src/commands/rooms/presence/enter.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/enter.ts)_ +## `ably rooms presence get-all ROOM` + +Get all current presence members in a chat room + +``` +USAGE + $ ably rooms presence get-all ROOM [-v] [--json | --pretty-json] [--limit ] + +ARGUMENTS + ROOM Room to get presence members for + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --limit= [default: 100] Maximum number of results to return + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get all current presence members in a chat room + +EXAMPLES + $ ably rooms presence get-all my-room + + $ ably rooms presence get-all my-room --limit 50 + + $ ably rooms presence get-all my-room --json + + $ ably rooms presence get-all my-room --pretty-json +``` + +_See code: [src/commands/rooms/presence/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/get-all.ts)_ + ## `ably rooms presence subscribe ROOM` Subscribe to presence events in a chat room @@ -4273,6 +4309,41 @@ EXAMPLES _See code: [src/commands/rooms/presence/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/subscribe.ts)_ +## `ably rooms presence update ROOM` + +Update presence data in a chat room + +``` +USAGE + $ ably rooms presence update ROOM --data [-v] [--json | --pretty-json] [--client-id ] [-D ] + +ARGUMENTS + ROOM Room to update presence in + +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. + --data= (required) JSON data to associate with the presence update + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Update presence data in a chat room + +EXAMPLES + $ ably rooms presence update my-room --data '{"status":"away"}' + + $ ably rooms presence update my-room --data '{"status":"busy"}' --json + + $ ably rooms presence update my-room --data '{"status":"busy"}' --pretty-json + + $ ably rooms presence update my-room --data '{"status":"online"}' --duration 60 +``` + +_See code: [src/commands/rooms/presence/update.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/update.ts)_ + ## `ably rooms reactions` Manage reactions in Ably chat rooms diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index dbe7b616..1a36d709 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -5,6 +5,7 @@ import { ChatBaseCommand } from "../../../chat-base-command.js"; import { formatSuccess, formatListening, + formatProgress, formatResource, formatTimestamp, formatPresenceAction, @@ -148,70 +149,59 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { } await currentRoom.attach(); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Entering presence in room: ${formatResource(this.roomName)}`, + ), + ); + } + this.logCliEvent(flags, "presence", "entering", "Entering presence", { data: this.data, }); await currentRoom.presence.enter(this.data || {}); this.logCliEvent(flags, "presence", "entered", "Entered presence"); - if (!this.shouldOutputJson(flags) && this.roomName) { + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + presenceMessage: { + action: "enter", + room: this.roomName, + clientId: this.chatClient!.clientId, + data: this.data ?? null, + timestamp: new Date().toISOString(), + }, + }, + flags, + ); + } else { this.log( formatSuccess( - `Entered presence in room: ${formatResource(this.roomName)}.`, + `Entered presence in room: ${formatResource(this.roomName!)}.`, ), ); if (flags["show-others"]) { - this.log(`\n${formatListening("Listening for presence events.")}`); + this.log(formatListening("Listening for presence events.")); } else { - this.log(`\n${formatListening("Staying present.")}`); + this.log(formatListening("Staying present.")); } } + this.logJsonStatus( + "holding", + "Holding presence. Press Ctrl+C to exit.", + flags, + ); + // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "presence", flags.duration); } catch (error) { this.fail(error, flags, "roomPresenceEnter", { room: this.roomName, }); - } finally { - const currentFlags = this.commandFlags || flags || {}; - this.logCliEvent( - currentFlags, - "presence", - "finallyBlockReached", - "Reached finally block for cleanup.", - ); - - if (!this.cleanupInProgress && !this.shouldOutputJson(currentFlags)) { - this.logCliEvent( - currentFlags, - "presence", - "implicitCleanupInFinally", - "Performing cleanup in finally (no prior signal or natural end).", - ); - } else { - // Either cleanup is in progress or we're in JSON mode - this.logCliEvent( - currentFlags, - "presence", - "explicitCleanupOrJsonMode", - "Cleanup already in progress or JSON output mode", - ); - } - - if (!this.shouldOutputJson(currentFlags)) { - if (this.cleanupInProgress) { - this.log(formatSuccess("Graceful shutdown complete.")); - } else { - // Normal completion without user interrupt - this.logCliEvent( - currentFlags, - "presence", - "completedNormally", - "Command completed normally", - ); - } - } } } } diff --git a/src/commands/rooms/presence/get-all.ts b/src/commands/rooms/presence/get-all.ts new file mode 100644 index 00000000..a09d7e48 --- /dev/null +++ b/src/commands/rooms/presence/get-all.ts @@ -0,0 +1,173 @@ +import { Args, Flags } from "@oclif/core"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { productApiFlags } from "../../../flags.js"; +import { + formatClientId, + formatCountLabel, + formatHeading, + formatIndex, + formatLabel, + formatLimitWarning, + formatMessageTimestamp, + formatProgress, + formatResource, + formatWarning, +} from "../../../utils/output.js"; +import { + buildPaginationNext, + collectPaginatedResults, + formatPaginationLog, +} from "../../../utils/pagination.js"; + +// Chat SDK maps room presence to the underlying channel: roomName::$chat +const chatChannelName = (roomName: string) => `${roomName}::$chat`; + +export default class RoomsPresenceGetAll extends AblyBaseCommand { + static override args = { + room: Args.string({ + description: "Room to get presence members for", + required: true, + }), + }; + + static override description = + "Get all current presence members in a chat room"; + + static override examples = [ + "$ ably rooms presence get-all my-room", + "$ ably rooms presence get-all my-room --limit 50", + "$ ably rooms presence get-all my-room --json", + "$ ably rooms presence get-all my-room --pretty-json", + ]; + + static override flags = { + ...productApiFlags, + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return", + min: 1, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(RoomsPresenceGetAll); + + try { + const client = await this.createAblyRestClient(flags); + if (!client) return; + + const { room: roomName } = args; + const channelName = chatChannelName(roomName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Fetching presence members for room: ${formatResource(roomName)}`, + ), + ); + } + + this.logCliEvent( + flags, + "presence", + "fetching", + `Fetching presence members for room ${roomName}`, + { room: roomName }, + ); + + const firstPage = await client.channels + .get(channelName) + .presence.get({ limit: flags.limit }); + + const { items, hasMore, pagesConsumed } = await collectPaginatedResults( + firstPage, + flags.limit, + ); + + this.logCliEvent( + flags, + "presence", + "fetched", + `Fetched ${items.length} presence members`, + { room: roomName, count: items.length }, + ); + + // Show pagination warning early (before main output) + const paginationWarning = formatPaginationLog( + pagesConsumed, + items.length, + ); + if (paginationWarning && !this.shouldOutputJson(flags)) { + this.log(paginationWarning); + } + + if (this.shouldOutputJson(flags)) { + const presenceMembers = items.map((member) => ({ + clientId: member.clientId, + connectionId: member.connectionId, + data: member.data ?? null, + extras: member.extras ?? null, + updatedAt: formatMessageTimestamp(member.timestamp), + })); + const next = buildPaginationNext(hasMore); + this.logJsonResult( + { + presenceMembers, + hasMore, + ...(next && { next }), + total: items.length, + }, + flags, + ); + } else if (items.length === 0) { + this.logToStderr( + formatWarning("No members currently present in this room."), + ); + } else { + this.log( + `\n${formatHeading(`Presence members in room: ${roomName}`)} (${formatCountLabel(items.length, "member")}):\n`, + ); + + for (let i = 0; i < items.length; i++) { + const member = items[i]; + this.log(`${formatIndex(i + 1)}`); + this.log( + ` ${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, + ); + this.log(` ${formatLabel("Connection ID")} ${member.connectionId}`); + if (member.data !== null && member.data !== undefined) { + this.log(` ${formatLabel("Data")} ${JSON.stringify(member.data)}`); + } + if ( + member.extras !== null && + member.extras !== undefined && + typeof member.extras === "object" && + Object.keys(member.extras).length > 0 + ) { + this.log( + ` ${formatLabel("Extras")} ${JSON.stringify(member.extras)}`, + ); + } + this.log( + ` ${formatLabel("Updated At")} ${formatMessageTimestamp(member.timestamp)}`, + ); + this.log(""); + } + + if (hasMore) { + const warning = formatLimitWarning( + items.length, + flags.limit, + "members", + ); + if (warning) this.log(warning); + } + } + } catch (error) { + this.fail(error, flags, "roomPresenceGetAll", { + room: args.room, + }); + } + } +} diff --git a/src/commands/rooms/presence/update.ts b/src/commands/rooms/presence/update.ts new file mode 100644 index 00000000..fa6c0303 --- /dev/null +++ b/src/commands/rooms/presence/update.ts @@ -0,0 +1,162 @@ +import { ChatClient, PresenceData, Room } from "@ably/chat"; +import { Args, Flags, Interfaces } from "@oclif/core"; + +import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; +import { + formatClientId, + formatLabel, + formatListening, + formatProgress, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; + +export default class RoomsPresenceUpdate extends ChatBaseCommand { + static override args = { + room: Args.string({ + description: "Room to update presence in", + required: true, + }), + }; + + static override description = "Update presence data in a chat room"; + + static override examples = [ + `$ ably rooms presence update my-room --data '{"status":"away"}'`, + `$ ably rooms presence update my-room --data '{"status":"busy"}' --json`, + `$ ably rooms presence update my-room --data '{"status":"busy"}' --pretty-json`, + `$ ably rooms presence update my-room --data '{"status":"online"}' --duration 60`, + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + data: Flags.string({ + description: "JSON data to associate with the presence update", + required: true, + }), + ...durationFlag, + }; + + private chatClient: ChatClient | null = null; + private room: Room | null = null; + private commandFlags: Interfaces.InferredFlags< + typeof RoomsPresenceUpdate.flags + > | null = null; + + async run(): Promise { + const { args, flags } = await this.parse(RoomsPresenceUpdate); + this.commandFlags = flags; + + try { + this.chatClient = await this.createChatClient(flags); + + if (!this.chatClient) { + return this.fail( + "Failed to create Chat client", + flags, + "roomPresenceUpdate", + ); + } + + const { room: roomName } = args; + const data = this.parseJsonFlag( + flags.data, + "data", + flags, + ) as PresenceData; + + this.setupConnectionStateLogging(this.chatClient.realtime, flags, { + includeUserFriendlyMessages: true, + }); + + this.room = await this.chatClient.rooms.get(roomName); + + this.setupRoomStatusHandler(this.room, flags, { + roomName, + successMessage: `Connected to room: ${formatResource(roomName)}.`, + }); + + await this.room.attach(); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Entering and updating presence in room: ${formatResource(roomName)}`, + ), + ); + } + + this.logCliEvent( + flags, + "presence", + "entering", + `Entering presence in room ${roomName}`, + { room: roomName, clientId: this.chatClient.clientId }, + ); + await this.room.presence.enter(data); + this.logCliEvent( + flags, + "presence", + "entered", + `Entered presence in room ${roomName}`, + { room: roomName, clientId: this.chatClient.clientId }, + ); + + this.logCliEvent( + flags, + "presence", + "updating", + `Updating presence data in room ${roomName}`, + { room: roomName, data }, + ); + await this.room.presence.update(data); + this.logCliEvent( + flags, + "presence", + "updated", + `Updated presence data in room ${roomName}`, + { room: roomName, clientId: this.chatClient.clientId, data }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + presenceMessage: { + action: "update", + room: roomName, + clientId: this.chatClient.clientId, + data, + timestamp: new Date().toISOString(), + }, + }, + flags, + ); + } else { + this.log( + formatSuccess( + `Updated presence in room: ${formatResource(roomName)}.`, + ), + ); + this.log( + `${formatLabel("Client ID")} ${formatClientId(this.chatClient.clientId ?? "unknown")}`, + ); + this.log(`${formatLabel("Data")} ${JSON.stringify(data)}`); + this.log(formatListening("Holding presence.")); + } + + this.logJsonStatus( + "holding", + "Holding presence. Press Ctrl+C to exit.", + flags, + ); + + await this.waitAndTrackCleanup(flags, "presence", flags.duration); + } catch (error) { + this.fail(error, flags, "roomPresenceUpdate", { + room: args.room, + }); + } + } +} diff --git a/test/unit/commands/rooms/presence/enter.test.ts b/test/unit/commands/rooms/presence/enter.test.ts index d6659552..5ee1d1ac 100644 --- a/test/unit/commands/rooms/presence/enter.test.ts +++ b/test/unit/commands/rooms/presence/enter.test.ts @@ -23,136 +23,96 @@ describe("rooms:presence:enter command", () => { "--show-others", ]); - it("should enter presence in room", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - - await runCommand(["rooms:presence:enter", "test-room"], import.meta.url); - - expect(mock.rooms.get).toHaveBeenCalledWith("test-room"); - expect(room.attach).toHaveBeenCalled(); - expect(room.presence.enter).toHaveBeenCalled(); - }); - - it("should pass parsed --data to presence.enter", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); + describe("functionality", () => { + it("should enter presence in room", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); - await runCommand( - ["rooms:presence:enter", "test-room", "--data", '{"status":"online"}'], - import.meta.url, - ); + await runCommand(["rooms:presence:enter", "test-room"], import.meta.url); - expect(room.presence.enter).toHaveBeenCalledWith({ status: "online" }); - }); + expect(mock.rooms.get).toHaveBeenCalledWith("test-room"); + expect(room.attach).toHaveBeenCalled(); + expect(room.presence.enter).toHaveBeenCalled(); + }); - it("should strip shell quotes from --data", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - - await runCommand( - [ - "rooms:presence:enter", - "test-room", - "--data", - '\'{"status":"online"}\'', - ], - import.meta.url, - ); - - expect(room.presence.enter).toHaveBeenCalledWith({ status: "online" }); - }); + it("should show progress message", async () => { + const { stdout } = await runCommand( + ["rooms:presence:enter", "test-room"], + import.meta.url, + ); - it("should error on invalid --data JSON", async () => { - const { error } = await runCommand( - ["rooms:presence:enter", "test-room", "--data", "not-json"], - import.meta.url, - ); + expect(stdout).toContain("Entering presence in room"); + expect(stdout).toContain("test-room"); + }); - expect(error).toBeDefined(); - expect(error?.message).toContain("Invalid data JSON"); - }); + it("should pass parsed --data to presence.enter", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); - it("should subscribe to presence events with --show-others", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); + await runCommand( + ["rooms:presence:enter", "test-room", "--data", '{"status":"online"}'], + import.meta.url, + ); - await runCommand( - ["rooms:presence:enter", "test-room", "--show-others"], - import.meta.url, - ); + expect(room.presence.enter).toHaveBeenCalledWith({ status: "online" }); + }); - expect(room.presence.subscribe).toHaveBeenCalled(); - }); + it("should strip shell quotes from --data", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); - it("should filter out self events by clientId with --show-others", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - const capturedLogs: string[] = []; + await runCommand( + [ + "rooms:presence:enter", + "test-room", + "--data", + '\'{"status":"online"}\'', + ], + import.meta.url, + ); - const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { - capturedLogs.push(String(msg)); + expect(room.presence.enter).toHaveBeenCalledWith({ status: "online" }); }); - let presenceCallback: ((event: unknown) => void) | null = null; - room.presence.subscribe.mockImplementation((callback) => { - presenceCallback = callback; - return { unsubscribe: vi.fn() }; + it("should error on invalid --data JSON", async () => { + const { error } = await runCommand( + ["rooms:presence:enter", "test-room", "--data", "not-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Invalid data JSON"); }); - const commandPromise = runCommand( - ["rooms:presence:enter", "test-room", "--show-others"], - import.meta.url, - ); - - await vi.waitFor( - () => { - expect(room.presence.subscribe).toHaveBeenCalled(); - }, - { timeout: 1000 }, - ); - - // Simulate a self event (should be filtered out) - if (presenceCallback) { - presenceCallback({ - type: "enter", - member: { - clientId: mock.clientId, - data: {}, - }, - }); + it("should subscribe to presence events with --show-others", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); - // Simulate another user's event (should be shown) - presenceCallback({ - type: "enter", - member: { - clientId: "other-user", - data: {}, - }, - }); - } + await runCommand( + ["rooms:presence:enter", "test-room", "--show-others"], + import.meta.url, + ); - await commandPromise; - logSpy.mockRestore(); + expect(room.presence.subscribe).toHaveBeenCalled(); + }); - const output = capturedLogs.join("\n"); - expect(output).toContain("other-user"); - expect(output).not.toContain(mock.clientId); - }); + it("should filter out self events by clientId with --show-others", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + const capturedLogs: string[] = []; - it("should output JSON on enter success", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); - let presenceCallback: ((event: unknown) => void) | null = null; - room.presence.subscribe.mockImplementation((callback) => { - presenceCallback = callback; - return { unsubscribe: vi.fn() }; - }); + let presenceCallback: ((event: unknown) => void) | null = null; + room.presence.subscribe.mockImplementation((callback) => { + presenceCallback = callback; + return { unsubscribe: vi.fn() }; + }); - const allRecords = await captureJsonLogs(async () => { const commandPromise = runCommand( - ["rooms:presence:enter", "test-room", "--show-others", "--json"], + ["rooms:presence:enter", "test-room", "--show-others"], import.meta.url, ); @@ -163,29 +123,117 @@ describe("rooms:presence:enter command", () => { { timeout: 1000 }, ); - // Simulate a presence event from another user + // Simulate a self event (should be filtered out) if (presenceCallback) { + presenceCallback({ + type: "enter", + member: { + clientId: mock.clientId, + data: {}, + }, + }); + + // Simulate another user's event (should be shown) presenceCallback({ type: "enter", member: { clientId: "other-user", - data: { status: "online" }, + data: {}, }, }); } await commandPromise; + logSpy.mockRestore(); + + const output = capturedLogs.join("\n"); + expect(output).toContain("other-user"); + expect(output).not.toContain(mock.clientId); }); - // Find the JSON output with presence data - const records = allRecords.filter((r) => r.type === "event" && r.member); + it("should output JSON result on enter success", async () => { + const allRecords = await captureJsonLogs(async () => { + await runCommand( + ["rooms:presence:enter", "test-room", "--json"], + import.meta.url, + ); + }); + + const results = allRecords.filter((r) => r.type === "result"); + expect(results.length).toBeGreaterThanOrEqual(1); - expect(records.length).toBeGreaterThan(0); - const parsed = records[0]; - expect(parsed).toHaveProperty("command"); - expect(parsed).toHaveProperty("type", "event"); - expect(parsed).toHaveProperty("eventType", "enter"); - expect(parsed.member).toHaveProperty("clientId", "other-user"); + const result = results[0]; + expect(result.presenceMessage).toBeDefined(); + const msg = result.presenceMessage as Record; + expect(msg.action).toBe("enter"); + expect(msg.room).toBe("test-room"); + expect(msg.clientId).toBeDefined(); + expect(msg.data).toBeNull(); + }); + + it("should emit hold status in JSON mode", async () => { + const allRecords = await captureJsonLogs(async () => { + await runCommand( + ["rooms:presence:enter", "test-room", "--json"], + import.meta.url, + ); + }); + + const statusRecords = allRecords.filter((r) => r.type === "status"); + expect(statusRecords.length).toBeGreaterThanOrEqual(1); + + const status = statusRecords[0]; + expect(status.status).toBe("holding"); + expect(status.message).toContain("Holding presence"); + }); + + it("should output JSON events with --show-others", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + let presenceCallback: ((event: unknown) => void) | null = null; + room.presence.subscribe.mockImplementation((callback) => { + presenceCallback = callback; + return { unsubscribe: vi.fn() }; + }); + + const allRecords = await captureJsonLogs(async () => { + const commandPromise = runCommand( + ["rooms:presence:enter", "test-room", "--show-others", "--json"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(room.presence.subscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a presence event from another user + if (presenceCallback) { + presenceCallback({ + type: "enter", + member: { + clientId: "other-user", + data: { status: "online" }, + }, + }); + } + + await commandPromise; + }); + + // Find the JSON output with presence event data + const records = allRecords.filter((r) => r.type === "event" && r.member); + + expect(records.length).toBeGreaterThan(0); + const parsed = records[0]; + expect(parsed).toHaveProperty("command"); + expect(parsed).toHaveProperty("type", "event"); + expect(parsed).toHaveProperty("eventType", "enter"); + expect(parsed.member).toHaveProperty("clientId", "other-user"); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/rooms/presence/get-all.test.ts b/test/unit/commands/rooms/presence/get-all.test.ts new file mode 100644 index 00000000..84722f90 --- /dev/null +++ b/test/unit/commands/rooms/presence/get-all.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { + getMockAblyRest, + createMockPaginatedResult, +} from "../../../../helpers/mock-ably-rest.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +const mockPresenceMembers = [ + { + clientId: "user-1", + connectionId: "conn-1", + action: "present", + data: { status: "online" }, + timestamp: 1710835200000, + id: "msg-1", + encoding: "", + extras: { role: "admin" }, + }, + { + clientId: "user-2", + connectionId: "conn-2", + action: "present", + data: null, + timestamp: 1710835260000, + id: "msg-2", + encoding: "", + }, +]; + +describe("rooms:presence:get-all command", () => { + beforeEach(() => { + const mock = getMockAblyRest(); + // Chat SDK maps room presence to roomName::$chat channel + const channel = mock.channels._getChannel("test-room::$chat"); + channel.presence.get.mockResolvedValue( + createMockPaginatedResult(mockPresenceMembers), + ); + }); + + standardHelpTests("rooms:presence:get-all", import.meta.url); + standardArgValidationTests("rooms:presence:get-all", import.meta.url, { + requiredArgs: ["test-room"], + }); + standardFlagTests("rooms:presence:get-all", import.meta.url, [ + "--limit", + "--json", + "--pretty-json", + ]); + + describe("functionality", () => { + it("should fetch and display presence members", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-room::$chat"); + + const { stdout } = await runCommand( + ["rooms:presence:get-all", "test-room"], + import.meta.url, + ); + + expect(channel.presence.get).toHaveBeenCalledWith({ limit: 100 }); + expect(stdout).toContain("Fetching presence members"); + expect(stdout).toContain("test-room"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("user-2"); + expect(stdout).toContain("2 members"); + }); + + it("should use the ::$chat channel name convention", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("my-room::$chat"); + channel.presence.get.mockResolvedValue( + createMockPaginatedResult(mockPresenceMembers), + ); + + await runCommand(["rooms:presence:get-all", "my-room"], import.meta.url); + + expect(channel.presence.get).toHaveBeenCalled(); + }); + + it("should handle empty presence set", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-room::$chat"); + channel.presence.get.mockResolvedValue(createMockPaginatedResult([])); + + const { stderr } = await runCommand( + ["rooms:presence:get-all", "test-room"], + import.meta.url, + ); + + expect(stderr).toContain("No members currently present"); + }); + + it("should output JSON with presenceMembers array", async () => { + const { stdout } = await runCommand( + ["rooms:presence:get-all", "test-room", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout.trim()); + expect(result.type).toBe("result"); + expect(result.presenceMembers).toBeDefined(); + expect(result.presenceMembers).toHaveLength(2); + expect(result.presenceMembers[0].clientId).toBe("user-1"); + expect(result.presenceMembers[0].connectionId).toBe("conn-1"); + expect(result.presenceMembers[0].data).toEqual({ status: "online" }); + expect(result.presenceMembers[0].extras).toEqual({ role: "admin" }); + expect(result.presenceMembers[0].updatedAt).toBeDefined(); + expect(result.presenceMembers[1].clientId).toBe("user-2"); + expect(result.presenceMembers[1].data).toBeNull(); + expect(result.hasMore).toBe(false); + expect(result.total).toBe(2); + }); + + it("should display member data and extras when present", async () => { + const { stdout } = await runCommand( + ["rooms:presence:get-all", "test-room"], + import.meta.url, + ); + + expect(stdout).toContain("conn-1"); + expect(stdout).toContain('{"status":"online"}'); + expect(stdout).toContain('{"role":"admin"}'); + }); + + it("should pass limit to presence.get", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-room::$chat"); + + await runCommand( + ["rooms:presence:get-all", "test-room", "--limit", "50"], + import.meta.url, + ); + + expect(channel.presence.get).toHaveBeenCalledWith({ limit: 50 }); + }); + + it("should handle pagination with hasMore", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-room::$chat"); + + const singleMember = { + clientId: "user-1", + connectionId: "conn-1", + action: "present" as const, + data: null, + timestamp: Date.now(), + id: "msg-1", + encoding: "", + }; + channel.presence.get.mockResolvedValue( + createMockPaginatedResult([singleMember], [singleMember]), + ); + + const { stdout } = await runCommand( + ["rooms:presence:get-all", "test-room", "--limit", "1", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout.trim()); + expect(result.hasMore).toBe(true); + expect(result.next).toBeDefined(); + expect(result.next.hint).toContain("--limit"); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-room::$chat"); + channel.presence.get.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["rooms:presence:get-all", "test-room"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + + it("should handle channel not found", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("nonexistent::$chat"); + channel.presence.get.mockRejectedValue( + Object.assign(new Error("Channel not found"), { + code: 40400, + statusCode: 404, + }), + ); + + const { error } = await runCommand( + ["rooms:presence:get-all", "nonexistent"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/rooms/presence/update.test.ts b/test/unit/commands/rooms/presence/update.test.ts new file mode 100644 index 00000000..72b41cb4 --- /dev/null +++ b/test/unit/commands/rooms/presence/update.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("rooms:presence:update command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + standardHelpTests("rooms:presence:update", import.meta.url); + standardArgValidationTests("rooms:presence:update", import.meta.url, { + requiredArgs: ["test-room"], + }); + standardFlagTests("rooms:presence:update", import.meta.url, [ + "--data", + "--json", + "--duration", + "--client-id", + ]); + + describe("functionality", () => { + it("should enter and update presence with data", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + const { stdout } = await runCommand( + ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], + import.meta.url, + ); + + expect(stdout).toContain("Entering and updating presence in room"); + expect(stdout).toContain("Updated"); + expect(stdout).toContain("test-room"); + expect(room.attach).toHaveBeenCalled(); + expect(room.presence.enter).toHaveBeenCalledWith({ status: "away" }); + expect(room.presence.update).toHaveBeenCalledWith({ status: "away" }); + }); + + it("should show labeled output in human mode", async () => { + const { stdout } = await runCommand( + ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], + import.meta.url, + ); + + expect(stdout).toContain("Client ID"); + expect(stdout).toContain("Data"); + expect(stdout).toContain("Holding presence"); + }); + + it("should output JSON with presenceMessage domain key", async () => { + const allRecords = await captureJsonLogs(async () => { + await runCommand( + [ + "rooms:presence:update", + "test-room", + "--data", + '{"status":"away"}', + "--json", + ], + import.meta.url, + ); + }); + + const results = allRecords.filter((r) => r.type === "result"); + expect(results.length).toBeGreaterThanOrEqual(1); + + const result = results[0]; + expect(result.presenceMessage).toBeDefined(); + const msg = result.presenceMessage as Record; + expect(msg.action).toBe("update"); + expect(msg.room).toBe("test-room"); + expect(msg.clientId).toBeDefined(); + expect(msg.data).toEqual({ status: "away" }); + }); + + it("should emit hold status in JSON mode", async () => { + const allRecords = await captureJsonLogs(async () => { + await runCommand( + [ + "rooms:presence:update", + "test-room", + "--data", + '{"status":"away"}', + "--json", + ], + import.meta.url, + ); + }); + + const statusRecords = allRecords.filter((r) => r.type === "status"); + expect(statusRecords.length).toBeGreaterThanOrEqual(1); + + const status = statusRecords[0]; + expect(status.status).toBe("holding"); + expect(status.message).toContain("Holding presence"); + }); + + it("should handle invalid JSON data gracefully", async () => { + const { error } = await runCommand( + ["rooms:presence:update", "test-room", "--data", "not-valid-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/invalid|json/i); + }); + + it("should call attach before enter and update", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + await runCommand( + ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.presence.enter).toHaveBeenCalled(); + expect(room.presence.update).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle presence enter errors", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + room.presence.enter.mockRejectedValue(new Error("Presence enter failed")); + + const { error } = await runCommand( + ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + + it("should handle presence update errors", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + room.presence.update.mockRejectedValue( + new Error("Presence update failed"), + ); + + const { error } = await runCommand( + ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + }); +}); From bc04fe3cbe3d3f65e3422ffc334d4e1b61e52793 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 20 Mar 2026 23:20:58 +0530 Subject: [PATCH 2/5] Fixed output formatting issue for all rooms presence commands --- src/commands/rooms/presence/enter.ts | 72 ++++--- src/commands/rooms/presence/get-all.ts | 21 +- src/commands/rooms/presence/subscribe.ts | 186 ++++++------------ src/commands/rooms/presence/update.ts | 10 +- .../commands/rooms/presence/enter.test.ts | 25 ++- .../commands/rooms/presence/get-all.test.ts | 9 +- .../commands/rooms/presence/subscribe.test.ts | 15 +- .../commands/rooms/presence/update.test.ts | 1 + 8 files changed, 146 insertions(+), 193 deletions(-) diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 1a36d709..150b8e96 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -1,17 +1,19 @@ import { ChatClient, Room, PresenceEvent, PresenceData } from "@ably/chat"; -import { Args, Flags, Interfaces } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { isJsonData } from "../../../utils/json-formatter.js"; import { formatSuccess, formatListening, formatProgress, formatResource, formatTimestamp, - formatPresenceAction, + formatEventType, formatIndex, formatClientId, formatLabel, + formatMessageTimestamp, } from "../../../utils/output.js"; export default class RoomsPresenceEnter extends ChatBaseCommand { @@ -55,14 +57,10 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { private roomName: string | null = null; private data: PresenceData | null = null; - private commandFlags: Interfaces.InferredFlags< - typeof RoomsPresenceEnter.flags - > | null = null; private sequenceCounter = 0; async run(): Promise { const { args, flags } = await this.parse(RoomsPresenceEnter); - this.commandFlags = flags; this.roomName = args.room; const rawData = flags.data; @@ -102,11 +100,15 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { const member = event.member; if (member.clientId !== this.chatClient?.clientId) { this.sequenceCounter++; - const timestamp = new Date().toISOString(); - const eventData = { - eventType: event.type, - member: { clientId: member.clientId, data: member.data }, + const timestamp = formatMessageTimestamp( + member.updatedAt?.getTime(), + ); + const presenceEvent = { + action: event.type, room: this.roomName, + clientId: member.clientId, + connectionId: member.connectionId, + data: member.data ?? null, timestamp, ...(flags["sequence-numbers"] ? { sequence: this.sequenceCounter } @@ -116,33 +118,29 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { flags, "presence", event.type, - `Presence event '${event.type}' received`, - eventData, + `Presence event: ${event.type} by ${member.clientId}`, + presenceEvent, ); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(eventData, flags); + this.logJsonEvent({ presenceMessage: presenceEvent }, flags); } else { - const { symbol: actionSymbol, color: actionColor } = - formatPresenceAction(event.type); const sequencePrefix = flags["sequence-numbers"] ? `${formatIndex(this.sequenceCounter)}` : ""; this.log( - `${formatTimestamp(timestamp)}${sequencePrefix} ${actionColor(actionSymbol)} ${formatClientId(member.clientId || "Unknown")} ${actionColor(event.type)}`, + `${formatTimestamp(timestamp)}${sequencePrefix} ${formatResource(`Room: ${this.roomName!}`)} | Action: ${formatEventType(event.type)} | Client: ${formatClientId(member.clientId || "N/A")}`, ); - if ( - member.data && - typeof member.data === "object" && - Object.keys(member.data).length > 0 - ) { - const profile = member.data as { name?: string }; - if (profile.name) { - this.log(` ${formatLabel("Name")} ${profile.name}`); + + if (member.data !== null && member.data !== undefined) { + if (isJsonData(member.data)) { + this.log(formatLabel("Data")); + this.log(JSON.stringify(member.data, null, 2)); + } else { + this.log(`${formatLabel("Data")} ${member.data}`); } - this.log( - ` ${formatLabel("Full Data")} ${this.formatJsonOutput({ data: member.data }, flags)}`, - ); } + + this.log(""); // Empty line for better readability } } }); @@ -171,6 +169,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { action: "enter", room: this.roomName, clientId: this.chatClient!.clientId, + connectionId: this.chatClient!.realtime.connection.id, data: this.data ?? null, timestamp: new Date().toISOString(), }, @@ -183,11 +182,22 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { `Entered presence in room: ${formatResource(this.roomName!)}.`, ), ); - if (flags["show-others"]) { - this.log(formatListening("Listening for presence events.")); - } else { - this.log(formatListening("Staying present.")); + this.log( + `${formatLabel("Client ID")} ${formatClientId(this.chatClient!.clientId ?? "unknown")}`, + ); + this.log( + `${formatLabel("Connection ID")} ${this.chatClient!.realtime.connection.id}`, + ); + if (this.data !== undefined && this.data !== null) { + this.log(`${formatLabel("Data")} ${JSON.stringify(this.data)}`); } + this.log( + formatListening( + flags["show-others"] + ? "Listening for presence events." + : "Holding presence.", + ), + ); } this.logJsonStatus( diff --git a/src/commands/rooms/presence/get-all.ts b/src/commands/rooms/presence/get-all.ts index a09d7e48..18ee3f49 100644 --- a/src/commands/rooms/presence/get-all.ts +++ b/src/commands/rooms/presence/get-all.ts @@ -5,6 +5,7 @@ import { productApiFlags } from "../../../flags.js"; import { formatClientId, formatCountLabel, + formatEventType, formatHeading, formatIndex, formatLabel, @@ -106,9 +107,10 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { const presenceMembers = items.map((member) => ({ clientId: member.clientId, connectionId: member.connectionId, + action: member.action, data: member.data ?? null, - extras: member.extras ?? null, - updatedAt: formatMessageTimestamp(member.timestamp), + timestamp: formatMessageTimestamp(member.timestamp), + id: member.id, })); const next = buildPaginationNext(hasMore); this.logJsonResult( @@ -136,21 +138,14 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { ` ${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, ); this.log(` ${formatLabel("Connection ID")} ${member.connectionId}`); + this.log( + ` ${formatLabel("Action")} ${formatEventType(String(member.action))}`, + ); if (member.data !== null && member.data !== undefined) { this.log(` ${formatLabel("Data")} ${JSON.stringify(member.data)}`); } - if ( - member.extras !== null && - member.extras !== undefined && - typeof member.extras === "object" && - Object.keys(member.extras).length > 0 - ) { - this.log( - ` ${formatLabel("Extras")} ${JSON.stringify(member.extras)}`, - ); - } this.log( - ` ${formatLabel("Updated At")} ${formatMessageTimestamp(member.timestamp)}`, + ` ${formatLabel("Timestamp")} ${formatMessageTimestamp(member.timestamp)}`, ); this.log(""); } diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index 11e56e38..1329e4ad 100644 --- a/src/commands/rooms/presence/subscribe.ts +++ b/src/commands/rooms/presence/subscribe.ts @@ -1,15 +1,15 @@ -import { PresenceMember, ChatClient, Room, PresenceEvent } from "@ably/chat"; -import { Args, Interfaces } from "@oclif/core"; -import chalk from "chalk"; +import { ChatClient, Room, PresenceEvent } from "@ably/chat"; +import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { isJsonData } from "../../../utils/json-formatter.js"; import { formatClientId, - formatHeading, + formatEventType, formatLabel, formatListening, - formatPresenceAction, + formatMessageTimestamp, formatProgress, formatResource, formatSuccess, @@ -41,71 +41,30 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { private chatClient: ChatClient | null = null; private roomName: string | null = null; private room: Room | null = null; - private commandFlags: Interfaces.InferredFlags< - typeof RoomsPresenceSubscribe.flags - > | null = null; async run(): Promise { const { args, flags } = await this.parse(RoomsPresenceSubscribe); - this.commandFlags = flags; this.roomName = args.room; try { - // Show a progress signal early so E2E harnesses know the command is running if (!this.shouldOutputJson(flags)) { this.log( formatProgress( - `Subscribing to presence in room: ${formatResource(this.roomName!)}`, + `Subscribing to presence events on room: ${formatResource(this.roomName!)}`, ), ); } - // Try to create clients, but don't fail if auth fails - try { - this.chatClient = await this.createChatClient(flags); - } catch (authError) { - // Auth failed, but we still want to show the signal and wait - this.logCliEvent( - flags, - "initialization", - "authFailed", - `Authentication failed: ${authError instanceof Error ? authError.message : String(authError)}`, - ); - if (!this.shouldOutputJson(flags)) { - this.log( - chalk.yellow( - "Warning: Failed to connect to Ably (authentication failed)", - ), - ); - } - - // Wait for the duration even with auth failures - await this.waitAndTrackCleanup(flags, "presence", flags.duration); - return; - } + this.chatClient = await this.createChatClient(flags); if (!this.chatClient) { - // Don't exit immediately on auth failures - log the error but continue - this.logCliEvent( + this.fail( + new Error("Failed to create Chat client"), flags, - "initialization", - "failed", - "Failed to create Chat client - likely authentication issue", + "roomPresenceSubscribe", ); - if (!this.shouldOutputJson(flags)) { - this.log( - chalk.yellow( - "Warning: Failed to connect to Ably (likely authentication issue)", - ), - ); - } - - // Wait for the duration even with auth failures - await this.waitAndTrackCleanup(flags, "presence", flags.duration); - return; } - // Only proceed with actual functionality if auth succeeded // Set up connection state logging this.setupConnectionStateLogging(this.chatClient.realtime, flags, { includeUserFriendlyMessages: true, @@ -122,99 +81,83 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { await currentRoom.attach(); - if (!this.shouldOutputJson(flags) && this.roomName) { - this.log( - formatProgress( - `Fetching current presence members for room ${formatResource(this.roomName)}`, - ), - ); - const members: PresenceMember[] = await currentRoom.presence.get(); - if (members.length === 0) { - this.log( - chalk.yellow("No members are currently present in this room."), - ); - } else { - this.log( - `\n${formatHeading("Current presence members")} (${chalk.bold(members.length.toString())}):\n`, - ); - for (const member of members) { - this.log(`- ${formatClientId(member.clientId || "Unknown")}`); - if ( - member.data && - typeof member.data === "object" && - Object.keys(member.data).length > 0 - ) { - const profile = member.data as { name?: string }; - if (profile.name) { - this.log(` ${formatLabel("Name")} ${profile.name}`); - } - this.log( - ` ${formatLabel("Full Profile Data")} ${this.formatJsonOutput({ data: member.data }, flags)}`, - ); - } - } - } - } - + // Subscribe to presence events this.logCliEvent( flags, "presence", - "subscribingToEvents", - "Subscribing to presence events", + "subscribing", + `Subscribing to presence events on room: ${this.roomName}`, + { room: this.roomName }, ); + currentRoom.presence.subscribe((event: PresenceEvent) => { - const timestamp = new Date().toISOString(); const member = event.member; - const eventData = { - eventType: event.type, - member: { clientId: member.clientId, data: member.data }, + const timestamp = formatMessageTimestamp(member.updatedAt?.getTime()); + const presenceData = { + action: event.type, room: this.roomName, + clientId: member.clientId, + connectionId: member.connectionId, + data: member.data ?? null, timestamp, }; this.logCliEvent( flags, "presence", event.type, - `Presence event '${event.type}' received`, - eventData, + `Presence event: ${event.type} by ${member.clientId}`, + presenceData, ); + if (this.shouldOutputJson(flags)) { - this.logJsonEvent(eventData, flags); + this.logJsonEvent({ presenceMessage: presenceData }, flags); } else { - const { symbol: actionSymbol, color: actionColor } = - formatPresenceAction(event.type); - this.log( - `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${formatClientId(member.clientId || "Unknown")} ${actionColor(event.type)}`, - ); - if ( - member.data && - typeof member.data === "object" && - Object.keys(member.data).length > 0 - ) { - const profile = member.data as { name?: string }; - if (profile.name) { - this.log(` ${formatLabel("Name")} ${profile.name}`); - } - this.log( - ` ${formatLabel("Full Data")} ${this.formatJsonOutput({ data: member.data }, flags)}`, + const lines: string[] = [ + formatTimestamp(timestamp), + `${formatLabel("Timestamp")} ${timestamp}`, + `${formatLabel("Action")} ${formatEventType(event.type)}`, + `${formatLabel("Room")} ${formatResource(this.roomName!)}`, + ]; + if (member.clientId) { + lines.push( + `${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, ); } + if (member.connectionId) { + lines.push( + `${formatLabel("Connection ID")} ${member.connectionId}`, + ); + } + if (member.data !== null && member.data !== undefined) { + if (isJsonData(member.data)) { + lines.push( + `${formatLabel("Data")}`, + JSON.stringify(member.data, null, 2), + ); + } else { + lines.push(`${formatLabel("Data")} ${String(member.data)}`); + } + } + this.log(lines.join("\n")); + this.log(""); // Empty line for better readability } }); + this.logCliEvent( flags, "presence", - "subscribedToEvents", - "Subscribed to presence events", + "listening", + "Listening for presence events. Press Ctrl+C to exit.", ); if (!this.shouldOutputJson(flags)) { this.log( formatSuccess( - `Subscribed to presence in room: ${formatResource(this.roomName!)}.`, + `Subscribed to presence on room: ${formatResource(this.roomName!)}.`, ), ); this.log(formatListening("Listening for presence events.")); + this.log(""); } // Wait until the user interrupts or the optional duration elapses @@ -223,23 +166,6 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { this.fail(error, flags, "roomPresenceSubscribe", { room: this.roomName, }); - } finally { - const currentFlags = this.commandFlags || {}; - this.logCliEvent( - currentFlags, - "presence", - "finallyBlockReached", - "Reached finally block for presence subscribe.", - ); - - if (!this.cleanupInProgress && !this.shouldOutputJson(currentFlags)) { - this.logCliEvent( - currentFlags, - "presence", - "implicitCleanupInFinally", - "Performing cleanup (no prior signal).", - ); - } } } } diff --git a/src/commands/rooms/presence/update.ts b/src/commands/rooms/presence/update.ts index fa6c0303..41502263 100644 --- a/src/commands/rooms/presence/update.ts +++ b/src/commands/rooms/presence/update.ts @@ -1,5 +1,5 @@ import { ChatClient, PresenceData, Room } from "@ably/chat"; -import { Args, Flags, Interfaces } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; @@ -41,13 +41,9 @@ export default class RoomsPresenceUpdate extends ChatBaseCommand { private chatClient: ChatClient | null = null; private room: Room | null = null; - private commandFlags: Interfaces.InferredFlags< - typeof RoomsPresenceUpdate.flags - > | null = null; async run(): Promise { const { args, flags } = await this.parse(RoomsPresenceUpdate); - this.commandFlags = flags; try { this.chatClient = await this.createChatClient(flags); @@ -127,6 +123,7 @@ export default class RoomsPresenceUpdate extends ChatBaseCommand { action: "update", room: roomName, clientId: this.chatClient.clientId, + connectionId: this.chatClient.realtime.connection.id, data, timestamp: new Date().toISOString(), }, @@ -142,6 +139,9 @@ export default class RoomsPresenceUpdate extends ChatBaseCommand { this.log( `${formatLabel("Client ID")} ${formatClientId(this.chatClient.clientId ?? "unknown")}`, ); + this.log( + `${formatLabel("Connection ID")} ${this.chatClient.realtime.connection.id}`, + ); this.log(`${formatLabel("Data")} ${JSON.stringify(data)}`); this.log(formatListening("Holding presence.")); } diff --git a/test/unit/commands/rooms/presence/enter.test.ts b/test/unit/commands/rooms/presence/enter.test.ts index 5ee1d1ac..3e8a0e0f 100644 --- a/test/unit/commands/rooms/presence/enter.test.ts +++ b/test/unit/commands/rooms/presence/enter.test.ts @@ -129,7 +129,9 @@ describe("rooms:presence:enter command", () => { type: "enter", member: { clientId: mock.clientId, + connectionId: "conn-self", data: {}, + updatedAt: new Date(), }, }); @@ -138,7 +140,9 @@ describe("rooms:presence:enter command", () => { type: "enter", member: { clientId: "other-user", + connectionId: "conn-other", data: {}, + updatedAt: new Date(), }, }); } @@ -148,7 +152,14 @@ describe("rooms:presence:enter command", () => { const output = capturedLogs.join("\n"); expect(output).toContain("other-user"); - expect(output).not.toContain(mock.clientId); + // Self events should be filtered from the event stream (Room: ... | Action: ... lines) + // but the client ID will appear in the "Client ID: mock-client-id" success label + const eventLines = capturedLogs.filter((line) => + String(line).includes("| Action:"), + ); + for (const line of eventLines) { + expect(String(line)).not.toContain(mock.clientId); + } }); it("should output JSON result on enter success", async () => { @@ -216,7 +227,9 @@ describe("rooms:presence:enter command", () => { type: "enter", member: { clientId: "other-user", + connectionId: "conn-other", data: { status: "online" }, + updatedAt: new Date(), }, }); } @@ -225,14 +238,18 @@ describe("rooms:presence:enter command", () => { }); // Find the JSON output with presence event data - const records = allRecords.filter((r) => r.type === "event" && r.member); + const records = allRecords.filter( + (r) => r.type === "event" && r.presenceMessage, + ); expect(records.length).toBeGreaterThan(0); const parsed = records[0]; expect(parsed).toHaveProperty("command"); expect(parsed).toHaveProperty("type", "event"); - expect(parsed).toHaveProperty("eventType", "enter"); - expect(parsed.member).toHaveProperty("clientId", "other-user"); + const msg = parsed.presenceMessage as Record; + expect(msg.action).toBe("enter"); + expect(msg.clientId).toBe("other-user"); + expect(msg.connectionId).toBe("conn-other"); }); }); diff --git a/test/unit/commands/rooms/presence/get-all.test.ts b/test/unit/commands/rooms/presence/get-all.test.ts index 84722f90..48dcc007 100644 --- a/test/unit/commands/rooms/presence/get-all.test.ts +++ b/test/unit/commands/rooms/presence/get-all.test.ts @@ -107,16 +107,17 @@ describe("rooms:presence:get-all command", () => { expect(result.presenceMembers).toHaveLength(2); expect(result.presenceMembers[0].clientId).toBe("user-1"); expect(result.presenceMembers[0].connectionId).toBe("conn-1"); + expect(result.presenceMembers[0].action).toBe("present"); expect(result.presenceMembers[0].data).toEqual({ status: "online" }); - expect(result.presenceMembers[0].extras).toEqual({ role: "admin" }); - expect(result.presenceMembers[0].updatedAt).toBeDefined(); + expect(result.presenceMembers[0].timestamp).toBeDefined(); + expect(result.presenceMembers[0].id).toBe("msg-1"); expect(result.presenceMembers[1].clientId).toBe("user-2"); expect(result.presenceMembers[1].data).toBeNull(); expect(result.hasMore).toBe(false); expect(result.total).toBe(2); }); - it("should display member data and extras when present", async () => { + it("should display member data and action when present", async () => { const { stdout } = await runCommand( ["rooms:presence:get-all", "test-room"], import.meta.url, @@ -124,7 +125,7 @@ describe("rooms:presence:get-all command", () => { expect(stdout).toContain("conn-1"); expect(stdout).toContain('{"status":"online"}'); - expect(stdout).toContain('{"role":"admin"}'); + expect(stdout).toContain("present"); }); it("should pass limit to presence.get", async () => { diff --git a/test/unit/commands/rooms/presence/subscribe.test.ts b/test/unit/commands/rooms/presence/subscribe.test.ts index 262c4d3d..775c5002 100644 --- a/test/unit/commands/rooms/presence/subscribe.test.ts +++ b/test/unit/commands/rooms/presence/subscribe.test.ts @@ -54,7 +54,9 @@ describe("rooms:presence:subscribe command", () => { type: "enter", member: { clientId: "user-123", + connectionId: "conn-123", data: { name: "Test User" }, + updatedAt: new Date(), }, }); } @@ -104,7 +106,9 @@ describe("rooms:presence:subscribe command", () => { type: "leave", member: { clientId: "user-456", + connectionId: "conn-456", data: {}, + updatedAt: new Date(), }, }); } @@ -118,10 +122,7 @@ describe("rooms:presence:subscribe command", () => { // Find the JSON output with presence data const records = allRecords.filter( - (r) => - r.type === "event" && - r.member && - (r.member as Record).clientId, + (r) => r.type === "event" && r.presenceMessage, ); // Verify that presence event was actually output in JSON format @@ -129,8 +130,10 @@ describe("rooms:presence:subscribe command", () => { const parsed = records[0]; expect(parsed).toHaveProperty("command"); expect(parsed).toHaveProperty("type", "event"); - expect(parsed).toHaveProperty("eventType", "leave"); - expect(parsed.member).toHaveProperty("clientId", "user-456"); + const msg = parsed.presenceMessage as Record; + expect(msg.action).toBe("leave"); + expect(msg.clientId).toBe("user-456"); + expect(msg.connectionId).toBe("conn-456"); }); }); diff --git a/test/unit/commands/rooms/presence/update.test.ts b/test/unit/commands/rooms/presence/update.test.ts index 72b41cb4..f0877e9f 100644 --- a/test/unit/commands/rooms/presence/update.test.ts +++ b/test/unit/commands/rooms/presence/update.test.ts @@ -49,6 +49,7 @@ describe("rooms:presence:update command", () => { ); expect(stdout).toContain("Client ID"); + expect(stdout).toContain("Connection ID"); expect(stdout).toContain("Data"); expect(stdout).toContain("Holding presence"); }); From 432c8d3302e7830e6ade73ca6df5b9e201b008ed Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 23 Mar 2026 11:38:39 +0530 Subject: [PATCH 3/5] Fixed all review comments and failing e2e tests --- README.md | 2 ++ src/commands/rooms/presence/enter.ts | 15 +++++++++----- src/commands/rooms/presence/get-all.ts | 8 +++----- src/commands/rooms/presence/subscribe.ts | 6 +++--- test/e2e/rooms/rooms-e2e.test.ts | 13 ++++++------ .../commands/rooms/presence/enter.test.ts | 20 ++++++------------- .../commands/rooms/presence/get-all.test.ts | 4 ++-- 7 files changed, 33 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 0411c268..0a89dd33 100644 --- a/README.md +++ b/README.md @@ -4241,6 +4241,8 @@ EXAMPLES $ ably rooms presence enter my-room --duration 30 $ ably rooms presence enter my-room --json + + $ ably rooms presence enter my-room --pretty-json ``` _See code: [src/commands/rooms/presence/enter.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/enter.ts)_ diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 150b8e96..6910f6f3 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -32,6 +32,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { "$ ably rooms presence enter my-room --show-others", "$ ably rooms presence enter my-room --duration 30", "$ ably rooms presence enter my-room --json", + "$ ably rooms presence enter my-room --pretty-json", ]; static override flags = { ...productApiFlags, @@ -63,9 +64,8 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { const { args, flags } = await this.parse(RoomsPresenceEnter); this.roomName = args.room; - const rawData = flags.data; - if (rawData && rawData !== "{}") { - const parsed = this.parseJsonFlag(rawData, "data", flags); + if (flags.data) { + const parsed = this.parseJsonFlag(flags.data, "data", flags); this.data = parsed as PresenceData; } @@ -157,10 +157,15 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { } this.logCliEvent(flags, "presence", "entering", "Entering presence", { + room: this.roomName, + clientId: this.chatClient!.clientId, data: this.data, }); - await currentRoom.presence.enter(this.data || {}); - this.logCliEvent(flags, "presence", "entered", "Entered presence"); + await currentRoom.presence.enter(this.data ?? undefined); + this.logCliEvent(flags, "presence", "entered", "Entered presence", { + room: this.roomName, + clientId: this.chatClient!.clientId, + }); if (this.shouldOutputJson(flags)) { this.logJsonResult( diff --git a/src/commands/rooms/presence/get-all.ts b/src/commands/rooms/presence/get-all.ts index 18ee3f49..de60bd07 100644 --- a/src/commands/rooms/presence/get-all.ts +++ b/src/commands/rooms/presence/get-all.ts @@ -73,7 +73,7 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { flags, "presence", "fetching", - `Fetching presence members for room ${roomName}`, + `Fetching presence members for room ${formatResource(roomName)}`, { room: roomName }, ); @@ -123,12 +123,10 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { flags, ); } else if (items.length === 0) { - this.logToStderr( - formatWarning("No members currently present in this room."), - ); + this.log(formatWarning("No members currently present in this room.")); } else { this.log( - `\n${formatHeading(`Presence members in room: ${roomName}`)} (${formatCountLabel(items.length, "member")}):\n`, + `\n${formatHeading(`Presence members in room: ${formatResource(roomName)}`)} (${formatCountLabel(items.length, "member")}):\n`, ); for (let i = 0; i < items.length; i++) { diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index 1329e4ad..067dae8e 100644 --- a/src/commands/rooms/presence/subscribe.ts +++ b/src/commands/rooms/presence/subscribe.ts @@ -50,7 +50,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( formatProgress( - `Subscribing to presence events on room: ${formatResource(this.roomName!)}`, + `Subscribing to presence events in room: ${formatResource(this.roomName!)}`, ), ); } @@ -86,7 +86,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { flags, "presence", "subscribing", - `Subscribing to presence events on room: ${this.roomName}`, + `Subscribing to presence events in room: ${this.roomName}`, { room: this.roomName }, ); @@ -153,7 +153,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( formatSuccess( - `Subscribed to presence on room: ${formatResource(this.roomName!)}.`, + `Subscribed to presence in room: ${formatResource(this.roomName!)}.`, ), ); this.log(formatListening("Listening for presence events.")); diff --git a/test/e2e/rooms/rooms-e2e.test.ts b/test/e2e/rooms/rooms-e2e.test.ts index dec3e85e..a1b4a07c 100644 --- a/test/e2e/rooms/rooms-e2e.test.ts +++ b/test/e2e/rooms/rooms-e2e.test.ts @@ -200,24 +200,25 @@ describe("Rooms E2E Tests", () => { // Wait for all presence event components using the improved detection try { - // Wait for action enter pattern - look for the actual format: "clientId enter" + // Wait for client2's presence event in subscribe output + // Subscribe command uses multi-line format with "Client ID:" label await waitForOutput( subscribeRunner, - ` ${client2Id} enter`, + client2Id, process.env.CI ? 20000 : 15000, ); - // Wait for profile data in human-readable output + // Wait for action type in the event await waitForOutput( subscribeRunner, - `Name: TestUser2`, + `enter`, process.env.CI ? 10000 : 5000, ); - // Wait for status in compact JSON Full Data output + // Wait for data containing the status field (pretty-printed JSON has spaces) await waitForOutput( subscribeRunner, - `"status":"active"`, + `"status": "active"`, process.env.CI ? 5000 : 3000, ); } catch (error) { diff --git a/test/unit/commands/rooms/presence/enter.test.ts b/test/unit/commands/rooms/presence/enter.test.ts index 3e8a0e0f..bae173e8 100644 --- a/test/unit/commands/rooms/presence/enter.test.ts +++ b/test/unit/commands/rooms/presence/enter.test.ts @@ -99,11 +99,6 @@ describe("rooms:presence:enter command", () => { it("should filter out self events by clientId with --show-others", async () => { const mock = getMockAblyChat(); const room = mock.rooms._getRoom("test-room"); - const capturedLogs: string[] = []; - - const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { - capturedLogs.push(String(msg)); - }); let presenceCallback: ((event: unknown) => void) | null = null; room.presence.subscribe.mockImplementation((callback) => { @@ -147,18 +142,15 @@ describe("rooms:presence:enter command", () => { }); } - await commandPromise; - logSpy.mockRestore(); - - const output = capturedLogs.join("\n"); - expect(output).toContain("other-user"); + const { stdout } = await commandPromise; + expect(stdout).toContain("other-user"); // Self events should be filtered from the event stream (Room: ... | Action: ... lines) // but the client ID will appear in the "Client ID: mock-client-id" success label - const eventLines = capturedLogs.filter((line) => - String(line).includes("| Action:"), - ); + const eventLines = stdout + .split("\n") + .filter((line) => line.includes("| Action:")); for (const line of eventLines) { - expect(String(line)).not.toContain(mock.clientId); + expect(line).not.toContain(mock.clientId); } }); diff --git a/test/unit/commands/rooms/presence/get-all.test.ts b/test/unit/commands/rooms/presence/get-all.test.ts index 48dcc007..0a4b1a5c 100644 --- a/test/unit/commands/rooms/presence/get-all.test.ts +++ b/test/unit/commands/rooms/presence/get-all.test.ts @@ -87,12 +87,12 @@ describe("rooms:presence:get-all command", () => { const channel = mock.channels._getChannel("test-room::$chat"); channel.presence.get.mockResolvedValue(createMockPaginatedResult([])); - const { stderr } = await runCommand( + const { stdout } = await runCommand( ["rooms:presence:get-all", "test-room"], import.meta.url, ); - expect(stderr).toContain("No members currently present"); + expect(stdout).toContain("No members currently present"); }); it("should output JSON with presenceMembers array", async () => { From bfb720bc2196659340998b251b09dce758d67d49 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 24 Mar 2026 14:41:03 +0530 Subject: [PATCH 4/5] - Renamed rooms presence get-all to rooms presence get - Marked clientId as mandatory while updating a presence, added warning about how enter and update works --- README.md | 58 ++----- src/commands/rooms/presence/enter.ts | 4 +- .../rooms/presence/{get-all.ts => get.ts} | 14 +- src/commands/rooms/presence/update.ts | 162 ------------------ .../presence/{get-all.test.ts => get.test.ts} | 26 +-- .../commands/rooms/presence/update.test.ts | 159 ----------------- 6 files changed, 33 insertions(+), 390 deletions(-) rename src/commands/rooms/presence/{get-all.ts => get.ts} (91%) delete mode 100644 src/commands/rooms/presence/update.ts rename test/unit/commands/rooms/presence/{get-all.test.ts => get.test.ts} (87%) delete mode 100644 test/unit/commands/rooms/presence/update.test.ts diff --git a/README.md b/README.md index 86c7cd0d..300f1a1c 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,8 @@ $ ably-interactive * [`ably rooms occupancy subscribe ROOM`](#ably-rooms-occupancy-subscribe-room) * [`ably rooms presence`](#ably-rooms-presence) * [`ably rooms presence enter ROOM`](#ably-rooms-presence-enter-room) -* [`ably rooms presence get-all ROOM`](#ably-rooms-presence-get-all-room) +* [`ably rooms presence get ROOM`](#ably-rooms-presence-get-room) * [`ably rooms presence subscribe ROOM`](#ably-rooms-presence-subscribe-room) -* [`ably rooms presence update ROOM`](#ably-rooms-presence-update-room) * [`ably rooms reactions`](#ably-rooms-reactions) * [`ably rooms reactions send ROOM EMOJI`](#ably-rooms-reactions-send-room-emoji) * [`ably rooms reactions subscribe ROOM`](#ably-rooms-reactions-subscribe-room) @@ -2039,8 +2038,6 @@ EXAMPLES $ ably channels presence update my-channel --data '{"status":"busy"}' --json - $ ably channels presence update my-channel --data '{"status":"busy"}' --pretty-json - $ ably channels presence update my-channel --data '{"status":"online"}' --duration 60 ``` @@ -4211,7 +4208,7 @@ _See code: [src/commands/rooms/presence/index.ts](https://github.com/ably/ably-c ## `ably rooms presence enter ROOM` -Enter presence in a chat room and remain present until terminated +Enter presence in a chat room and remain present until terminated. Provide clientId and data to update existing presence member. ``` USAGE @@ -4233,7 +4230,7 @@ FLAGS --show-others Show other presence events while present (default: false) DESCRIPTION - Enter presence in a chat room and remain present until terminated + Enter presence in a chat room and remain present until terminated. Provide clientId and data to update existing presence member. EXAMPLES $ ably rooms presence enter my-room @@ -4251,13 +4248,13 @@ EXAMPLES _See code: [src/commands/rooms/presence/enter.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/enter.ts)_ -## `ably rooms presence get-all ROOM` +## `ably rooms presence get ROOM` Get all current presence members in a chat room ``` USAGE - $ ably rooms presence get-all ROOM [-v] [--json | --pretty-json] [--limit ] + $ ably rooms presence get ROOM [-v] [--json | --pretty-json] [--limit ] ARGUMENTS ROOM Room to get presence members for @@ -4272,16 +4269,16 @@ DESCRIPTION Get all current presence members in a chat room EXAMPLES - $ ably rooms presence get-all my-room + $ ably rooms presence get my-room - $ ably rooms presence get-all my-room --limit 50 + $ ably rooms presence get my-room --limit 50 - $ ably rooms presence get-all my-room --json + $ ably rooms presence get my-room --json - $ ably rooms presence get-all my-room --pretty-json + $ ably rooms presence get my-room --pretty-json ``` -_See code: [src/commands/rooms/presence/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/get-all.ts)_ +_See code: [src/commands/rooms/presence/get.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/get.ts)_ ## `ably rooms presence subscribe ROOM` @@ -4315,41 +4312,6 @@ EXAMPLES _See code: [src/commands/rooms/presence/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/subscribe.ts)_ -## `ably rooms presence update ROOM` - -Update presence data in a chat room - -``` -USAGE - $ ably rooms presence update ROOM --data [-v] [--json | --pretty-json] [--client-id ] [-D ] - -ARGUMENTS - ROOM Room to update presence in - -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. - --data= (required) JSON data to associate with the presence update - --json Output in JSON format - --pretty-json Output in colorized JSON format - -DESCRIPTION - Update presence data in a chat room - -EXAMPLES - $ ably rooms presence update my-room --data '{"status":"away"}' - - $ ably rooms presence update my-room --data '{"status":"busy"}' --json - - $ ably rooms presence update my-room --data '{"status":"busy"}' --pretty-json - - $ ably rooms presence update my-room --data '{"status":"online"}' --duration 60 -``` - -_See code: [src/commands/rooms/presence/update.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/presence/update.ts)_ - ## `ably rooms reactions` Manage reactions in Ably chat rooms diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 6910f6f3..ff2fee8b 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -25,7 +25,8 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { }; static override description = - "Enter presence in a chat room and remain present until terminated"; + "Enter presence in a chat room and remain present until terminated. Provide clientId and data to update existing presence member."; + static override examples = [ "$ ably rooms presence enter my-room", `$ ably rooms presence enter my-room --data '{"name":"User","status":"active"}'`, @@ -34,6 +35,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { "$ ably rooms presence enter my-room --json", "$ ably rooms presence enter my-room --pretty-json", ]; + static override flags = { ...productApiFlags, ...clientIdFlag, diff --git a/src/commands/rooms/presence/get-all.ts b/src/commands/rooms/presence/get.ts similarity index 91% rename from src/commands/rooms/presence/get-all.ts rename to src/commands/rooms/presence/get.ts index de60bd07..13db96b4 100644 --- a/src/commands/rooms/presence/get-all.ts +++ b/src/commands/rooms/presence/get.ts @@ -24,7 +24,7 @@ import { // Chat SDK maps room presence to the underlying channel: roomName::$chat const chatChannelName = (roomName: string) => `${roomName}::$chat`; -export default class RoomsPresenceGetAll extends AblyBaseCommand { +export default class RoomsPresenceGet extends AblyBaseCommand { static override args = { room: Args.string({ description: "Room to get presence members for", @@ -36,10 +36,10 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { "Get all current presence members in a chat room"; static override examples = [ - "$ ably rooms presence get-all my-room", - "$ ably rooms presence get-all my-room --limit 50", - "$ ably rooms presence get-all my-room --json", - "$ ably rooms presence get-all my-room --pretty-json", + "$ ably rooms presence get my-room", + "$ ably rooms presence get my-room --limit 50", + "$ ably rooms presence get my-room --json", + "$ ably rooms presence get my-room --pretty-json", ]; static override flags = { @@ -52,7 +52,7 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { }; async run(): Promise { - const { args, flags } = await this.parse(RoomsPresenceGetAll); + const { args, flags } = await this.parse(RoomsPresenceGet); try { const client = await this.createAblyRestClient(flags); @@ -158,7 +158,7 @@ export default class RoomsPresenceGetAll extends AblyBaseCommand { } } } catch (error) { - this.fail(error, flags, "roomPresenceGetAll", { + this.fail(error, flags, "roomPresenceGet", { room: args.room, }); } diff --git a/src/commands/rooms/presence/update.ts b/src/commands/rooms/presence/update.ts deleted file mode 100644 index 41502263..00000000 --- a/src/commands/rooms/presence/update.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ChatClient, PresenceData, Room } from "@ably/chat"; -import { Args, Flags } from "@oclif/core"; - -import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; -import { - formatClientId, - formatLabel, - formatListening, - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; - -export default class RoomsPresenceUpdate extends ChatBaseCommand { - static override args = { - room: Args.string({ - description: "Room to update presence in", - required: true, - }), - }; - - static override description = "Update presence data in a chat room"; - - static override examples = [ - `$ ably rooms presence update my-room --data '{"status":"away"}'`, - `$ ably rooms presence update my-room --data '{"status":"busy"}' --json`, - `$ ably rooms presence update my-room --data '{"status":"busy"}' --pretty-json`, - `$ ably rooms presence update my-room --data '{"status":"online"}' --duration 60`, - ]; - - static override flags = { - ...productApiFlags, - ...clientIdFlag, - data: Flags.string({ - description: "JSON data to associate with the presence update", - required: true, - }), - ...durationFlag, - }; - - private chatClient: ChatClient | null = null; - private room: Room | null = null; - - async run(): Promise { - const { args, flags } = await this.parse(RoomsPresenceUpdate); - - try { - this.chatClient = await this.createChatClient(flags); - - if (!this.chatClient) { - return this.fail( - "Failed to create Chat client", - flags, - "roomPresenceUpdate", - ); - } - - const { room: roomName } = args; - const data = this.parseJsonFlag( - flags.data, - "data", - flags, - ) as PresenceData; - - this.setupConnectionStateLogging(this.chatClient.realtime, flags, { - includeUserFriendlyMessages: true, - }); - - this.room = await this.chatClient.rooms.get(roomName); - - this.setupRoomStatusHandler(this.room, flags, { - roomName, - successMessage: `Connected to room: ${formatResource(roomName)}.`, - }); - - await this.room.attach(); - - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Entering and updating presence in room: ${formatResource(roomName)}`, - ), - ); - } - - this.logCliEvent( - flags, - "presence", - "entering", - `Entering presence in room ${roomName}`, - { room: roomName, clientId: this.chatClient.clientId }, - ); - await this.room.presence.enter(data); - this.logCliEvent( - flags, - "presence", - "entered", - `Entered presence in room ${roomName}`, - { room: roomName, clientId: this.chatClient.clientId }, - ); - - this.logCliEvent( - flags, - "presence", - "updating", - `Updating presence data in room ${roomName}`, - { room: roomName, data }, - ); - await this.room.presence.update(data); - this.logCliEvent( - flags, - "presence", - "updated", - `Updated presence data in room ${roomName}`, - { room: roomName, clientId: this.chatClient.clientId, data }, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - presenceMessage: { - action: "update", - room: roomName, - clientId: this.chatClient.clientId, - connectionId: this.chatClient.realtime.connection.id, - data, - timestamp: new Date().toISOString(), - }, - }, - flags, - ); - } else { - this.log( - formatSuccess( - `Updated presence in room: ${formatResource(roomName)}.`, - ), - ); - this.log( - `${formatLabel("Client ID")} ${formatClientId(this.chatClient.clientId ?? "unknown")}`, - ); - this.log( - `${formatLabel("Connection ID")} ${this.chatClient.realtime.connection.id}`, - ); - this.log(`${formatLabel("Data")} ${JSON.stringify(data)}`); - this.log(formatListening("Holding presence.")); - } - - this.logJsonStatus( - "holding", - "Holding presence. Press Ctrl+C to exit.", - flags, - ); - - await this.waitAndTrackCleanup(flags, "presence", flags.duration); - } catch (error) { - this.fail(error, flags, "roomPresenceUpdate", { - room: args.room, - }); - } - } -} diff --git a/test/unit/commands/rooms/presence/get-all.test.ts b/test/unit/commands/rooms/presence/get.test.ts similarity index 87% rename from test/unit/commands/rooms/presence/get-all.test.ts rename to test/unit/commands/rooms/presence/get.test.ts index 0a4b1a5c..3bda520c 100644 --- a/test/unit/commands/rooms/presence/get-all.test.ts +++ b/test/unit/commands/rooms/presence/get.test.ts @@ -32,7 +32,7 @@ const mockPresenceMembers = [ }, ]; -describe("rooms:presence:get-all command", () => { +describe("rooms:presence:get command", () => { beforeEach(() => { const mock = getMockAblyRest(); // Chat SDK maps room presence to roomName::$chat channel @@ -42,11 +42,11 @@ describe("rooms:presence:get-all command", () => { ); }); - standardHelpTests("rooms:presence:get-all", import.meta.url); - standardArgValidationTests("rooms:presence:get-all", import.meta.url, { + standardHelpTests("rooms:presence:get", import.meta.url); + standardArgValidationTests("rooms:presence:get", import.meta.url, { requiredArgs: ["test-room"], }); - standardFlagTests("rooms:presence:get-all", import.meta.url, [ + standardFlagTests("rooms:presence:get", import.meta.url, [ "--limit", "--json", "--pretty-json", @@ -58,7 +58,7 @@ describe("rooms:presence:get-all command", () => { const channel = mock.channels._getChannel("test-room::$chat"); const { stdout } = await runCommand( - ["rooms:presence:get-all", "test-room"], + ["rooms:presence:get", "test-room"], import.meta.url, ); @@ -77,7 +77,7 @@ describe("rooms:presence:get-all command", () => { createMockPaginatedResult(mockPresenceMembers), ); - await runCommand(["rooms:presence:get-all", "my-room"], import.meta.url); + await runCommand(["rooms:presence:get", "my-room"], import.meta.url); expect(channel.presence.get).toHaveBeenCalled(); }); @@ -88,7 +88,7 @@ describe("rooms:presence:get-all command", () => { channel.presence.get.mockResolvedValue(createMockPaginatedResult([])); const { stdout } = await runCommand( - ["rooms:presence:get-all", "test-room"], + ["rooms:presence:get", "test-room"], import.meta.url, ); @@ -97,7 +97,7 @@ describe("rooms:presence:get-all command", () => { it("should output JSON with presenceMembers array", async () => { const { stdout } = await runCommand( - ["rooms:presence:get-all", "test-room", "--json"], + ["rooms:presence:get", "test-room", "--json"], import.meta.url, ); @@ -119,7 +119,7 @@ describe("rooms:presence:get-all command", () => { it("should display member data and action when present", async () => { const { stdout } = await runCommand( - ["rooms:presence:get-all", "test-room"], + ["rooms:presence:get", "test-room"], import.meta.url, ); @@ -133,7 +133,7 @@ describe("rooms:presence:get-all command", () => { const channel = mock.channels._getChannel("test-room::$chat"); await runCommand( - ["rooms:presence:get-all", "test-room", "--limit", "50"], + ["rooms:presence:get", "test-room", "--limit", "50"], import.meta.url, ); @@ -158,7 +158,7 @@ describe("rooms:presence:get-all command", () => { ); const { stdout } = await runCommand( - ["rooms:presence:get-all", "test-room", "--limit", "1", "--json"], + ["rooms:presence:get", "test-room", "--limit", "1", "--json"], import.meta.url, ); @@ -176,7 +176,7 @@ describe("rooms:presence:get-all command", () => { channel.presence.get.mockRejectedValue(new Error("API error")); const { error } = await runCommand( - ["rooms:presence:get-all", "test-room"], + ["rooms:presence:get", "test-room"], import.meta.url, ); @@ -194,7 +194,7 @@ describe("rooms:presence:get-all command", () => { ); const { error } = await runCommand( - ["rooms:presence:get-all", "nonexistent"], + ["rooms:presence:get", "nonexistent"], import.meta.url, ); diff --git a/test/unit/commands/rooms/presence/update.test.ts b/test/unit/commands/rooms/presence/update.test.ts deleted file mode 100644 index f0877e9f..00000000 --- a/test/unit/commands/rooms/presence/update.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { runCommand } from "@oclif/test"; -import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; -import { captureJsonLogs } from "../../../../helpers/ndjson.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, -} from "../../../../helpers/standard-tests.js"; - -describe("rooms:presence:update command", () => { - beforeEach(() => { - getMockAblyChat(); - }); - - standardHelpTests("rooms:presence:update", import.meta.url); - standardArgValidationTests("rooms:presence:update", import.meta.url, { - requiredArgs: ["test-room"], - }); - standardFlagTests("rooms:presence:update", import.meta.url, [ - "--data", - "--json", - "--duration", - "--client-id", - ]); - - describe("functionality", () => { - it("should enter and update presence with data", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - - const { stdout } = await runCommand( - ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], - import.meta.url, - ); - - expect(stdout).toContain("Entering and updating presence in room"); - expect(stdout).toContain("Updated"); - expect(stdout).toContain("test-room"); - expect(room.attach).toHaveBeenCalled(); - expect(room.presence.enter).toHaveBeenCalledWith({ status: "away" }); - expect(room.presence.update).toHaveBeenCalledWith({ status: "away" }); - }); - - it("should show labeled output in human mode", async () => { - const { stdout } = await runCommand( - ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], - import.meta.url, - ); - - expect(stdout).toContain("Client ID"); - expect(stdout).toContain("Connection ID"); - expect(stdout).toContain("Data"); - expect(stdout).toContain("Holding presence"); - }); - - it("should output JSON with presenceMessage domain key", async () => { - const allRecords = await captureJsonLogs(async () => { - await runCommand( - [ - "rooms:presence:update", - "test-room", - "--data", - '{"status":"away"}', - "--json", - ], - import.meta.url, - ); - }); - - const results = allRecords.filter((r) => r.type === "result"); - expect(results.length).toBeGreaterThanOrEqual(1); - - const result = results[0]; - expect(result.presenceMessage).toBeDefined(); - const msg = result.presenceMessage as Record; - expect(msg.action).toBe("update"); - expect(msg.room).toBe("test-room"); - expect(msg.clientId).toBeDefined(); - expect(msg.data).toEqual({ status: "away" }); - }); - - it("should emit hold status in JSON mode", async () => { - const allRecords = await captureJsonLogs(async () => { - await runCommand( - [ - "rooms:presence:update", - "test-room", - "--data", - '{"status":"away"}', - "--json", - ], - import.meta.url, - ); - }); - - const statusRecords = allRecords.filter((r) => r.type === "status"); - expect(statusRecords.length).toBeGreaterThanOrEqual(1); - - const status = statusRecords[0]; - expect(status.status).toBe("holding"); - expect(status.message).toContain("Holding presence"); - }); - - it("should handle invalid JSON data gracefully", async () => { - const { error } = await runCommand( - ["rooms:presence:update", "test-room", "--data", "not-valid-json"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/invalid|json/i); - }); - - it("should call attach before enter and update", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - - await runCommand( - ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], - import.meta.url, - ); - - expect(room.attach).toHaveBeenCalled(); - expect(room.presence.enter).toHaveBeenCalled(); - expect(room.presence.update).toHaveBeenCalled(); - }); - }); - - describe("error handling", () => { - it("should handle presence enter errors", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - room.presence.enter.mockRejectedValue(new Error("Presence enter failed")); - - const { error } = await runCommand( - ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], - import.meta.url, - ); - - expect(error).toBeDefined(); - }); - - it("should handle presence update errors", async () => { - const mock = getMockAblyChat(); - const room = mock.rooms._getRoom("test-room"); - room.presence.update.mockRejectedValue( - new Error("Presence update failed"), - ); - - const { error } = await runCommand( - ["rooms:presence:update", "test-room", "--data", '{"status":"away"}'], - import.meta.url, - ); - - expect(error).toBeDefined(); - }); - }); -}); From fbcbd982390ff632333ace927cf1dfb0ee249710 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 25 Mar 2026 22:38:40 +0530 Subject: [PATCH 5/5] Removed description section for updating presence using presence enter --- README.md | 4 ++-- src/commands/rooms/presence/enter.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 300f1a1c..e071f160 100644 --- a/README.md +++ b/README.md @@ -4208,7 +4208,7 @@ _See code: [src/commands/rooms/presence/index.ts](https://github.com/ably/ably-c ## `ably rooms presence enter ROOM` -Enter presence in a chat room and remain present until terminated. Provide clientId and data to update existing presence member. +Enter presence in a chat room and remain present until terminated. ``` USAGE @@ -4230,7 +4230,7 @@ FLAGS --show-others Show other presence events while present (default: false) DESCRIPTION - Enter presence in a chat room and remain present until terminated. Provide clientId and data to update existing presence member. + Enter presence in a chat room and remain present until terminated. EXAMPLES $ ably rooms presence enter my-room diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index ff2fee8b..8e790451 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -25,7 +25,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { }; static override description = - "Enter presence in a chat room and remain present until terminated. Provide clientId and data to update existing presence member."; + "Enter presence in a chat room and remain present until terminated."; static override examples = [ "$ ably rooms presence enter my-room",