From 3072af4207e99144fd22eee6009b277f84a392e7 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 25 Mar 2026 15:10:26 +0530 Subject: [PATCH 1/2] Fixed channels and rooms subcommands 1. list -> now only returns names of channels and rooms without occupancy 2. occupancy -> refactored to show all fields for json/non-json flags --- README.md | 20 +- src/commands/channels/list.ts | 50 +--- src/commands/channels/occupancy/get.ts | 37 +-- src/commands/channels/occupancy/subscribe.ts | 28 +- src/commands/rooms/list.ts | 62 +---- src/commands/rooms/occupancy/get.ts | 86 ++++-- src/commands/rooms/occupancy/subscribe.ts | 259 +++++++----------- .../channels/channel-occupancy-e2e.test.ts | 2 +- test/unit/commands/channels/list.test.ts | 65 +---- .../commands/channels/occupancy/get.test.ts | 14 +- .../channels/occupancy/subscribe.test.ts | 5 +- test/unit/commands/rooms/features.test.ts | 86 +++--- test/unit/commands/rooms/list.test.ts | 15 +- .../unit/commands/rooms/occupancy/get.test.ts | 88 +++--- .../rooms/occupancy/subscribe.test.ts | 180 +++++++----- 15 files changed, 453 insertions(+), 544 deletions(-) diff --git a/README.md b/README.md index 22f37a3c7..9ae7c4f79 100644 --- a/README.md +++ b/README.md @@ -4127,17 +4127,15 @@ Get current occupancy metrics for a room ``` USAGE - $ ably rooms occupancy get ROOM [-v] [--json | --pretty-json] [--client-id ] + $ ably rooms occupancy get ROOM [-v] [--json | --pretty-json] ARGUMENTS ROOM Room to get occupancy for FLAGS - -v, --verbose Output verbose logs - --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set - no client ID. Not applicable when using token authentication. - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format DESCRIPTION Get current occupancy metrics for a room @@ -4145,8 +4143,6 @@ DESCRIPTION EXAMPLES $ ably rooms occupancy get my-room - $ ABLY_API_KEY="YOUR_API_KEY" ably rooms occupancy get my-room - $ ably rooms occupancy get my-room --json $ ably rooms occupancy get my-room --pretty-json @@ -4156,14 +4152,14 @@ _See code: [src/commands/rooms/occupancy/get.ts](https://github.com/ably/ably-cl ## `ably rooms occupancy subscribe ROOM` -Subscribe to real-time occupancy metrics for a room +Subscribe to occupancy events on a room ``` USAGE $ ably rooms occupancy subscribe ROOM [-v] [--json | --pretty-json] [--client-id ] [-D ] ARGUMENTS - ROOM Room to subscribe to occupancy for + ROOM Room to subscribe to occupancy events FLAGS -D, --duration= Automatically exit after N seconds @@ -4174,14 +4170,14 @@ FLAGS --pretty-json Output in colorized JSON format DESCRIPTION - Subscribe to real-time occupancy metrics for a room + Subscribe to occupancy events on a room EXAMPLES $ ably rooms occupancy subscribe my-room $ ably rooms occupancy subscribe my-room --json - $ ably rooms occupancy subscribe --pretty-json my-room + $ ably rooms occupancy subscribe my-room --duration 30 ``` _See code: [src/commands/rooms/occupancy/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/rooms/occupancy/subscribe.ts)_ diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 52a36dc26..eb97b919d 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -3,7 +3,6 @@ import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import { formatCountLabel, - formatLabel, formatLimitWarning, formatResource, } from "../../utils/output.js"; @@ -13,23 +12,9 @@ import { formatPaginationLog, } from "../../utils/pagination.js"; -interface ChannelMetrics { - connections?: number; - presenceConnections?: number; - presenceMembers?: number; - publishers?: number; - subscribers?: number; -} - -interface ChannelStatus { - occupancy?: { - metrics?: ChannelMetrics; - }; -} - interface ChannelItem { channelId: string; - status?: ChannelStatus; + [key: string]: unknown; } // Type for channel listing request parameters @@ -123,7 +108,6 @@ export default class ChannelsList extends AblyBaseCommand { { channels: channels.map((channel: ChannelItem) => ({ channelId: channel.channelId, - metrics: channel.status?.occupancy?.metrics || {}, })), hasMore, ...(next && { next }), @@ -139,39 +123,11 @@ export default class ChannelsList extends AblyBaseCommand { } this.log( - `Found ${formatCountLabel(channels.length, "active channel")}:`, + `Found ${formatCountLabel(channels.length, "active channel")}:\n`, ); for (const channel of channels) { this.log(`${formatResource(channel.channelId)}`); - - // Show occupancy if available - if (channel.status?.occupancy?.metrics) { - const { metrics } = channel.status.occupancy; - this.log( - ` ${formatLabel("Connections")} ${metrics.connections || 0}`, - ); - this.log( - ` ${formatLabel("Publishers")} ${metrics.publishers || 0}`, - ); - this.log( - ` ${formatLabel("Subscribers")} ${metrics.subscribers || 0}`, - ); - - if (metrics.presenceConnections !== undefined) { - this.log( - ` ${formatLabel("Presence Connections")} ${metrics.presenceConnections}`, - ); - } - - if (metrics.presenceMembers !== undefined) { - this.log( - ` ${formatLabel("Presence Members")} ${metrics.presenceMembers}`, - ); - } - } - - this.log(""); // Add a line break between channels } if (hasMore) { @@ -180,7 +136,7 @@ export default class ChannelsList extends AblyBaseCommand { flags.limit, "channels", ); - if (warning) this.log(warning); + if (warning) this.log(`\n${warning}`); } } } catch (error) { diff --git a/src/commands/channels/occupancy/get.ts b/src/commands/channels/occupancy/get.ts index 4a6719c4f..4157d65bc 100644 --- a/src/commands/channels/occupancy/get.ts +++ b/src/commands/channels/occupancy/get.ts @@ -70,8 +70,10 @@ export default class ChannelsOccupancyGet extends AblyBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult( { - channel: channelName, - metrics: occupancyMetrics, + occupancy: { + channelName, + metrics: occupancyMetrics, + }, }, flags, ); @@ -80,32 +82,21 @@ export default class ChannelsOccupancyGet extends AblyBaseCommand { `Occupancy metrics for channel ${formatResource(channelName)}:\n`, ); this.log( - `${formatLabel("Connections")} ${occupancyMetrics.connections ?? 0}`, + `${formatLabel("Connections")} ${occupancyMetrics.connections}`, ); + this.log(`${formatLabel("Publishers")} ${occupancyMetrics.publishers}`); this.log( - `${formatLabel("Publishers")} ${occupancyMetrics.publishers ?? 0}`, + `${formatLabel("Subscribers")} ${occupancyMetrics.subscribers}`, ); this.log( - `${formatLabel("Subscribers")} ${occupancyMetrics.subscribers ?? 0}`, + `${formatLabel("Presence Connections")} ${occupancyMetrics.presenceConnections}`, + ); + this.log( + `${formatLabel("Presence Members")} ${occupancyMetrics.presenceMembers}`, + ); + this.log( + `${formatLabel("Presence Subscribers")} ${occupancyMetrics.presenceSubscribers}`, ); - - if (occupancyMetrics.presenceConnections !== undefined) { - this.log( - `${formatLabel("Presence Connections")} ${occupancyMetrics.presenceConnections}`, - ); - } - - if (occupancyMetrics.presenceMembers !== undefined) { - this.log( - `${formatLabel("Presence Members")} ${occupancyMetrics.presenceMembers}`, - ); - } - - if (occupancyMetrics.presenceSubscribers !== undefined) { - this.log( - `${formatLabel("Presence Subscribers")} ${occupancyMetrics.presenceSubscribers}`, - ); - } } } catch (error) { this.fail(error, flags, "occupancyGet", { diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index 7e393e160..a92eb227e 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -89,7 +89,7 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { await channel.subscribe(occupancyEventName, (message: Ably.Message) => { const timestamp = formatMessageTimestamp(message.timestamp); const event = { - channel: channelName, + channelName, event: occupancyEventName, data: message.data, timestamp, @@ -103,19 +103,35 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { ); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(event, flags); + this.logJsonEvent({ occupancy: event }, flags); } else { + this.log(formatTimestamp(timestamp)); + this.log(`${formatLabel("Channel")} ${formatResource(channelName)}`); this.log( - `${formatTimestamp(timestamp)} ${formatResource(`Channel: ${channelName}`)} | ${formatEventType("Occupancy Update")}`, + `${formatLabel("Event")} ${formatEventType("Occupancy Update")}`, ); - if (message.data !== null && message.data !== undefined) { + if (message.data?.metrics) { + const metrics = message.data.metrics; this.log( - `${formatLabel("Occupancy Data")} ${JSON.stringify(message.data, null, 2)}`, + `${formatLabel("Connections")} ${metrics.connections ?? 0}`, + ); + this.log(`${formatLabel("Publishers")} ${metrics.publishers ?? 0}`); + this.log( + `${formatLabel("Subscribers")} ${metrics.subscribers ?? 0}`, + ); + this.log( + `${formatLabel("Presence Connections")} ${metrics.presenceConnections ?? 0}`, + ); + this.log( + `${formatLabel("Presence Members")} ${metrics.presenceMembers ?? 0}`, + ); + this.log( + `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers ?? 0}`, ); } - this.log(""); // Empty line for better readability + this.log(""); } }); diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index 19b0b291c..5230e6dbb 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -5,7 +5,6 @@ import { formatCountLabel, formatLimitWarning, formatResource, - formatLabel, } from "../../utils/output.js"; import { buildPaginationNext, @@ -13,25 +12,9 @@ import { formatPaginationLog, } from "../../utils/pagination.js"; -// Add interface definitions at the beginning of the file -interface RoomMetrics { - connections?: number; - presenceConnections?: number; - presenceMembers?: number; - publishers?: number; - subscribers?: number; -} - -interface RoomStatus { - occupancy?: { - metrics?: RoomMetrics; - }; -} - interface RoomItem { channelId: string; room: string; - status?: RoomStatus; [key: string]: unknown; } @@ -140,7 +123,18 @@ export default class RoomsList extends ChatBaseCommand { // Output rooms based on format if (this.shouldOutputJson(flags)) { const next = buildPaginationNext(hasMore); - this.logJsonResult({ rooms, hasMore, ...(next && { next }) }, flags); + this.logJsonResult( + { + rooms: rooms.map((room) => ({ + roomName: room.room, + })), + hasMore, + ...(next && { next }), + timestamp: new Date().toISOString(), + total: rooms.length, + }, + flags, + ); } else { if (rooms.length === 0) { this.log("No active chat rooms found."); @@ -148,39 +142,11 @@ export default class RoomsList extends ChatBaseCommand { } this.log( - `Found ${formatCountLabel(rooms.length, "active chat room")}:`, + `Found ${formatCountLabel(rooms.length, "active chat room")}:\n`, ); for (const room of rooms) { this.log(`${formatResource(room.room)}`); - - // Show occupancy if available - if (room.status?.occupancy?.metrics) { - const { metrics } = room.status.occupancy; - this.log( - ` ${formatLabel("Connections")} ${metrics.connections || 0}`, - ); - this.log( - ` ${formatLabel("Publishers")} ${metrics.publishers || 0}`, - ); - this.log( - ` ${formatLabel("Subscribers")} ${metrics.subscribers || 0}`, - ); - - if (metrics.presenceConnections !== undefined) { - this.log( - ` ${formatLabel("Presence Connections")} ${metrics.presenceConnections}`, - ); - } - - if (metrics.presenceMembers !== undefined) { - this.log( - ` ${formatLabel("Presence Members")} ${metrics.presenceMembers}`, - ); - } - } - - this.log(""); // Add a line break between rooms } if (hasMore) { @@ -189,7 +155,7 @@ export default class RoomsList extends ChatBaseCommand { flags.limit, "rooms", ); - if (warning) this.log(warning); + if (warning) this.log(`\n${warning}`); } } } catch (error) { diff --git a/src/commands/rooms/occupancy/get.ts b/src/commands/rooms/occupancy/get.ts index 31e9c28a6..e4fb63db0 100644 --- a/src/commands/rooms/occupancy/get.ts +++ b/src/commands/rooms/occupancy/get.ts @@ -1,10 +1,21 @@ import { Args } from "@oclif/core"; -import { ChatClient, Room } from "@ably/chat"; -import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { clientIdFlag, productApiFlags } from "../../../flags.js"; -import { formatResource } from "../../../utils/output.js"; -export default class RoomsOccupancyGet extends ChatBaseCommand { +import { AblyBaseCommand } from "../../../base-command.js"; +import { productApiFlags } from "../../../flags.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; + +const CHAT_CHANNEL_TAG = "::$chat"; + +interface OccupancyMetrics { + connections: number; + presenceConnections: number; + presenceMembers: number; + presenceSubscribers: number; + publishers: number; + subscribers: number; +} + +export default class RoomsOccupancyGet extends AblyBaseCommand { static override args = { room: Args.string({ description: "Room to get occupancy for", @@ -16,54 +27,71 @@ export default class RoomsOccupancyGet extends ChatBaseCommand { static override examples = [ "$ ably rooms occupancy get my-room", - '$ ABLY_API_KEY="YOUR_API_KEY" ably rooms occupancy get my-room', "$ ably rooms occupancy get my-room --json", "$ ably rooms occupancy get my-room --pretty-json", ]; static override flags = { ...productApiFlags, - ...clientIdFlag, }; - private chatClient: ChatClient | null = null; - private room: Room | null = null; - async run(): Promise { const { args, flags } = await this.parse(RoomsOccupancyGet); try { - // Create Chat client - this.chatClient = await this.createChatClient(flags, { restOnly: true }); + const client = await this.createAblyRestClient(flags); + if (!client) return; - if (!this.chatClient) { - return this.fail( - "Failed to create Chat client", - flags, - "roomOccupancyGet", - ); - } + const roomName = args.room; + const channelName = `${roomName}${CHAT_CHANNEL_TAG}`; - const { room: roomName } = args; + const channelDetails = await client.request( + "get", + `/channels/${encodeURIComponent(channelName)}`, + 2, + { occupancy: "metrics" }, + null, + ); - this.room = await this.chatClient.rooms.get(roomName); + const occupancyData = channelDetails.items?.[0] || {}; + const occupancyMetrics: OccupancyMetrics = occupancyData.status?.occupancy + ?.metrics || { + connections: 0, + presenceConnections: 0, + presenceMembers: 0, + presenceSubscribers: 0, + publishers: 0, + subscribers: 0, + }; - const occupancyMetrics = await this.room.occupancy.get(); - - // Output the occupancy metrics based on format if (this.shouldOutputJson(flags)) { this.logJsonResult( { - metrics: occupancyMetrics, - room: roomName, + occupancy: { + roomName, + metrics: occupancyMetrics, + }, }, flags, ); } else { this.log(`Occupancy metrics for room ${formatResource(roomName)}:\n`); - this.log(`Connections: ${occupancyMetrics.connections ?? 0}`); - - this.log(`Presence Members: ${occupancyMetrics.presenceMembers ?? 0}`); + this.log( + `${formatLabel("Connections")} ${occupancyMetrics.connections}`, + ); + this.log(`${formatLabel("Publishers")} ${occupancyMetrics.publishers}`); + this.log( + `${formatLabel("Subscribers")} ${occupancyMetrics.subscribers}`, + ); + this.log( + `${formatLabel("Presence Connections")} ${occupancyMetrics.presenceConnections}`, + ); + this.log( + `${formatLabel("Presence Members")} ${occupancyMetrics.presenceMembers}`, + ); + this.log( + `${formatLabel("Presence Subscribers")} ${occupancyMetrics.presenceSubscribers}`, + ); } } catch (error) { this.fail(error, flags, "roomOccupancyGet", { room: args.room }); diff --git a/src/commands/rooms/occupancy/subscribe.ts b/src/commands/rooms/occupancy/subscribe.ts index 3cbc77e42..720831777 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -1,36 +1,35 @@ -import { OccupancyEvent, ChatClient } from "@ably/chat"; import { Args } from "@oclif/core"; -import chalk from "chalk"; +import * as Ably from "ably"; -import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { errorMessage } from "../../../utils/errors.js"; +import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { + formatEventType, + formatLabel, + formatListening, + formatMessageTimestamp, formatProgress, formatResource, + formatSuccess, formatTimestamp, } from "../../../utils/output.js"; -export interface OccupancyMetrics { - connections?: number; - presenceMembers?: number; -} +const CHAT_CHANNEL_TAG = "::$chat"; -export default class RoomsOccupancySubscribe extends ChatBaseCommand { +export default class RoomsOccupancySubscribe extends AblyBaseCommand { static override args = { room: Args.string({ - description: "Room to subscribe to occupancy for", + description: "Room to subscribe to occupancy events", required: true, }), }; - static override description = - "Subscribe to real-time occupancy metrics for a room"; + static override description = "Subscribe to occupancy events on a room"; static override examples = [ "$ ably rooms occupancy subscribe my-room", "$ ably rooms occupancy subscribe my-room --json", - "$ ably rooms occupancy subscribe --pretty-json my-room", + "$ ably rooms occupancy subscribe my-room --duration 30", ]; static override flags = { @@ -39,186 +38,120 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { ...durationFlag, }; - private chatClient: ChatClient | null = null; - private roomName: string | null = null; + private client: Ably.Realtime | null = null; async run(): Promise { const { args, flags } = await this.parse(RoomsOccupancySubscribe); - this.roomName = args.room; // Store for cleanup + let channel: Ably.RealtimeChannel | null = null; try { - this.logCliEvent( - flags, - "subscribe", - "connecting", - "Connecting to Ably...", - ); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Connecting to Ably")); - } + this.client = await this.createAblyRealtimeClient(flags); + if (!this.client) return; - // Create Chat client - this.chatClient = await this.createChatClient(flags); + const roomName = args.room; + const channelName = `${roomName}${CHAT_CHANNEL_TAG}`; + const occupancyEventName = "[meta]occupancy"; - if (!this.chatClient) { - return this.fail( - "Failed to create Chat client", - flags, - "roomOccupancySubscribe", - ); - } - - // Set up connection state logging - this.setupConnectionStateLogging(this.chatClient.realtime, flags, { - includeUserFriendlyMessages: true, + // Get channel with occupancy metrics enabled + channel = this.client.channels.get(channelName, { + params: { occupancy: "metrics" }, }); - // Get the room with occupancy option enabled - this.logCliEvent( - flags, - "room", - "gettingRoom", - `Getting room handle for ${this.roomName}`, - ); - const room = await this.chatClient.rooms.get(this.roomName, { - occupancy: { enableEvents: true }, + // Set up connection and channel state logging + this.setupConnectionStateLogging(this.client, flags, { + includeUserFriendlyMessages: true, }); - this.logCliEvent( - flags, - "room", - "gotRoom", - `Got room handle for ${this.roomName}`, - ); - - // Subscribe to room status changes - this.setupRoomStatusHandler(room, flags, { - roomName: this.roomName!, - successMessage: `Subscribed to occupancy in room: ${formatResource(this.roomName!)}.`, - listeningMessage: "Listening for occupancy updates.", + this.setupChannelStateLogging(channel, flags, { + includeUserFriendlyMessages: true, }); - // Attach to the room this.logCliEvent( flags, - "room", - "attaching", - `Attaching to room ${this.roomName}`, + "roomOccupancy", + "subscribing", + `Subscribing to occupancy events on room: ${roomName}`, + { roomName, channel: channelName }, ); - await room.attach(); - // Successful attach logged by onStatusChange handler - // Get the initial occupancy metrics - this.logCliEvent( - flags, - "occupancy", - "gettingInitial", - "Fetching initial occupancy metrics", - ); - try { - const initialOccupancy = await room.occupancy.get(); - this.logCliEvent( - flags, - "occupancy", - "gotInitial", - "Initial occupancy metrics fetched", - { metrics: initialOccupancy }, - ); - this.displayOccupancyMetrics( - initialOccupancy, - this.roomName, - flags, - true, + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Subscribing to occupancy events on room: ${formatResource(roomName)}`, + ), ); - } catch (error) { - const errorMsg = `Failed to fetch initial occupancy: ${errorMessage(error)}`; - this.logCliEvent(flags, "occupancy", "getInitialError", errorMsg, { - error: errorMsg, - }); - if (!this.shouldOutputJson(flags)) { - this.log(chalk.yellow(errorMsg)); - } } - // Subscribe to occupancy events - this.logCliEvent( - flags, - "occupancy", - "subscribing", - "Subscribing to occupancy updates", - ); - room.occupancy.subscribe((occupancyEvent: OccupancyEvent) => { - const occupancyMetrics = occupancyEvent.occupancy; + await channel.subscribe(occupancyEventName, (message: Ably.Message) => { + const timestamp = formatMessageTimestamp(message.timestamp); + const event = { + roomName, + event: occupancyEventName, + data: message.data, + timestamp, + }; + this.logCliEvent( flags, - "occupancy", - "updateReceived", - "Occupancy update received", - { metrics: occupancyMetrics }, + "roomOccupancy", + "occupancyUpdate", + `Occupancy update received for room ${roomName}`, + event, ); - this.displayOccupancyMetrics(occupancyMetrics, this.roomName, flags); + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent({ occupancy: event }, flags); + } else { + this.log(formatTimestamp(timestamp)); + this.log(`${formatLabel("Room")} ${formatResource(roomName)}`); + this.log( + `${formatLabel("Event")} ${formatEventType("Occupancy Update")}`, + ); + + if (message.data?.metrics) { + const metrics = message.data.metrics; + this.log( + `${formatLabel("Connections")} ${metrics.connections ?? 0}`, + ); + this.log(`${formatLabel("Publishers")} ${metrics.publishers ?? 0}`); + this.log( + `${formatLabel("Subscribers")} ${metrics.subscribers ?? 0}`, + ); + this.log( + `${formatLabel("Presence Connections")} ${metrics.presenceConnections ?? 0}`, + ); + this.log( + `${formatLabel("Presence Members")} ${metrics.presenceMembers ?? 0}`, + ); + this.log( + `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers ?? 0}`, + ); + } + + this.log(""); + } }); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess( + `Subscribed to occupancy on room: ${formatResource(roomName)}.`, + ), + ); + this.log(formatListening("Listening for occupancy events.")); + } + this.logCliEvent( flags, - "occupancy", - "subscribed", - "Subscribed to occupancy updates", + "roomOccupancy", + "listening", + "Listening for occupancy events. Press Ctrl+C to exit.", ); - // Wait until the user interrupts or the optional duration elapses - await this.waitAndTrackCleanup(flags, "occupancy", flags.duration); + await this.waitAndTrackCleanup(flags, "roomOccupancy", flags.duration); } catch (error) { this.fail(error, flags, "roomOccupancySubscribe", { - room: this.roomName, + room: args.room, }); } } - - private displayOccupancyMetrics( - occupancyMetrics: OccupancyMetrics | OccupancyEvent, - roomName: string | null, - flags: Record, - isInitial = false, - ): void { - if (!roomName) return; // Guard against null roomName - if (!occupancyMetrics) return; // Guard against undefined occupancyMetrics - - const timestamp = new Date().toISOString(); - const logData = { - metrics: occupancyMetrics, - room: roomName, - timestamp, - eventType: isInitial ? "initialSnapshot" : "update", - }; - this.logCliEvent( - flags, - "occupancy", - isInitial ? "initialMetrics" : "updateReceived", - isInitial ? "Initial occupancy metrics" : "Occupancy update received", - logData, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonEvent(logData, flags); - } else { - const prefix = isInitial ? "Initial occupancy" : "Occupancy update"; - this.log( - `${formatTimestamp(timestamp)} ${prefix} for room ${formatResource(roomName)}`, - ); - // Type guard to handle both OccupancyMetrics and OccupancyEvent - const connections = - "connections" in occupancyMetrics ? occupancyMetrics.connections : 0; - const presenceMembers = - "presenceMembers" in occupancyMetrics - ? occupancyMetrics.presenceMembers - : undefined; - - this.log(` Connections: ${connections ?? 0}`); - - if (presenceMembers !== undefined) { - this.log(` Presence Members: ${presenceMembers}`); - } - - this.log(""); // Empty line for better readability - } - } } diff --git a/test/e2e/channels/channel-occupancy-e2e.test.ts b/test/e2e/channels/channel-occupancy-e2e.test.ts index d26ed70a6..51d0aae4b 100644 --- a/test/e2e/channels/channel-occupancy-e2e.test.ts +++ b/test/e2e/channels/channel-occupancy-e2e.test.ts @@ -173,7 +173,7 @@ describe("Channel Occupancy E2E Tests", () => { const fs = await import("node:fs"); const output = fs.readFileSync(outputPath, "utf8"); expect(output).toContain("Occupancy Update"); - expect(output).toContain("metrics"); + expect(output).toContain("Connections:"); console.log(`Occupancy subscription test completed successfully`); } finally { diff --git a/test/unit/commands/channels/list.test.ts b/test/unit/commands/channels/list.test.ts index 0f923e7bd..cf4f389ec 100644 --- a/test/unit/commands/channels/list.test.ts +++ b/test/unit/commands/channels/list.test.ts @@ -14,32 +14,8 @@ describe("channels:list command", () => { // Mock channel response data const mockChannelsResponse = { ...createMockPaginatedResult([ - { - channelId: "test-channel-1", - status: { - occupancy: { - metrics: { - connections: 5, - publishers: 2, - subscribers: 3, - presenceConnections: 1, - presenceMembers: 2, - }, - }, - }, - }, - { - channelId: "test-channel-2", - status: { - occupancy: { - metrics: { - connections: 3, - publishers: 1, - subscribers: 2, - }, - }, - }, - }, + { channelId: "test-channel-1" }, + { channelId: "test-channel-2" }, ]), statusCode: 200, }; @@ -74,14 +50,6 @@ describe("channels:list command", () => { expect(stdout).toContain("test-channel-2"); }); - it("should display channel metrics", async () => { - const { stdout } = await runCommand(["channels:list"], import.meta.url); - - expect(stdout).toContain("Connections: 5"); - expect(stdout).toContain("Publishers: 2"); - expect(stdout).toContain("Subscribers: 3"); - }); - it("should handle empty channels response", async () => { const mock = getMockAblyRest(); mock.request.mockResolvedValue({ @@ -140,35 +108,18 @@ describe("channels:list command", () => { expect(jsonOutput).toHaveProperty("channels"); expect(jsonOutput.channels).toBeInstanceOf(Array); expect(jsonOutput.channels).toHaveLength(2); - expect(jsonOutput.channels[0]).toHaveProperty( - "channelId", - "test-channel-1", - ); - expect(jsonOutput.channels[0]).toHaveProperty("metrics"); + expect(jsonOutput.channels[0]).toEqual({ + channelId: "test-channel-1", + }); + expect(jsonOutput.channels[1]).toEqual({ + channelId: "test-channel-2", + }); expect(jsonOutput).toHaveProperty("success", true); expect(jsonOutput).toHaveProperty("total", 2); expect(jsonOutput).toHaveProperty("hasMore", false); expect(jsonOutput).toHaveProperty("timestamp"); }); - it("should include channel metrics in JSON output", async () => { - const { stdout } = await runCommand( - ["channels:list", "--json"], - import.meta.url, - ); - - const jsonOutput = JSON.parse(stdout); - - // Verify metrics are included - expect(jsonOutput.channels[0].metrics).toEqual({ - connections: 5, - publishers: 2, - subscribers: 3, - presenceConnections: 1, - presenceMembers: 2, - }); - }); - it("should handle API errors in JSON mode", async () => { const mock = getMockAblyRest(); mock.request.mockRejectedValue(new Error("Network error")); diff --git a/test/unit/commands/channels/occupancy/get.test.ts b/test/unit/commands/channels/occupancy/get.test.ts index 0332a4c73..47752ee3d 100644 --- a/test/unit/commands/channels/occupancy/get.test.ts +++ b/test/unit/commands/channels/occupancy/get.test.ts @@ -70,9 +70,12 @@ describe("ChannelsOccupancyGet", function () { // Parse and verify the JSON output const parsedOutput = JSON.parse(stdout.trim()); - expect(parsedOutput).toHaveProperty("channel", "test-occupancy-channel"); - expect(parsedOutput).toHaveProperty("metrics"); - expect(parsedOutput.metrics).toMatchObject({ + expect(parsedOutput).toHaveProperty("occupancy"); + expect(parsedOutput.occupancy).toHaveProperty( + "channelName", + "test-occupancy-channel", + ); + expect(parsedOutput.occupancy.metrics).toMatchObject({ connections: 10, presenceConnections: 5, presenceMembers: 8, @@ -97,11 +100,14 @@ describe("ChannelsOccupancyGet", function () { import.meta.url, ); - // Check for expected output with zeros + // Check for expected output with zeros — all 6 fields shown unconditionally expect(stdout).toContain("test-empty-channel"); expect(stdout).toContain("Connections: 0"); expect(stdout).toContain("Publishers: 0"); expect(stdout).toContain("Subscribers: 0"); + expect(stdout).toContain("Presence Connections: 0"); + expect(stdout).toContain("Presence Members: 0"); + expect(stdout).toContain("Presence Subscribers: 0"); }); describe("functionality", () => { diff --git a/test/unit/commands/channels/occupancy/subscribe.test.ts b/test/unit/commands/channels/occupancy/subscribe.test.ts index 0351567d5..990ca03c7 100644 --- a/test/unit/commands/channels/occupancy/subscribe.test.ts +++ b/test/unit/commands/channels/occupancy/subscribe.test.ts @@ -192,13 +192,14 @@ describe("channels:occupancy:subscribe command", () => { await commandPromise; }); const events = records.filter( - (r) => r.type === "event" && r.channel === "test-channel", + (r) => + r.type === "event" && r.occupancy?.channelName === "test-channel", ); expect(events.length).toBeGreaterThan(0); const record = events[0]; expect(record).toHaveProperty("type", "event"); expect(record).toHaveProperty("command", "channels:occupancy:subscribe"); - expect(record).toHaveProperty("channel", "test-channel"); + expect(record.occupancy).toHaveProperty("channelName", "test-channel"); }); }); }); diff --git a/test/unit/commands/rooms/features.test.ts b/test/unit/commands/rooms/features.test.ts index bc390651f..a81d3e737 100644 --- a/test/unit/commands/rooms/features.test.ts +++ b/test/unit/commands/rooms/features.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblyChat } from "../../../helpers/mock-ably-chat.js"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; describe("rooms feature commands", function () { beforeEach(function () { @@ -9,12 +11,24 @@ describe("rooms feature commands", function () { describe("functionality", function () { it("should get room occupancy metrics", async function () { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - - room.occupancy.get.mockResolvedValue({ - connections: 5, - presenceMembers: 4, + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + status: { + occupancy: { + metrics: { + connections: 5, + presenceConnections: 2, + presenceMembers: 4, + presenceSubscribers: 1, + publishers: 3, + subscribers: 6, + }, + }, + }, + }, + ], }); const { stdout } = await runCommand( @@ -22,56 +36,62 @@ describe("rooms feature commands", function () { import.meta.url, ); - expect(room.occupancy.get).toHaveBeenCalled(); + expect(mock.request).toHaveBeenCalled(); expect(stdout).toContain("5"); }); }); describe("rooms occupancy subscribe", function () { it("should subscribe to room occupancy updates", async function () { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-room::$chat"); - room.occupancy.subscribe.mockImplementation( - (_callback: (event: unknown) => void) => { - return { unsubscribe: vi.fn() }; + mock.connection.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") callback(); }, ); + channel.once.mockImplementation((event: string, callback: () => void) => { + if (event === "attached") { + channel.state = "attached"; + callback(); + } + }); const { stdout } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - expect(room.attach).toHaveBeenCalled(); - expect(room.occupancy.subscribe).toHaveBeenCalled(); + expect(channel.subscribe).toHaveBeenCalledWith( + "[meta]occupancy", + expect.any(Function), + ); expect(stdout).toContain("Subscribed to occupancy"); }); - it("should display occupancy updates when received", async function () { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); + it("should display subscribing message", async function () { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-room::$chat"); - room.occupancy.subscribe.mockImplementation( - (callback: (event: unknown) => void) => { - // Simulate receiving an occupancy update after room is attached - setTimeout(() => { - callback({ - connections: 6, - presenceMembers: 4, - }); - }, 100); - return { unsubscribe: vi.fn() }; + mock.connection.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") callback(); }, ); + channel.once.mockImplementation((event: string, callback: () => void) => { + if (event === "attached") { + channel.state = "attached"; + callback(); + } + }); const { stdout } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - // Check for either the number or part of the occupancy output - expect(stdout).toMatch(/6|connections/i); + expect(stdout).toContain("Subscribing to occupancy events on room"); }); }); @@ -183,10 +203,8 @@ describe("rooms feature commands", function () { describe("error handling", () => { it("should handle occupancy get failure", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - - room.occupancy.get.mockRejectedValue(new Error("Connection failed")); + const mock = getMockAblyRest(); + mock.request.mockRejectedValue(new Error("Connection failed")); const { error } = await runCommand( ["rooms:occupancy:get", "test-room"], diff --git a/test/unit/commands/rooms/list.test.ts b/test/unit/commands/rooms/list.test.ts index 7838da109..a4ed29fb2 100644 --- a/test/unit/commands/rooms/list.test.ts +++ b/test/unit/commands/rooms/list.test.ts @@ -121,14 +121,6 @@ describe("rooms:list command", () => { expect(stdout).toContain("No active chat rooms found"); }); - it("should display occupancy metrics when present", async () => { - const { stdout } = await runCommand(["rooms:list"], import.meta.url); - - expect(stdout).toContain("Connections:"); - expect(stdout).toContain("Publishers:"); - expect(stdout).toContain("Subscribers:"); - }); - it("should output JSON with items array", async () => { const { stdout } = await runCommand( ["rooms:list", "--json"], @@ -139,8 +131,11 @@ describe("rooms:list command", () => { expect(json).toHaveProperty("rooms"); expect(json.rooms).toBeInstanceOf(Array); expect(json.rooms.length).toBe(2); - expect(json.rooms[0]).toHaveProperty("room", "room1"); - expect(json.rooms[1]).toHaveProperty("room", "room2"); + expect(json.rooms[0]).toEqual({ roomName: "room1" }); + expect(json.rooms[1]).toEqual({ roomName: "room2" }); + expect(json).toHaveProperty("total", 2); + expect(json).toHaveProperty("timestamp"); + expect(json).toHaveProperty("hasMore", false); }); it("should handle non-200 response with error", async () => { diff --git a/test/unit/commands/rooms/occupancy/get.test.ts b/test/unit/commands/rooms/occupancy/get.test.ts index f26027dc6..c7f0a00b7 100644 --- a/test/unit/commands/rooms/occupancy/get.test.ts +++ b/test/unit/commands/rooms/occupancy/get.test.ts @@ -1,15 +1,35 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; +import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; import { standardHelpTests, standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +const mockOccupancyMetrics = { + connections: 10, + presenceConnections: 5, + presenceMembers: 8, + presenceSubscribers: 4, + publishers: 2, + subscribers: 6, +}; + describe("rooms:occupancy:get command", () => { beforeEach(() => { - getMockAblyChat(); + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + status: { + occupancy: { + metrics: mockOccupancyMetrics, + }, + }, + }, + ], + }); }); standardHelpTests("rooms:occupancy:get", import.meta.url); @@ -19,30 +39,34 @@ describe("rooms:occupancy:get command", () => { standardFlagTests("rooms:occupancy:get", import.meta.url, ["--json"]); describe("functionality", () => { - it("should display occupancy metrics", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockResolvedValue({ - connections: 5, - presenceMembers: 3, - }); + it("should display all 6 occupancy metrics", async () => { + const mock = getMockAblyRest(); const { stdout } = await runCommand( ["rooms:occupancy:get", "test-room"], import.meta.url, ); - expect(room.occupancy.get).toHaveBeenCalled(); - expect(stdout).toContain("Connections: 5"); - expect(stdout).toContain("Presence Members: 3"); + expect(mock.request).toHaveBeenCalledOnce(); + const [method, path, version, params] = mock.request.mock.calls[0]; + expect(method).toBe("get"); + expect(path).toBe("/channels/test-room%3A%3A%24chat"); + expect(version).toBe(2); + expect(params).toEqual({ occupancy: "metrics" }); + + expect(stdout).toContain("test-room"); + expect(stdout).toContain("Connections: 10"); + expect(stdout).toContain("Publishers: 2"); + expect(stdout).toContain("Subscribers: 6"); + expect(stdout).toContain("Presence Connections: 5"); + expect(stdout).toContain("Presence Members: 8"); + expect(stdout).toContain("Presence Subscribers: 4"); }); it("should handle zero metrics", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockResolvedValue({ - connections: 0, - presenceMembers: 0, + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [{}], }); const { stdout } = await runCommand( @@ -51,17 +75,14 @@ describe("rooms:occupancy:get command", () => { ); expect(stdout).toContain("Connections: 0"); + expect(stdout).toContain("Publishers: 0"); + expect(stdout).toContain("Subscribers: 0"); + expect(stdout).toContain("Presence Connections: 0"); expect(stdout).toContain("Presence Members: 0"); + expect(stdout).toContain("Presence Subscribers: 0"); }); - it("should output JSON with metrics", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockResolvedValue({ - connections: 10, - presenceMembers: 7, - }); - + it("should output JSON nested under occupancy key", async () => { const { stdout } = await runCommand( ["rooms:occupancy:get", "test-room", "--json"], import.meta.url, @@ -69,16 +90,14 @@ describe("rooms:occupancy:get command", () => { const result = JSON.parse(stdout); expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("room", "test-room"); - expect(result).toHaveProperty("metrics"); - expect(result.metrics.connections).toBe(10); - expect(result.metrics.presenceMembers).toBe(7); + expect(result).toHaveProperty("occupancy"); + expect(result.occupancy).toHaveProperty("roomName", "test-room"); + expect(result.occupancy.metrics).toMatchObject(mockOccupancyMetrics); }); it("should output JSON error on failure", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockRejectedValue(new Error("Service unavailable")); + const mock = getMockAblyRest(); + mock.request.mockRejectedValue(new Error("Service unavailable")); const { stdout } = await runCommand( ["rooms:occupancy:get", "test-room", "--json"], @@ -93,9 +112,8 @@ describe("rooms:occupancy:get command", () => { describe("error handling", () => { it("should handle occupancy fetch failure gracefully", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockRejectedValue(new Error("Service unavailable")); + const mock = getMockAblyRest(); + mock.request.mockRejectedValue(new Error("Service unavailable")); const { error } = await runCommand( ["rooms:occupancy:get", "test-room"], diff --git a/test/unit/commands/rooms/occupancy/subscribe.test.ts b/test/unit/commands/rooms/occupancy/subscribe.test.ts index 5534c02ef..4eb5c7a77 100644 --- a/test/unit/commands/rooms/occupancy/subscribe.test.ts +++ b/test/unit/commands/rooms/occupancy/subscribe.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { runCommand } from "@oclif/test"; -import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; +import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; import { captureJsonLogs } from "../../../../helpers/ndjson.js"; import { standardHelpTests, @@ -10,7 +10,25 @@ import { describe("rooms:occupancy:subscribe command", () => { beforeEach(() => { - getMockAblyChat(); + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-room::$chat"); + + // Configure connection.once to immediately call callback for 'connected' + mock.connection.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") { + callback(); + } + }, + ); + + // Configure channel.once to immediately call callback for 'attached' + channel.once.mockImplementation((event: string, callback: () => void) => { + if (event === "attached") { + channel.state = "attached"; + callback(); + } + }); }); standardHelpTests("rooms:occupancy:subscribe", import.meta.url); @@ -20,112 +38,127 @@ describe("rooms:occupancy:subscribe command", () => { standardFlagTests("rooms:occupancy:subscribe", import.meta.url, ["--json"]); describe("functionality", () => { - it("should display initial occupancy snapshot", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockResolvedValue({ - connections: 3, - presenceMembers: 1, - }); + it("should subscribe to occupancy events and show initial message", async () => { + const mock = getMockAblyRealtime(); const { stdout } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - expect(room.attach).toHaveBeenCalled(); - expect(room.occupancy.get).toHaveBeenCalled(); - expect(stdout).toContain("Initial occupancy"); - expect(stdout).toContain("Connections: 3"); + expect(stdout).toContain("Subscribing to occupancy events on room"); + expect(stdout).toContain("test-room"); + expect(mock.channels.get).toHaveBeenCalledWith("test-room::$chat", { + params: { occupancy: "metrics" }, + }); }); - it("should warn on initial fetch failure but continue listening", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockRejectedValue(new Error("Fetch failed")); + it("should subscribe to [meta]occupancy event", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-room::$chat"); - const { stdout } = await runCommand( + await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - expect(stdout).toContain("Failed to fetch initial occupancy"); - expect(stdout).toContain("Listening"); + expect(channel.subscribe).toHaveBeenCalledWith( + "[meta]occupancy", + expect.any(Function), + ); }); - it("should subscribe and display updates", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - const capturedLogs: string[] = []; + it("should not fetch initial occupancy (passive observer)", async () => { + getMockAblyRealtime(); - const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { - capturedLogs.push(String(msg)); - }); - - let occupancyCallback: ((event: unknown) => void) | null = null; - room.occupancy.subscribe.mockImplementation((callback) => { - occupancyCallback = callback; - return { unsubscribe: vi.fn() }; - }); - - const commandPromise = runCommand( + const { stdout } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - await vi.waitFor( - () => { - expect(room.occupancy.subscribe).toHaveBeenCalled(); + // Should NOT contain initial snapshot text (subscribe = passive observer) + expect(stdout).not.toContain("Initial occupancy"); + // Should show listening message + expect(stdout).toContain("Listening"); + }); + + it("should emit JSON envelope with occupancy nesting for events", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-room::$chat"); + + let occupancyCallback: ((message: unknown) => void) | null = null; + channel.subscribe.mockImplementation( + ( + eventOrCallback: string | ((msg: unknown) => void), + callback?: (msg: unknown) => void, + ) => { + if (typeof eventOrCallback === "string" && callback) { + occupancyCallback = callback; + } }, - { timeout: 1000 }, ); - if (occupancyCallback) { - occupancyCallback({ - occupancy: { connections: 8, presenceMembers: 4 }, + const records = await captureJsonLogs(async () => { + const commandPromise = runCommand( + ["rooms:occupancy:subscribe", "test-room", "--json"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(occupancyCallback).not.toBeNull(); }); - } - await commandPromise; - logSpy.mockRestore(); + occupancyCallback!({ + data: { + metrics: { + connections: 5, + publishers: 2, + subscribers: 3, + presenceConnections: 1, + presenceMembers: 4, + presenceSubscribers: 0, + }, + }, + timestamp: Date.now(), + }); - expect(room.occupancy.subscribe).toHaveBeenCalled(); + await commandPromise; + }); + + const events = records.filter( + (r) => r.type === "event" && r.occupancy?.roomName === "test-room", + ); + expect(events.length).toBeGreaterThan(0); + const record = events[0]; + expect(record).toHaveProperty("type", "event"); + expect(record).toHaveProperty("command", "rooms:occupancy:subscribe"); + expect(record.occupancy).toHaveProperty("roomName", "test-room"); + expect(record.occupancy).toHaveProperty("event", "[meta]occupancy"); }); + }); - it("should output JSON with type field", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.occupancy.get.mockResolvedValue({ - connections: 2, - presenceMembers: 0, - }); + describe("error handling", () => { + it("should handle subscription errors gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-room::$chat"); - const allRecords = await captureJsonLogs(async () => { - await runCommand( - ["rooms:occupancy:subscribe", "test-room", "--json"], - import.meta.url, - ); + channel.subscribe.mockImplementation(() => { + throw new Error("Subscription failed"); }); - // Find the JSON output with initial snapshot - const records = allRecords.filter( - (r) => r.type === "event" && r.eventType === "initialSnapshot", + const { error } = await runCommand( + ["rooms:occupancy:subscribe", "test-room"], + import.meta.url, ); - expect(records.length).toBeGreaterThan(0); - const parsed = records[0]; - expect(parsed).toHaveProperty("type", "event"); - expect(parsed).toHaveProperty("eventType", "initialSnapshot"); - expect(parsed).toHaveProperty("room", "test-room"); + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Subscription failed/i); }); - }); - - describe("error handling", () => { - it("should handle errors gracefully", async () => { - const chatMock = getMockAblyChat(); - const room = chatMock.rooms._getRoom("test-room"); - room.attach.mockRejectedValue(new Error("Connection failed")); + it("should handle missing mock client in test mode", async () => { + if (globalThis.__TEST_MOCKS__) { + delete globalThis.__TEST_MOCKS__.ablyRealtimeMock; + } const { error } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], @@ -133,6 +166,7 @@ describe("rooms:occupancy:subscribe command", () => { ); expect(error).toBeDefined(); + expect(error?.message).toMatch(/No mock|client/i); }); }); }); From 3b1dffc110ca5fe7860e1f828d7858d23c8a52a7 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 25 Mar 2026 20:24:16 +0530 Subject: [PATCH 2/2] - Added missing `objectPublishers` and `objectSubscribers` - Refactored channels list and rooms list commands to return plain names --- src/commands/channels/list.ts | 4 +--- src/commands/channels/occupancy/get.ts | 10 ++++++++++ src/commands/channels/occupancy/subscribe.ts | 14 ++++++++------ src/commands/rooms/list.ts | 4 +--- src/commands/rooms/occupancy/get.ts | 10 ++++++++++ src/commands/rooms/occupancy/subscribe.ts | 14 ++++++++------ src/commands/spaces/occupancy/get.ts | 10 ++++++++++ src/commands/spaces/occupancy/subscribe.ts | 14 ++++++++------ test/unit/commands/channels/list.test.ts | 8 ++------ test/unit/commands/channels/occupancy/get.test.ts | 6 ++++++ test/unit/commands/rooms/list.test.ts | 4 ++-- test/unit/commands/rooms/occupancy/get.test.ts | 8 +++++++- test/unit/commands/spaces/occupancy/get.test.ts | 6 ++++++ 13 files changed, 79 insertions(+), 33 deletions(-) diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index eb97b919d..f978f31a3 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -106,9 +106,7 @@ export default class ChannelsList extends AblyBaseCommand { const next = buildPaginationNext(hasMore); this.logJsonResult( { - channels: channels.map((channel: ChannelItem) => ({ - channelId: channel.channelId, - })), + channels: channels.map((channel: ChannelItem) => channel.channelId), hasMore, ...(next && { next }), timestamp: new Date().toISOString(), diff --git a/src/commands/channels/occupancy/get.ts b/src/commands/channels/occupancy/get.ts index 4157d65bc..d5b922ce9 100644 --- a/src/commands/channels/occupancy/get.ts +++ b/src/commands/channels/occupancy/get.ts @@ -11,6 +11,8 @@ interface OccupancyMetrics { presenceSubscribers: number; publishers: number; subscribers: number; + objectPublishers: number; + objectSubscribers: number; } export default class ChannelsOccupancyGet extends AblyBaseCommand { @@ -64,6 +66,8 @@ export default class ChannelsOccupancyGet extends AblyBaseCommand { presenceSubscribers: 0, publishers: 0, subscribers: 0, + objectPublishers: 0, + objectSubscribers: 0, }; // Output the occupancy metrics based on format @@ -97,6 +101,12 @@ export default class ChannelsOccupancyGet extends AblyBaseCommand { this.log( `${formatLabel("Presence Subscribers")} ${occupancyMetrics.presenceSubscribers}`, ); + this.log( + `${formatLabel("Object Publishers")} ${occupancyMetrics.objectPublishers}`, + ); + this.log( + `${formatLabel("Object Subscribers")} ${occupancyMetrics.objectSubscribers}`, + ); } } catch (error) { this.fail(error, flags, "occupancyGet", { diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index a92eb227e..eb58a4694 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -113,21 +113,23 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { if (message.data?.metrics) { const metrics = message.data.metrics; + this.log(`${formatLabel("Connections")} ${metrics.connections}`); + this.log(`${formatLabel("Publishers")} ${metrics.publishers}`); + this.log(`${formatLabel("Subscribers")} ${metrics.subscribers}`); this.log( - `${formatLabel("Connections")} ${metrics.connections ?? 0}`, + `${formatLabel("Presence Connections")} ${metrics.presenceConnections}`, ); - this.log(`${formatLabel("Publishers")} ${metrics.publishers ?? 0}`); this.log( - `${formatLabel("Subscribers")} ${metrics.subscribers ?? 0}`, + `${formatLabel("Presence Members")} ${metrics.presenceMembers}`, ); this.log( - `${formatLabel("Presence Connections")} ${metrics.presenceConnections ?? 0}`, + `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers}`, ); this.log( - `${formatLabel("Presence Members")} ${metrics.presenceMembers ?? 0}`, + `${formatLabel("Object Publishers")} ${metrics.objectPublishers}`, ); this.log( - `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers ?? 0}`, + `${formatLabel("Object Subscribers")} ${metrics.objectSubscribers}`, ); } diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index 5230e6dbb..0187bcc38 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -125,9 +125,7 @@ export default class RoomsList extends ChatBaseCommand { const next = buildPaginationNext(hasMore); this.logJsonResult( { - rooms: rooms.map((room) => ({ - roomName: room.room, - })), + rooms: rooms.map((room) => room.room), hasMore, ...(next && { next }), timestamp: new Date().toISOString(), diff --git a/src/commands/rooms/occupancy/get.ts b/src/commands/rooms/occupancy/get.ts index e4fb63db0..feebc8445 100644 --- a/src/commands/rooms/occupancy/get.ts +++ b/src/commands/rooms/occupancy/get.ts @@ -13,6 +13,8 @@ interface OccupancyMetrics { presenceSubscribers: number; publishers: number; subscribers: number; + objectPublishers: number; + objectSubscribers: number; } export default class RoomsOccupancyGet extends AblyBaseCommand { @@ -62,6 +64,8 @@ export default class RoomsOccupancyGet extends AblyBaseCommand { presenceSubscribers: 0, publishers: 0, subscribers: 0, + objectPublishers: 0, + objectSubscribers: 0, }; if (this.shouldOutputJson(flags)) { @@ -92,6 +96,12 @@ export default class RoomsOccupancyGet extends AblyBaseCommand { this.log( `${formatLabel("Presence Subscribers")} ${occupancyMetrics.presenceSubscribers}`, ); + this.log( + `${formatLabel("Object Publishers")} ${occupancyMetrics.objectPublishers}`, + ); + this.log( + `${formatLabel("Object Subscribers")} ${occupancyMetrics.objectSubscribers}`, + ); } } catch (error) { this.fail(error, flags, "roomOccupancyGet", { room: args.room }); diff --git a/src/commands/rooms/occupancy/subscribe.ts b/src/commands/rooms/occupancy/subscribe.ts index 720831777..5f3cfa5b3 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -109,21 +109,23 @@ export default class RoomsOccupancySubscribe extends AblyBaseCommand { if (message.data?.metrics) { const metrics = message.data.metrics; + this.log(`${formatLabel("Connections")} ${metrics.connections}`); + this.log(`${formatLabel("Publishers")} ${metrics.publishers}`); + this.log(`${formatLabel("Subscribers")} ${metrics.subscribers}`); this.log( - `${formatLabel("Connections")} ${metrics.connections ?? 0}`, + `${formatLabel("Presence Connections")} ${metrics.presenceConnections}`, ); - this.log(`${formatLabel("Publishers")} ${metrics.publishers ?? 0}`); this.log( - `${formatLabel("Subscribers")} ${metrics.subscribers ?? 0}`, + `${formatLabel("Presence Members")} ${metrics.presenceMembers}`, ); this.log( - `${formatLabel("Presence Connections")} ${metrics.presenceConnections ?? 0}`, + `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers}`, ); this.log( - `${formatLabel("Presence Members")} ${metrics.presenceMembers ?? 0}`, + `${formatLabel("Object Publishers")} ${metrics.objectPublishers}`, ); this.log( - `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers ?? 0}`, + `${formatLabel("Object Subscribers")} ${metrics.objectSubscribers}`, ); } diff --git a/src/commands/spaces/occupancy/get.ts b/src/commands/spaces/occupancy/get.ts index 97b36a7ff..7b886795e 100644 --- a/src/commands/spaces/occupancy/get.ts +++ b/src/commands/spaces/occupancy/get.ts @@ -13,6 +13,8 @@ interface OccupancyMetrics { presenceSubscribers: number; publishers: number; subscribers: number; + objectPublishers: number; + objectSubscribers: number; } export default class SpacesOccupancyGet extends AblyBaseCommand { @@ -62,6 +64,8 @@ export default class SpacesOccupancyGet extends AblyBaseCommand { presenceSubscribers: 0, publishers: 0, subscribers: 0, + objectPublishers: 0, + objectSubscribers: 0, }; if (this.shouldOutputJson(flags)) { @@ -92,6 +96,12 @@ export default class SpacesOccupancyGet extends AblyBaseCommand { this.log( `${formatLabel("Presence Subscribers")} ${occupancyMetrics.presenceSubscribers}`, ); + this.log( + `${formatLabel("Object Publishers")} ${occupancyMetrics.objectPublishers}`, + ); + this.log( + `${formatLabel("Object Subscribers")} ${occupancyMetrics.objectSubscribers}`, + ); } } catch (error) { this.fail(error, flags, "spacesOccupancyGet", { diff --git a/src/commands/spaces/occupancy/subscribe.ts b/src/commands/spaces/occupancy/subscribe.ts index f8de63b38..d4eb943c9 100644 --- a/src/commands/spaces/occupancy/subscribe.ts +++ b/src/commands/spaces/occupancy/subscribe.ts @@ -109,21 +109,23 @@ export default class SpacesOccupancySubscribe extends AblyBaseCommand { if (message.data?.metrics) { const metrics = message.data.metrics; + this.log(`${formatLabel("Connections")} ${metrics.connections}`); + this.log(`${formatLabel("Publishers")} ${metrics.publishers}`); + this.log(`${formatLabel("Subscribers")} ${metrics.subscribers}`); this.log( - `${formatLabel("Connections")} ${metrics.connections ?? 0}`, + `${formatLabel("Presence Connections")} ${metrics.presenceConnections}`, ); - this.log(`${formatLabel("Publishers")} ${metrics.publishers ?? 0}`); this.log( - `${formatLabel("Subscribers")} ${metrics.subscribers ?? 0}`, + `${formatLabel("Presence Members")} ${metrics.presenceMembers}`, ); this.log( - `${formatLabel("Presence Connections")} ${metrics.presenceConnections ?? 0}`, + `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers}`, ); this.log( - `${formatLabel("Presence Members")} ${metrics.presenceMembers ?? 0}`, + `${formatLabel("Object Publishers")} ${metrics.objectPublishers}`, ); this.log( - `${formatLabel("Presence Subscribers")} ${metrics.presenceSubscribers ?? 0}`, + `${formatLabel("Object Subscribers")} ${metrics.objectSubscribers}`, ); } diff --git a/test/unit/commands/channels/list.test.ts b/test/unit/commands/channels/list.test.ts index cf4f389ec..7fcf75277 100644 --- a/test/unit/commands/channels/list.test.ts +++ b/test/unit/commands/channels/list.test.ts @@ -108,12 +108,8 @@ describe("channels:list command", () => { expect(jsonOutput).toHaveProperty("channels"); expect(jsonOutput.channels).toBeInstanceOf(Array); expect(jsonOutput.channels).toHaveLength(2); - expect(jsonOutput.channels[0]).toEqual({ - channelId: "test-channel-1", - }); - expect(jsonOutput.channels[1]).toEqual({ - channelId: "test-channel-2", - }); + expect(jsonOutput.channels[0]).toEqual("test-channel-1"); + expect(jsonOutput.channels[1]).toEqual("test-channel-2"); expect(jsonOutput).toHaveProperty("success", true); expect(jsonOutput).toHaveProperty("total", 2); expect(jsonOutput).toHaveProperty("hasMore", false); diff --git a/test/unit/commands/channels/occupancy/get.test.ts b/test/unit/commands/channels/occupancy/get.test.ts index 47752ee3d..eb9de9987 100644 --- a/test/unit/commands/channels/occupancy/get.test.ts +++ b/test/unit/commands/channels/occupancy/get.test.ts @@ -23,6 +23,8 @@ describe("ChannelsOccupancyGet", function () { presenceSubscribers: 4, publishers: 2, subscribers: 6, + objectPublishers: 0, + objectSubscribers: 0, }, }, }, @@ -56,6 +58,8 @@ describe("ChannelsOccupancyGet", function () { expect(stdout).toContain("Presence Subscribers: 4"); expect(stdout).toContain("Publishers: 2"); expect(stdout).toContain("Subscribers: 6"); + expect(stdout).toContain("Object Publishers: 0"); + expect(stdout).toContain("Object Subscribers: 0"); }); it("should output occupancy in JSON format when requested", async function () { @@ -108,6 +112,8 @@ describe("ChannelsOccupancyGet", function () { expect(stdout).toContain("Presence Connections: 0"); expect(stdout).toContain("Presence Members: 0"); expect(stdout).toContain("Presence Subscribers: 0"); + expect(stdout).toContain("Object Publishers: 0"); + expect(stdout).toContain("Object Subscribers: 0"); }); describe("functionality", () => { diff --git a/test/unit/commands/rooms/list.test.ts b/test/unit/commands/rooms/list.test.ts index a4ed29fb2..1d4f37fe2 100644 --- a/test/unit/commands/rooms/list.test.ts +++ b/test/unit/commands/rooms/list.test.ts @@ -131,8 +131,8 @@ describe("rooms:list command", () => { expect(json).toHaveProperty("rooms"); expect(json.rooms).toBeInstanceOf(Array); expect(json.rooms.length).toBe(2); - expect(json.rooms[0]).toEqual({ roomName: "room1" }); - expect(json.rooms[1]).toEqual({ roomName: "room2" }); + expect(json.rooms[0]).toEqual("room1"); + expect(json.rooms[1]).toEqual("room2"); expect(json).toHaveProperty("total", 2); expect(json).toHaveProperty("timestamp"); expect(json).toHaveProperty("hasMore", false); diff --git a/test/unit/commands/rooms/occupancy/get.test.ts b/test/unit/commands/rooms/occupancy/get.test.ts index c7f0a00b7..3ad7e970e 100644 --- a/test/unit/commands/rooms/occupancy/get.test.ts +++ b/test/unit/commands/rooms/occupancy/get.test.ts @@ -14,6 +14,8 @@ const mockOccupancyMetrics = { presenceSubscribers: 4, publishers: 2, subscribers: 6, + objectPublishers: 3, + objectSubscribers: 7, }; describe("rooms:occupancy:get command", () => { @@ -39,7 +41,7 @@ describe("rooms:occupancy:get command", () => { standardFlagTests("rooms:occupancy:get", import.meta.url, ["--json"]); describe("functionality", () => { - it("should display all 6 occupancy metrics", async () => { + it("should display all 8 occupancy metrics", async () => { const mock = getMockAblyRest(); const { stdout } = await runCommand( @@ -61,6 +63,8 @@ describe("rooms:occupancy:get command", () => { expect(stdout).toContain("Presence Connections: 5"); expect(stdout).toContain("Presence Members: 8"); expect(stdout).toContain("Presence Subscribers: 4"); + expect(stdout).toContain("Object Publishers: 3"); + expect(stdout).toContain("Object Subscribers: 7"); }); it("should handle zero metrics", async () => { @@ -80,6 +84,8 @@ describe("rooms:occupancy:get command", () => { expect(stdout).toContain("Presence Connections: 0"); expect(stdout).toContain("Presence Members: 0"); expect(stdout).toContain("Presence Subscribers: 0"); + expect(stdout).toContain("Object Publishers: 0"); + expect(stdout).toContain("Object Subscribers: 0"); }); it("should output JSON nested under occupancy key", async () => { diff --git a/test/unit/commands/spaces/occupancy/get.test.ts b/test/unit/commands/spaces/occupancy/get.test.ts index 29b7aba96..c6a33cf74 100644 --- a/test/unit/commands/spaces/occupancy/get.test.ts +++ b/test/unit/commands/spaces/occupancy/get.test.ts @@ -23,6 +23,8 @@ describe("spaces:occupancy:get command", () => { presenceSubscribers: 4, publishers: 2, subscribers: 6, + objectPublishers: 0, + objectSubscribers: 0, }, }, }, @@ -65,6 +67,8 @@ describe("spaces:occupancy:get command", () => { expect(stdout).toContain("Presence Connections: 5"); expect(stdout).toContain("Presence Members: 8"); expect(stdout).toContain("Presence Subscribers: 4"); + expect(stdout).toContain("Object Publishers: 0"); + expect(stdout).toContain("Object Subscribers: 0"); }); it("should output JSON envelope with spaceName and metrics", async () => { @@ -110,6 +114,8 @@ describe("spaces:occupancy:get command", () => { expect(stdout).toContain("Connections: 0"); expect(stdout).toContain("Publishers: 0"); expect(stdout).toContain("Subscribers: 0"); + expect(stdout).toContain("Object Publishers: 0"); + expect(stdout).toContain("Object Subscribers: 0"); }); });