diff --git a/README.md b/README.md index 22f37a3c..e071f160 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ $ 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 ROOM`](#ably-rooms-presence-get-room) * [`ably rooms presence subscribe ROOM`](#ably-rooms-presence-subscribe-room) * [`ably rooms reactions`](#ably-rooms-reactions) * [`ably rooms reactions send ROOM EMOJI`](#ably-rooms-reactions-send-room-emoji) @@ -4207,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. ``` USAGE @@ -4229,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. EXAMPLES $ ably rooms presence enter my-room @@ -4241,10 +4242,44 @@ 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)_ +## `ably rooms presence get ROOM` + +Get all current presence members in a chat room + +``` +USAGE + $ ably rooms presence get 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 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 +``` + +_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` Subscribe to presence events in a chat room diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index dbe7b616..8e790451 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -1,16 +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 { @@ -22,14 +25,17 @@ 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."; + static override examples = [ "$ ably rooms presence enter my-room", `$ ably rooms presence enter my-room --data '{"name":"User","status":"active"}'`, "$ 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, ...clientIdFlag, @@ -54,19 +60,14 @@ 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; - 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; } @@ -101,11 +102,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 } @@ -115,103 +120,105 @@ 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 } } }); } await currentRoom.attach(); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Entering presence in room: ${formatResource(this.roomName)}`, + ), + ); + } + 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.roomName) { + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + presenceMessage: { + action: "enter", + room: this.roomName, + clientId: this.chatClient!.clientId, + connectionId: this.chatClient!.realtime.connection.id, + 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.")}`); - } else { - this.log(`\n${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( + "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.ts b/src/commands/rooms/presence/get.ts new file mode 100644 index 00000000..13db96b4 --- /dev/null +++ b/src/commands/rooms/presence/get.ts @@ -0,0 +1,166 @@ +import { Args, Flags } from "@oclif/core"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { productApiFlags } from "../../../flags.js"; +import { + formatClientId, + formatCountLabel, + formatEventType, + 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 RoomsPresenceGet 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 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 = { + ...productApiFlags, + limit: Flags.integer({ + default: 100, + description: "Maximum number of results to return", + min: 1, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(RoomsPresenceGet); + + 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 ${formatResource(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, + action: member.action, + data: member.data ?? null, + timestamp: formatMessageTimestamp(member.timestamp), + id: member.id, + })); + const next = buildPaginationNext(hasMore); + this.logJsonResult( + { + presenceMembers, + hasMore, + ...(next && { next }), + total: items.length, + }, + flags, + ); + } else if (items.length === 0) { + this.log(formatWarning("No members currently present in this room.")); + } else { + this.log( + `\n${formatHeading(`Presence members in room: ${formatResource(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}`); + this.log( + ` ${formatLabel("Action")} ${formatEventType(String(member.action))}`, + ); + if (member.data !== null && member.data !== undefined) { + this.log(` ${formatLabel("Data")} ${JSON.stringify(member.data)}`); + } + this.log( + ` ${formatLabel("Timestamp")} ${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, "roomPresenceGet", { + room: args.room, + }); + } + } +} diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index 11e56e38..067dae8e 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 in 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,90 +81,73 @@ 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 in 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)) { @@ -215,6 +157,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { ), ); 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/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 d6659552..bae173e8 100644 --- a/test/unit/commands/rooms/presence/enter.test.ts +++ b/test/unit/commands/rooms/presence/enter.test.ts @@ -23,136 +23,91 @@ 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, + ); - 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: {}, - }, - }); + expect(error).toBeDefined(); + expect(error?.message).toContain("Invalid data JSON"); + }); - // Simulate another user's event (should be shown) - presenceCallback({ - type: "enter", - member: { - clientId: "other-user", - data: {}, - }, - }); - } + it("should subscribe to presence events with --show-others", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); - await commandPromise; - logSpy.mockRestore(); + await runCommand( + ["rooms:presence:enter", "test-room", "--show-others"], + import.meta.url, + ); - const output = capturedLogs.join("\n"); - expect(output).toContain("other-user"); - expect(output).not.toContain(mock.clientId); - }); + expect(room.presence.subscribe).toHaveBeenCalled(); + }); - it("should output JSON on enter success", 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"); - 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 +118,131 @@ 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, + connectionId: "conn-self", + data: {}, + updatedAt: new Date(), + }, + }); + + // Simulate another user's event (should be shown) presenceCallback({ type: "enter", member: { clientId: "other-user", - data: { status: "online" }, + connectionId: "conn-other", + data: {}, + updatedAt: new Date(), }, }); } - await commandPromise; + 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 = stdout + .split("\n") + .filter((line) => line.includes("| Action:")); + for (const line of eventLines) { + expect(line).not.toContain(mock.clientId); + } + }); + + 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); + + 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(); }); - // Find the JSON output with presence data - const records = allRecords.filter((r) => r.type === "event" && r.member); + 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, + ); - 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"); + 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", + connectionId: "conn-other", + data: { status: "online" }, + updatedAt: new Date(), + }, + }); + } + + await commandPromise; + }); + + // Find the JSON output with presence event data + 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"); + const msg = parsed.presenceMessage as Record; + expect(msg.action).toBe("enter"); + expect(msg.clientId).toBe("other-user"); + expect(msg.connectionId).toBe("conn-other"); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/rooms/presence/get.test.ts b/test/unit/commands/rooms/presence/get.test.ts new file mode 100644 index 00000000..3bda520c --- /dev/null +++ b/test/unit/commands/rooms/presence/get.test.ts @@ -0,0 +1,204 @@ +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 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", import.meta.url); + standardArgValidationTests("rooms:presence:get", import.meta.url, { + requiredArgs: ["test-room"], + }); + standardFlagTests("rooms:presence:get", 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", "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", "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 { stdout } = await runCommand( + ["rooms:presence:get", "test-room"], + import.meta.url, + ); + + expect(stdout).toContain("No members currently present"); + }); + + it("should output JSON with presenceMembers array", async () => { + const { stdout } = await runCommand( + ["rooms:presence:get", "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].action).toBe("present"); + expect(result.presenceMembers[0].data).toEqual({ status: "online" }); + 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 action when present", async () => { + const { stdout } = await runCommand( + ["rooms:presence:get", "test-room"], + import.meta.url, + ); + + expect(stdout).toContain("conn-1"); + expect(stdout).toContain('{"status":"online"}'); + expect(stdout).toContain("present"); + }); + + it("should pass limit to presence.get", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-room::$chat"); + + await runCommand( + ["rooms:presence:get", "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", "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", "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", "nonexistent"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + }); +}); 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"); }); });