diff --git a/src/commands/spaces/cursors/get-all.ts b/src/commands/spaces/cursors/get-all.ts index 0e2d1592..1f535eae 100644 --- a/src/commands/spaces/cursors/get-all.ts +++ b/src/commands/spaces/cursors/get-all.ts @@ -1,3 +1,4 @@ +import { type CursorUpdate } from "@ably/spaces"; import { Args } from "@oclif/core"; import chalk from "chalk"; @@ -5,23 +6,19 @@ import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import isTestMode from "../../../utils/test-mode.js"; import { + formatClientId, + formatCountLabel, + formatHeading, + formatIndex, formatProgress, - formatSuccess, formatResource, - formatClientId, + formatSuccess, + formatWarning, } from "../../../utils/output.js"; - -interface CursorPosition { - x: number; - y: number; -} - -interface CursorUpdate { - clientId?: string; - connectionId?: string; - data?: Record; - position: CursorPosition; -} +import { + formatCursorBlock, + formatCursorOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesCursorsGetAll extends SpacesBaseCommand { static override args = { @@ -76,16 +73,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { // Check realtime client state if (this.realtimeClient!.connection.state === "connected") { clearTimeout(timeout); - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - connectionId: this.realtimeClient!.connection.id, - spaceName, - status: "connected", - }, - flags, - ); - } else { + if (!this.shouldOutputJson(flags)) { this.log( formatSuccess(`Entered space: ${formatResource(spaceName)}.`), ); @@ -194,34 +182,17 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { const allCursors = await this.space!.cursors.getAll(); // Add any cached cursors that we didn't see in live updates - if (Array.isArray(allCursors)) { - allCursors.forEach((cursor) => { - if ( - cursor && - cursor.connectionId && - !cursorMap.has(cursor.connectionId) - ) { - cursorMap.set(cursor.connectionId, cursor as CursorUpdate); - } - }); - } else if (allCursors && typeof allCursors === "object") { - // Handle object return type - Object.values(allCursors).forEach((cursor) => { - if ( - cursor && - cursor.connectionId && - !cursorMap.has(cursor.connectionId) - ) { - cursorMap.set(cursor.connectionId, cursor as CursorUpdate); - } - }); + for (const cursor of Object.values(allCursors)) { + if (cursor && !cursorMap.has(cursor.connectionId)) { + cursorMap.set(cursor.connectionId, cursor); + } } } catch { // If getAll fails due to connection issues, use only the live updates we collected if (!this.shouldOutputJson(flags)) { this.log( - chalk.yellow( - "Warning: Could not fetch all cursors, showing only live updates", + formatWarning( + "Could not fetch all cursors, showing only live updates.", ), ); } @@ -232,22 +203,16 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult( { - cursors: cursors.map((cursor: CursorUpdate) => ({ - clientId: cursor.clientId, - connectionId: cursor.connectionId, - data: cursor.data, - position: cursor.position, - })), - spaceName, - cursorUpdateReceived, + cursors: cursors.map((cursor: CursorUpdate) => + formatCursorOutput(cursor), + ), }, flags, ); } else { if (!cursorUpdateReceived && cursors.length === 0) { - this.log(chalk.dim("─".repeat(60))); this.log( - chalk.yellow( + formatWarning( "No cursor updates are being sent in this space. Make sure other clients are actively setting cursor positions.", ), ); @@ -255,109 +220,20 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { } if (cursors.length === 0) { - this.log(chalk.dim("─".repeat(60))); - this.log(chalk.yellow("No active cursors found in space.")); + this.log(formatWarning("No active cursors found in space.")); return; } - // Show summary table - this.log(chalk.dim("─".repeat(60))); this.log( - chalk.bold( - `\nCursor Summary - ${cursors.length} cursor${cursors.length === 1 ? "" : "s"} found:\n`, - ), + `\n${formatHeading("Current cursors")} (${formatCountLabel(cursors.length, "cursor")}):\n`, ); - // Table header - const colWidths = { client: 20, x: 8, y: 8, connection: 20 }; - this.log( - chalk.gray( - "┌" + - "─".repeat(colWidths.client + 2) + - "┬" + - "─".repeat(colWidths.x + 2) + - "┬" + - "─".repeat(colWidths.y + 2) + - "┬" + - "─".repeat(colWidths.connection + 2) + - "┐", - ), - ); - this.log( - chalk.gray("│ ") + - chalk.bold("Client ID".padEnd(colWidths.client)) + - chalk.gray(" │ ") + - chalk.bold("X".padEnd(colWidths.x)) + - chalk.gray(" │ ") + - chalk.bold("Y".padEnd(colWidths.y)) + - chalk.gray(" │ ") + - chalk.bold("connection".padEnd(colWidths.connection)) + - chalk.gray(" │"), - ); - this.log( - chalk.gray( - "├" + - "─".repeat(colWidths.client + 2) + - "┼" + - "─".repeat(colWidths.x + 2) + - "┼" + - "─".repeat(colWidths.y + 2) + - "┼" + - "─".repeat(colWidths.connection + 2) + - "┤", - ), - ); - - // Table rows - cursors.forEach((cursor: CursorUpdate) => { - const clientId = (cursor.clientId || "Unknown").slice( - 0, - colWidths.client, - ); - const x = cursor.position.x.toString().slice(0, colWidths.x); - const y = cursor.position.y.toString().slice(0, colWidths.y); - const connectionId = (cursor.connectionId || "Unknown").slice( - 0, - colWidths.connection, - ); - + cursors.forEach((cursor: CursorUpdate, index: number) => { this.log( - chalk.gray("│ ") + - formatClientId(clientId.padEnd(colWidths.client)) + - chalk.gray(" │ ") + - chalk.yellow(x.padEnd(colWidths.x)) + - chalk.gray(" │ ") + - chalk.yellow(y.padEnd(colWidths.y)) + - chalk.gray(" │ ") + - chalk.dim(connectionId.padEnd(colWidths.connection)) + - chalk.gray(" │"), + `${formatIndex(index + 1)} ${formatCursorBlock(cursor, { indent: " " })}`, ); + this.log(""); }); - - this.log( - chalk.gray( - "└" + - "─".repeat(colWidths.client + 2) + - "┴" + - "─".repeat(colWidths.x + 2) + - "┴" + - "─".repeat(colWidths.y + 2) + - "┴" + - "─".repeat(colWidths.connection + 2) + - "┘", - ), - ); - - // Show additional data if any cursor has it - const cursorsWithData = cursors.filter((c) => c.data); - if (cursorsWithData.length > 0) { - this.log(`\n${chalk.bold("Additional Data:")}`); - cursorsWithData.forEach((cursor: CursorUpdate) => { - this.log( - ` ${formatClientId(cursor.clientId || "Unknown")}: ${JSON.stringify(cursor.data)}`, - ); - }); - } } } catch (error) { this.fail(error, flags, "cursorGetAll", { spaceName }); diff --git a/src/commands/spaces/cursors/set.ts b/src/commands/spaces/cursors/set.ts index f8bcf3fc..e3ea8ad7 100644 --- a/src/commands/spaces/cursors/set.ts +++ b/src/commands/spaces/cursors/set.ts @@ -1,6 +1,5 @@ +import type { CursorData, CursorPosition } from "@ably/spaces"; import { Args, Flags } from "@oclif/core"; -import chalk from "chalk"; - import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; @@ -12,18 +11,6 @@ import { formatLabel, } from "../../../utils/output.js"; -// Define cursor types based on Ably documentation -interface CursorPosition { - x: number; - y: number; -} - -interface CursorData { - [key: string]: unknown; -} - -// CursorUpdate interface no longer required in this file - export default class SpacesCursorsSet extends SpacesBaseCommand { static override args = { space: Args.string({ @@ -191,17 +178,31 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult( { - cursor: cursorForOutput, - spaceName, + cursors: [ + { + clientId: this.realtimeClient!.auth.clientId, + connectionId: this.realtimeClient!.connection.id, + position: ( + cursorForOutput as { position: { x: number; y: number } } + ).position, + data: + (cursorForOutput as { data?: Record }) + .data ?? null, + }, + ], }, flags, ); } else { this.log( - formatSuccess( - `Set cursor in space ${formatResource(spaceName)} with data: ${chalk.blue(JSON.stringify(cursorForOutput))}.`, - ), + formatSuccess(`Set cursor in space ${formatResource(spaceName)}.`), ); + const lines: string[] = []; + lines.push(`${formatLabel("Position")} (${position.x}, ${position.y})`); + if (data) { + lines.push(`${formatLabel("Data")} ${JSON.stringify(data)}`); + } + this.log(lines.join("\n")); } // Decide how long to remain connected diff --git a/src/commands/spaces/cursors/subscribe.ts b/src/commands/spaces/cursors/subscribe.ts index b0aba265..ccd77f6f 100644 --- a/src/commands/spaces/cursors/subscribe.ts +++ b/src/commands/spaces/cursors/subscribe.ts @@ -7,9 +7,11 @@ import { formatResource, formatSuccess, formatTimestamp, - formatClientId, - formatLabel, } from "../../../utils/output.js"; +import { + formatCursorBlock, + formatCursorOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesCursorsSubscribe extends SpacesBaseCommand { static override args = { @@ -56,35 +58,27 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { this.listener = (cursorUpdate: CursorUpdate) => { try { const timestamp = new Date().toISOString(); - const eventData = { - member: { - clientId: cursorUpdate.clientId, - connectionId: cursorUpdate.connectionId, - }, - position: cursorUpdate.position, - data: cursorUpdate.data, - spaceName, - timestamp, - eventType: "cursor_update", - }; this.logCliEvent( flags, "cursor", "updateReceived", "Cursor update received", - eventData, + { + clientId: cursorUpdate.clientId, + position: cursorUpdate.position, + timestamp, + }, ); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(eventData, flags); - } else { - // Include data field in the output if present - const dataString = cursorUpdate.data - ? ` data: ${JSON.stringify(cursorUpdate.data)}` - : ""; - this.log( - `${formatTimestamp(timestamp)} ${formatClientId(cursorUpdate.clientId)} ${formatLabel("position")} ${JSON.stringify(cursorUpdate.position)}${dataString}`, + this.logJsonEvent( + { cursor: formatCursorOutput(cursorUpdate) }, + flags, ); + } else { + this.log(formatTimestamp(timestamp)); + this.log(formatCursorBlock(cursorUpdate)); + this.log(""); } } catch (error) { this.fail(error, flags, "cursorSubscribe", { diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 3c1b2e3a..3aab7489 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -132,14 +132,10 @@ export default class SpacesList extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult( { - hasMore: spacesList.length > flags.limit, - shown: limitedSpaces.length, spaces: limitedSpaces.map((space: SpaceItem) => ({ - metrics: space.status?.occupancy?.metrics || {}, spaceName: space.spaceName, + metrics: space.status?.occupancy?.metrics || {}, })), - timestamp: new Date().toISOString(), - total: spacesList.length, }, flags, ); diff --git a/src/commands/spaces/locations/get-all.ts b/src/commands/spaces/locations/get-all.ts index 4149dbe1..175b25e1 100644 --- a/src/commands/spaces/locations/get-all.ts +++ b/src/commands/spaces/locations/get-all.ts @@ -1,48 +1,18 @@ import { Args } from "@oclif/core"; -import chalk from "chalk"; -import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatClientId, + formatCountLabel, formatHeading, + formatIndex, formatLabel, formatProgress, formatResource, formatSuccess, + formatWarning, } from "../../../utils/output.js"; - -interface LocationData { - [key: string]: unknown; -} - -interface Member { - clientId?: string; - memberId?: string; - isCurrentMember?: boolean; -} - -interface LocationWithCurrent { - current: { - member: Member; - }; - location?: LocationData; - data?: LocationData; - [key: string]: unknown; -} - -interface LocationItem { - [key: string]: unknown; - clientId?: string; - connectionId?: string; - data?: LocationData; - id?: string; - location?: LocationData; - member?: Member; - memberId?: string; - userId?: string; -} +import type { LocationEntry } from "../../../utils/spaces-output.js"; export default class SpacesLocationsGetAll extends SpacesBaseCommand { static override args = { @@ -132,139 +102,48 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { ); } - let locations: LocationItem[] = []; try { const locationsFromSpace = await this.space!.locations.getAll(); - if (locationsFromSpace && typeof locationsFromSpace === "object") { - if (Array.isArray(locationsFromSpace)) { - locations = locationsFromSpace as LocationItem[]; - } else if (Object.keys(locationsFromSpace).length > 0) { - locations = Object.entries(locationsFromSpace).map( - ([memberId, locationData]) => ({ - location: locationData, - memberId, - }), - ) as LocationItem[]; - } - } - - const knownMetaKeys = new Set([ - "clientId", - "connectionId", - "current", - "id", - "member", - "memberId", - "userId", - ]); - - const extractLocationData = (item: LocationItem): unknown => { - if (item.location !== undefined) return item.location; - if (item.data !== undefined) return item.data; - const rest: Record = {}; - for (const [key, value] of Object.entries(item)) { - if (!knownMetaKeys.has(key)) { - rest[key] = value; - } - } - return Object.keys(rest).length > 0 ? rest : null; - }; - - const validLocations = locations.filter((item: LocationItem) => { - if (item === null || item === undefined) return false; - - const locationData = extractLocationData(item); - - if (locationData === null || locationData === undefined) return false; - if ( - typeof locationData === "object" && - Object.keys(locationData as object).length === 0 + const entries: LocationEntry[] = Object.entries(locationsFromSpace) + .filter( + ([, loc]) => + loc != null && + !( + typeof loc === "object" && + Object.keys(loc as object).length === 0 + ), ) - return false; - - return true; - }); + .map(([connectionId, loc]) => ({ connectionId, location: loc })); if (this.shouldOutputJson(flags)) { this.logJsonResult( { - locations: validLocations.map((item: LocationItem) => { - const currentMember = - "current" in item && - item.current && - typeof item.current === "object" - ? (item.current as LocationWithCurrent["current"]).member - : undefined; - const member = item.member || currentMember; - const memberId = - item.memberId || - member?.memberId || - member?.clientId || - item.clientId || - item.id || - item.userId || - "Unknown"; - const locationData = extractLocationData(item); - return { - isCurrentMember: member?.isCurrentMember || false, - location: locationData, - memberId, - }; - }), - spaceName, - timestamp: new Date().toISOString(), + locations: entries.map((entry) => ({ + connectionId: entry.connectionId, + location: entry.location, + })), }, flags, ); - } else if (!validLocations || validLocations.length === 0) { + } else if (entries.length === 0) { this.log( - chalk.yellow("No locations are currently set in this space."), + formatWarning("No locations are currently set in this space."), ); } else { - const locationsCount = validLocations.length; this.log( - `\n${formatHeading("Current locations")} (${chalk.bold(String(locationsCount))}):\n`, + `\n${formatHeading("Current locations")} (${formatCountLabel(entries.length, "location")}):\n`, ); - for (const location of validLocations) { - // Check if location has 'current' property with expected structure - if ( - "current" in location && - typeof location.current === "object" && - location.current !== null && - "member" in location.current - ) { - const locationWithCurrent = location as LocationWithCurrent; - const { member } = locationWithCurrent.current; - this.log( - `Member ID: ${formatResource(member.memberId || member.clientId || "Unknown")}`, - ); - try { - const locationData = extractLocationData(location); - - this.log( - `- ${formatClientId(member.memberId || member.clientId || "Unknown")}:`, - ); - this.log( - ` ${formatLabel("Location")} ${JSON.stringify(locationData, null, 2)}`, - ); - - if (member.isCurrentMember) { - this.log(` ${chalk.dim("(Current member)")}`); - } - } catch (error) { - this.log( - `- ${chalk.red("Error displaying location item")}: ${errorMessage(error)}`, - ); - } - } else { - // Simpler display if location doesn't have expected structure - this.log(`- ${formatClientId("Member")}:`); - this.log( - ` ${formatLabel("Location")} ${JSON.stringify(location, null, 2)}`, - ); - } + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + this.log( + `${formatIndex(i + 1)} ${formatLabel("Connection ID")} ${entry.connectionId}`, + ); + this.log( + ` ${formatLabel("Location")} ${JSON.stringify(entry.location)}`, + ); + this.log(""); } } } catch (error) { diff --git a/src/commands/spaces/locations/set.ts b/src/commands/spaces/locations/set.ts index caaa7c9e..1b4e201a 100644 --- a/src/commands/spaces/locations/set.ts +++ b/src/commands/spaces/locations/set.ts @@ -1,6 +1,5 @@ import type { LocationsEvents } from "@ably/spaces"; import { Args, Flags } from "@oclif/core"; -import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; @@ -9,9 +8,8 @@ import { formatListening, formatResource, formatTimestamp, - formatClientId, - formatLabel, } from "../../../utils/output.js"; +import { formatLocationUpdateBlock } from "../../../utils/spaces-output.js"; // Define the type for location subscription interface LocationSubscription { @@ -133,7 +131,7 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { }); if (this.shouldOutputJson(flags)) { - this.logJsonResult({ location, spaceName }, flags); + this.logJsonResult({ location }, flags); } else { this.log( formatSuccess( @@ -144,7 +142,7 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { } catch { // If an error occurs in E2E mode, just exit cleanly after showing what we can if (this.shouldOutputJson(flags)) { - this.logJsonResult({ location, spaceName }, flags); + this.logJsonResult({ location }, flags); } // Don't call this.error() in E2E mode as it sets exit code to 1 } @@ -188,7 +186,6 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { this.locationHandler = (locationUpdate: LocationsEvents.UpdateEvent) => { const timestamp = new Date().toISOString(); const { member } = locationUpdate; - const { currentLocation } = locationUpdate; // Use current location const { connectionId } = member; // Skip self events - check connection ID @@ -197,36 +194,37 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { return; } - const eventData = { - action: "update", - location: currentLocation, - member: { - clientId: member.clientId, - connectionId: member.connectionId, - }, - timestamp, - }; this.logCliEvent( flags, "location", "updateReceived", "Location update received", - eventData, + { + clientId: member.clientId, + connectionId: member.connectionId, + timestamp, + }, ); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(eventData, flags); - } else { - // For locations, use yellow for updates - const actionColor = chalk.yellow; - const action = "update"; - - this.log( - `${formatTimestamp(timestamp)} ${formatClientId(member.clientId || "Unknown")} ${actionColor(action)}d location:`, - ); - this.log( - ` ${formatLabel("Location")} ${JSON.stringify(currentLocation, null, 2)}`, + this.logJsonEvent( + { + location: { + member: { + clientId: member.clientId, + connectionId: member.connectionId, + }, + currentLocation: locationUpdate.currentLocation, + previousLocation: locationUpdate.previousLocation, + timestamp, + }, + }, + flags, ); + } else { + this.log(formatTimestamp(timestamp)); + this.log(formatLocationUpdateBlock(locationUpdate)); + this.log(""); } }; diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index 9ff8b31e..fc1abe18 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -1,39 +1,14 @@ import type { LocationsEvents } from "@ably/spaces"; import { Args } from "@oclif/core"; -import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatClientId, - formatEventType, - formatHeading, formatListening, - formatProgress, - formatResource, formatSuccess, formatTimestamp, - formatLabel, } from "../../../utils/output.js"; - -// Define interfaces for location types -interface SpaceMember { - clientId: string; - connectionId: string; - isConnected: boolean; - profileData: Record | null; -} - -interface LocationData { - [key: string]: unknown; -} - -interface LocationItem { - location: LocationData; - member: SpaceMember; -} - -// Define type for subscription +import { formatLocationUpdateBlock } from "../../../utils/spaces-output.js"; export default class SpacesLocationsSubscribe extends SpacesBaseCommand { static override args = { @@ -83,102 +58,6 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: true }); - // Get current locations - this.logCliEvent( - flags, - "location", - "gettingInitial", - `Fetching initial locations for space ${spaceName}`, - ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching current locations for space ${formatResource(spaceName)}`, - ), - ); - } - - let locations: LocationItem[] = []; - try { - const result = await this.space!.locations.getAll(); - this.logCliEvent( - flags, - "location", - "gotInitial", - `Fetched initial locations`, - { locations: result }, - ); - - if (result && typeof result === "object") { - if (Array.isArray(result)) { - // Unlikely based on current docs, but handle if API changes - // Need to map Array result to LocationItem[] if structure differs - this.logCliEvent( - flags, - "location", - "initialFormatWarning", - "Received array format for initial locations, expected object", - ); - // Assuming array elements match expected structure for now: - locations = result.map( - (item: { location: LocationData; member: SpaceMember }) => ({ - location: item.location, - member: item.member, - }), - ); - } else if (Object.keys(result).length > 0) { - // Standard case: result is an object { connectionId: locationData } - locations = Object.entries(result).map( - ([connectionId, locationData]) => ({ - location: locationData as LocationData, - member: { - // Construct a partial SpaceMember as SDK doesn't provide full details here - clientId: "unknown", // clientId not directly available in getAll response - connectionId, - isConnected: true, // Assume connected for initial state - profileData: null, - }, - }), - ); - } - } - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - locations: locations.map((item) => ({ - // Map to a simpler structure for output if needed - connectionId: item.member.connectionId, - location: item.location, - })), - spaceName, - eventType: "locations_snapshot", - }, - flags, - ); - } else if (locations.length === 0) { - this.log( - chalk.yellow("No locations are currently set in this space."), - ); - } else { - this.log( - `\n${formatHeading("Current locations")} (${chalk.bold(locations.length.toString())}):\n`, - ); - for (const item of locations) { - this.log( - `- Connection ID: ${chalk.blue(item.member.connectionId || "Unknown")}`, - ); // Use connectionId as key - this.log( - ` ${formatLabel("Location")} ${JSON.stringify(item.location)}`, - ); - } - } - } catch (error) { - this.fail(error, flags, "locationSubscribe", { - spaceName, - }); - } - this.logCliEvent( flags, "location", @@ -200,43 +79,37 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { const locationHandler = (update: LocationsEvents.UpdateEvent) => { try { const timestamp = new Date().toISOString(); - const eventData = { - action: "update", - location: update.currentLocation, - member: { - clientId: update.member.clientId, - connectionId: update.member.connectionId, - }, - previousLocation: update.previousLocation, - timestamp, - }; this.logCliEvent( flags, "location", "updateReceived", "Location update received", - { spaceName, ...eventData }, + { + clientId: update.member.clientId, + connectionId: update.member.connectionId, + timestamp, + }, ); if (this.shouldOutputJson(flags)) { this.logJsonEvent( { - spaceName, - eventType: "location_update", - ...eventData, + location: { + member: { + clientId: update.member.clientId, + connectionId: update.member.connectionId, + }, + currentLocation: update.currentLocation, + previousLocation: update.previousLocation, + timestamp, + }, }, flags, ); } else { - this.log( - `${formatTimestamp(timestamp)} ${formatClientId(update.member.clientId)} ${formatEventType("updated")} location:`, - ); - this.log( - ` ${formatLabel("Current")} ${JSON.stringify(update.currentLocation)}`, - ); - this.log( - ` ${formatLabel("Previous")} ${JSON.stringify(update.previousLocation)}`, - ); + this.log(formatTimestamp(timestamp)); + this.log(formatLocationUpdateBlock(update)); + this.log(""); } } catch (error) { this.fail(error, flags, "locationSubscribe", { @@ -273,7 +146,7 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { this.fail(error, flags, "locationSubscribe", { spaceName }); } finally { // Wrap all cleanup in a timeout to prevent hanging - if (!this.shouldOutputJson(flags || {})) { + if (!this.shouldOutputJson(flags)) { if (this.cleanupInProgress) { this.log(formatSuccess("Graceful shutdown complete.")); } else { diff --git a/src/commands/spaces/locks/acquire.ts b/src/commands/spaces/locks/acquire.ts index 146b9775..f75354b3 100644 --- a/src/commands/spaces/locks/acquire.ts +++ b/src/commands/spaces/locks/acquire.ts @@ -8,8 +8,11 @@ import { formatSuccess, formatListening, formatResource, - formatLabel, } from "../../../utils/output.js"; +import { + formatLockBlock, + formatLockOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesLocksAcquire extends SpacesBaseCommand { static override args = { @@ -109,33 +112,19 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { lockId, lockData as LockOptions, ); - const lockDetails = { - lockId: lock.id, - member: lock.member - ? { - clientId: lock.member.clientId, - connectionId: lock.member.connectionId, - } - : null, - reason: lock.reason, - status: lock.status, - timestamp: lock.timestamp, - }; this.logCliEvent( flags, "lock", "acquired", `Lock acquired: ${lockId}`, - lockDetails, + { lockId: lock.id, status: lock.status }, ); if (this.shouldOutputJson(flags)) { - this.logJsonResult({ lock: lockDetails }, flags); + this.logJsonResult({ locks: [formatLockOutput(lock)] }, flags); } else { this.log(formatSuccess(`Lock acquired: ${formatResource(lockId)}.`)); - this.log( - `${formatLabel("Lock details")} ${this.formatJsonOutput(lockDetails, { ...flags, "pretty-json": true })}`, - ); + this.log(formatLockBlock(lock)); this.log(`\n${formatListening("Holding lock.")}`); } } catch (error) { diff --git a/src/commands/spaces/locks/get-all.ts b/src/commands/spaces/locks/get-all.ts index 1f0ccd09..9465728f 100644 --- a/src/commands/spaces/locks/get-all.ts +++ b/src/commands/spaces/locks/get-all.ts @@ -1,25 +1,21 @@ +import type { Lock } from "@ably/spaces"; import { Args } from "@oclif/core"; import chalk from "chalk"; -import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { + formatCountLabel, formatHeading, - formatLabel, + formatIndex, formatProgress, formatResource, formatSuccess, } from "../../../utils/output.js"; - -interface LockItem { - attributes?: Record; - id: string; - member?: { - clientId?: string; - }; - status?: string; -} +import { + formatLockBlock, + formatLockOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesLocksGetAll extends SpacesBaseCommand { static override args = { @@ -112,56 +108,26 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { ); } - let locks: LockItem[] = []; - const result = await this.space!.locks.getAll(); - locks = Array.isArray(result) ? result : []; - - const validLocks = locks.filter((lock: LockItem) => { - if (!lock || !lock.id) return false; - return true; - }); + const locks: Lock[] = await this.space!.locks.getAll(); if (this.shouldOutputJson(flags)) { this.logJsonResult( { - locks: validLocks.map((lock) => ({ - attributes: lock.attributes || {}, - holder: lock.member?.clientId || null, - id: lock.id, - status: lock.status || "unknown", - })), - spaceName, - timestamp: new Date().toISOString(), + locks: locks.map((lock) => formatLockOutput(lock)), }, flags, ); - } else if (!validLocks || validLocks.length === 0) { + } else if (locks.length === 0) { this.log(chalk.yellow("No locks are currently active in this space.")); } else { - const lockCount = validLocks.length; this.log( - `\n${formatHeading("Current locks")} (${chalk.bold(String(lockCount))}):\n`, + `\n${formatHeading("Current locks")} (${formatCountLabel(locks.length, "lock")}):\n`, ); - validLocks.forEach((lock: LockItem) => { - try { - this.log(`- ${formatResource(lock.id)}:`); - this.log(` ${formatLabel("Status")} ${lock.status || "unknown"}`); - this.log( - ` ${formatLabel("Holder")} ${lock.member?.clientId || "None"}`, - ); - - if (lock.attributes && Object.keys(lock.attributes).length > 0) { - this.log( - ` ${formatLabel("Attributes")} ${JSON.stringify(lock.attributes, null, 2)}`, - ); - } - } catch (error) { - this.log( - `- ${chalk.red("Error displaying lock item")}: ${errorMessage(error)}`, - ); - } - }); + for (let i = 0; i < locks.length; i++) { + this.log(`${formatIndex(i + 1)} ${formatLockBlock(locks[i])}`); + this.log(""); + } } } catch (error) { this.fail(error, flags, "lockGetAll", { spaceName }); diff --git a/src/commands/spaces/locks/get.ts b/src/commands/spaces/locks/get.ts index 5148ba18..d2d0cd64 100644 --- a/src/commands/spaces/locks/get.ts +++ b/src/commands/spaces/locks/get.ts @@ -3,11 +3,11 @@ import chalk from "chalk"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; +import { formatResource, formatSuccess } from "../../../utils/output.js"; import { - formatLabel, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; + formatLockBlock, + formatLockOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesLocksGet extends SpacesBaseCommand { static override args = { @@ -55,7 +55,7 @@ export default class SpacesLocksGet extends SpacesBaseCommand { if (!lock) { if (this.shouldOutputJson(flags)) { - this.logJsonResult({ found: false, lockId }, flags); + this.logJsonResult({ locks: [] }, flags); } else { this.log( chalk.yellow( @@ -68,14 +68,9 @@ export default class SpacesLocksGet extends SpacesBaseCommand { } if (this.shouldOutputJson(flags)) { - this.logJsonResult( - structuredClone(lock) as Record, - flags, - ); + this.logJsonResult({ locks: [formatLockOutput(lock)] }, flags); } else { - this.log( - `${formatLabel("Lock details")} ${this.formatJsonOutput(structuredClone(lock), flags)}`, - ); + this.log(formatLockBlock(lock)); } } catch (error) { this.fail(error, flags, "lockGet"); diff --git a/src/commands/spaces/locks/subscribe.ts b/src/commands/spaces/locks/subscribe.ts index ef3035b5..ee154f7b 100644 --- a/src/commands/spaces/locks/subscribe.ts +++ b/src/commands/spaces/locks/subscribe.ts @@ -1,17 +1,13 @@ import { type Lock } from "@ably/spaces"; import { Args } from "@oclif/core"; -import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; +import { formatListening, formatTimestamp } from "../../../utils/output.js"; import { - formatHeading, - formatLabel, - formatListening, - formatProgress, - formatResource, - formatTimestamp, -} from "../../../utils/output.js"; + formatLockBlock, + formatLockOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesLocksSubscribe extends SpacesBaseCommand { static override args = { @@ -38,35 +34,6 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { private listener: ((lock: Lock) => void) | null = null; - private displayLockDetails(lock: Lock): void { - this.log(` ${formatLabel("Status")} ${lock.status}`); - this.log( - ` ${formatLabel("Member")} ${lock.member?.clientId || "Unknown"}`, - ); - - if (lock.member?.connectionId) { - this.log(` ${formatLabel("Connection ID")} ${lock.member.connectionId}`); - } - - if (lock.timestamp) { - this.log( - ` ${formatLabel("Timestamp")} ${new Date(lock.timestamp).toISOString()}`, - ); - } - - if (lock.attributes) { - this.log( - ` ${formatLabel("Attributes")} ${JSON.stringify(lock.attributes)}`, - ); - } - - if (lock.reason) { - this.log( - ` ${formatLabel("Reason")} ${lock.reason.message || lock.reason.toString()}`, - ); - } - } - async run(): Promise { const { args, flags } = await this.parse(SpacesLocksSubscribe); const { space: spaceName } = args; @@ -91,70 +58,6 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: true }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Connecting to space: ${formatResource(spaceName)}`), - ); - } - - // Get current locks - this.logCliEvent( - flags, - "lock", - "gettingInitial", - "Fetching initial locks", - ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching current locks for space ${formatResource(spaceName)}`, - ), - ); - } - - const locks = await this.space!.locks.getAll(); - this.logCliEvent( - flags, - "lock", - "gotInitial", - `Fetched ${locks.length} initial locks`, - { count: locks.length, locks }, - ); - - // Output current locks - if (locks.length === 0) { - if (!this.shouldOutputJson(flags)) { - this.log( - chalk.yellow("No locks are currently active in this space."), - ); - } - } else if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - locks: locks.map((lock) => ({ - id: lock.id, - member: lock.member, - status: lock.status, - timestamp: lock.timestamp, - ...(lock.attributes && { attributes: lock.attributes }), - ...(lock.reason && { reason: lock.reason }), - })), - spaceName, - status: "connected", - }, - flags, - ); - } else { - this.log( - `\n${formatHeading("Current locks")} (${chalk.bold(locks.length.toString())}):\n`, - ); - - for (const lock of locks) { - this.log(`- Lock ID: ${formatResource(lock.id)}`); - this.displayLockDetails(lock); - } - } - // Subscribe to lock events this.logCliEvent( flags, @@ -176,35 +79,17 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { this.listener = (lock: Lock) => { const timestamp = new Date().toISOString(); - const eventData = { - lock: { - id: lock.id, - member: lock.member, - status: lock.status, - timestamp: lock.timestamp, - ...(lock.attributes && { attributes: lock.attributes }), - ...(lock.reason && { reason: lock.reason }), - }, - spaceName, - timestamp, - eventType: "lock_event", - }; - - this.logCliEvent( - flags, - "lock", - "event-update", - "Lock event received", - eventData, - ); + this.logCliEvent(flags, "lock", "event-update", "Lock event received", { + lockId: lock.id, + status: lock.status, + }); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(eventData, flags); + this.logJsonEvent({ lock: formatLockOutput(lock) }, flags); } else { - this.log( - `${formatTimestamp(timestamp)} Lock ${formatResource(lock.id)} updated`, - ); - this.displayLockDetails(lock); + this.log(formatTimestamp(timestamp)); + this.log(formatLockBlock(lock)); + this.log(""); } }; diff --git a/src/commands/spaces/members/enter.ts b/src/commands/spaces/members/enter.ts index bdaaf44f..e3d25e6f 100644 --- a/src/commands/spaces/members/enter.ts +++ b/src/commands/spaces/members/enter.ts @@ -8,10 +8,12 @@ import { formatListening, formatResource, formatTimestamp, - formatPresenceAction, - formatClientId, formatLabel, } from "../../../utils/output.js"; +import { + formatMemberEventBlock, + formatMemberOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesMembersEnter extends SpacesBaseCommand { static override args = { @@ -83,22 +85,14 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { { profileData }, ); await this.space!.enter(profileData); - const enteredEventData = { + this.logCliEvent(flags, "member", "enteredSpace", "Entered space", { connectionId: this.realtimeClient!.connection.id, - profile: profileData, - spaceName, - status: "connected", - }; - this.logCliEvent( - flags, - "member", - "enteredSpace", - "Entered space", - enteredEventData, - ); + profileData, + }); if (this.shouldOutputJson(flags)) { - this.logJsonResult(enteredEventData, flags); + const self = await this.space!.members.getSelf(); + this.logJsonResult({ members: [formatMemberOutput(self!)] }, flags); } else { this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); if (profileData) { @@ -171,76 +165,20 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { timestamp: now, }); - const memberEventData = { - action, - member: { - clientId: member.clientId, - connectionId: member.connectionId, - isConnected: member.isConnected, - profileData: member.profileData, - }, - spaceName, - timestamp, - eventType: "member_update", - }; this.logCliEvent( flags, "member", `update-${action}`, `Member event '${action}' received`, - memberEventData, + { action, clientId, connectionId }, ); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(memberEventData, flags); + this.logJsonEvent({ member: formatMemberOutput(member) }, flags); } else { - const { symbol: actionSymbol, color: actionColor } = - formatPresenceAction(action); - - this.log( - `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${formatClientId(clientId)} ${actionColor(action)}`, - ); - - const hasProfileData = - member.profileData && Object.keys(member.profileData).length > 0; - - if (hasProfileData) { - this.log( - ` ${formatLabel("Profile")} ${JSON.stringify(member.profileData, null, 2)}`, - ); - } else { - // No profile data available - this.logCliEvent( - flags, - "member", - "noProfileDataForMember", - "No profile data available for member", - ); - } - - if (connectionId === "Unknown") { - // Connection ID is unknown - this.logCliEvent( - flags, - "member", - "unknownConnectionId", - "Connection ID is unknown for member", - ); - } else { - this.log(` ${formatLabel("Connection ID")} ${connectionId}`); - } - - if (member.isConnected === false) { - this.log(` ${formatLabel("Status")} Not connected`); - } else { - // Member is connected - this.logCliEvent( - flags, - "member", - "memberConnected", - "Member is connected", - ); - } + this.log(formatTimestamp(timestamp)); + this.log(formatMemberEventBlock(member, action)); + this.log(""); } }; diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 03ec3608..290dcbb1 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -1,18 +1,17 @@ import type { SpaceMember } from "@ably/spaces"; import { Args } from "@oclif/core"; -import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatClientId, - formatHeading, formatListening, - formatPresenceAction, formatProgress, formatTimestamp, - formatLabel, } from "../../../utils/output.js"; +import { + formatMemberEventBlock, + formatMemberOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesMembersSubscribe extends SpacesBaseCommand { static override args = { @@ -58,73 +57,6 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: true }); - // Get current members - this.logCliEvent( - flags, - "member", - "gettingInitial", - "Fetching initial members", - ); - const members = await this.space!.members.getAll(); - const initialMembers = members.map((member) => ({ - clientId: member.clientId, - connectionId: member.connectionId, - isConnected: member.isConnected, - profileData: member.profileData, - })); - this.logCliEvent( - flags, - "member", - "gotInitial", - `Fetched ${members.length} initial members`, - { count: members.length, members: initialMembers }, - ); - - // Output current members - if (members.length === 0) { - if (!this.shouldOutputJson(flags)) { - this.log( - chalk.yellow("No members are currently present in this space."), - ); - } - } else if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - members: initialMembers, - spaceName, - status: "connected", - }, - flags, - ); - } else { - this.log( - `\n${formatHeading("Current members")} (${chalk.bold(members.length.toString())}):\n`, - ); - - for (const member of members) { - this.log(`- ${formatClientId(member.clientId || "Unknown")}`); - - if ( - member.profileData && - Object.keys(member.profileData).length > 0 - ) { - this.log( - ` ${formatLabel("Profile")} ${JSON.stringify(member.profileData, null, 2)}`, - ); - } - - if (member.connectionId) { - this.log( - ` ${formatLabel("Connection ID")} ${member.connectionId}`, - ); - } - - if (member.isConnected === false) { - this.log(` ${formatLabel("Status")} Not connected`); - } - } - } - if (!this.shouldOutputJson(flags)) { this.log(`\n${formatListening("Listening for member events.")}\n`); } @@ -179,52 +111,20 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { timestamp: now, }); - const memberEventData = { - action, - member: { - clientId: member.clientId, - connectionId: member.connectionId, - isConnected: member.isConnected, - profileData: member.profileData, - }, - spaceName, - timestamp, - eventType: "member_update", - }; this.logCliEvent( flags, "member", `update-${action}`, `Member event '${action}' received`, - memberEventData, + { action, clientId, connectionId }, ); if (this.shouldOutputJson(flags)) { - this.logJsonEvent(memberEventData, flags); + this.logJsonEvent({ member: formatMemberOutput(member) }, flags); } else { - const { symbol: actionSymbol, color: actionColor } = - formatPresenceAction(action); - - this.log( - `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${formatClientId(clientId)} ${actionColor(action)}`, - ); - - if ( - member.profileData && - Object.keys(member.profileData).length > 0 - ) { - this.log( - ` ${formatLabel("Profile")} ${JSON.stringify(member.profileData, null, 2)}`, - ); - } - - if (connectionId !== "Unknown") { - this.log(` ${formatLabel("Connection ID")} ${connectionId}`); - } - - if (member.isConnected === false) { - this.log(` ${formatLabel("Status")} Not connected`); - } + this.log(formatTimestamp(timestamp)); + this.log(formatMemberEventBlock(member, action)); + this.log(""); } }; diff --git a/src/utils/spaces-output.ts b/src/utils/spaces-output.ts new file mode 100644 index 00000000..80409a1d --- /dev/null +++ b/src/utils/spaces-output.ts @@ -0,0 +1,226 @@ +import type { CursorUpdate, Lock, SpaceMember } from "@ably/spaces"; + +import { + formatClientId, + formatEventType, + formatLabel, + formatMessageTimestamp, + formatResource, +} from "./output.js"; + +// --- JSON display interfaces (used by logJsonResult / logJsonEvent) --- + +export interface MemberOutput { + clientId: string; + connectionId: string; + isConnected: boolean; + profileData: Record | null; + location: unknown | null; + lastEvent: { name: string; timestamp: number }; +} + +export interface CursorOutput { + clientId: string; + connectionId: string; + position: { x: number; y: number }; + data: Record | null; +} + +export interface LockOutput { + id: string; + status: string; + member: MemberOutput; + timestamp: number; + attributes: Record | null; + reason: { message?: string; code?: number; statusCode?: number } | null; +} + +export interface LocationEntry { + connectionId: string; + location: unknown; +} + +// --- JSON formatters (SDK type → display interface) --- + +export function formatMemberOutput(member: SpaceMember): MemberOutput { + return { + clientId: member.clientId, + connectionId: member.connectionId, + isConnected: member.isConnected, + profileData: member.profileData ?? null, + location: member.location ?? null, + lastEvent: { + name: member.lastEvent.name, + timestamp: member.lastEvent.timestamp, + }, + }; +} + +export function formatCursorOutput(cursor: CursorUpdate): CursorOutput { + return { + clientId: cursor.clientId, + connectionId: cursor.connectionId, + position: cursor.position, + data: (cursor.data as Record) ?? null, + }; +} + +export function formatLockOutput(lock: Lock): LockOutput { + return { + id: lock.id, + status: lock.status, + member: formatMemberOutput(lock.member), + timestamp: lock.timestamp, + attributes: (lock.attributes as Record) ?? null, + reason: lock.reason + ? { + message: lock.reason.message, + code: lock.reason.code, + statusCode: lock.reason.statusCode, + } + : null, + }; +} + +// --- Human-readable block formatters (for non-JSON output) --- + +/** + * Format a SpaceMember as a multi-line labeled block. + * Used in members enter, members subscribe, and as nested output in locks. + */ +export function formatMemberBlock( + member: SpaceMember, + options?: { indent?: string }, +): string { + const indent = options?.indent ?? ""; + const lines: string[] = [ + `${indent}${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, + `${indent}${formatLabel("Connection ID")} ${member.connectionId}`, + `${indent}${formatLabel("Connected")} ${member.isConnected}`, + ]; + + if (member.profileData && Object.keys(member.profileData).length > 0) { + lines.push( + `${indent}${formatLabel("Profile")} ${JSON.stringify(member.profileData)}`, + ); + } + + if (member.location != null) { + lines.push( + `${indent}${formatLabel("Location")} ${JSON.stringify(member.location)}`, + ); + } + + lines.push( + `${indent}${formatLabel("Last Event")} ${member.lastEvent.name} at ${formatMessageTimestamp(member.lastEvent.timestamp)}`, + ); + + return lines.join("\n"); +} + +/** + * Format a SpaceMember event as a multi-line labeled block with action header. + * Used in members subscribe and members enter for streaming events. + */ +export function formatMemberEventBlock( + member: SpaceMember, + action: string, +): string { + const lines: string[] = [ + `${formatLabel("Action")} ${formatEventType(action)}`, + `${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, + `${formatLabel("Connection ID")} ${member.connectionId}`, + `${formatLabel("Connected")} ${member.isConnected}`, + ]; + + if (member.profileData && Object.keys(member.profileData).length > 0) { + lines.push( + `${formatLabel("Profile")} ${JSON.stringify(member.profileData)}`, + ); + } + + if (member.location != null) { + lines.push(`${formatLabel("Location")} ${JSON.stringify(member.location)}`); + } + + return lines.join("\n"); +} + +/** + * Format a CursorUpdate as a multi-line labeled block. + */ +export function formatCursorBlock( + cursor: CursorUpdate, + options?: { indent?: string }, +): string { + const indent = options?.indent ?? ""; + const lines: string[] = [ + `${indent}${formatLabel("Client ID")} ${formatClientId(cursor.clientId)}`, + `${indent}${formatLabel("Connection ID")} ${cursor.connectionId}`, + `${indent}${formatLabel("Position")} (${cursor.position.x}, ${cursor.position.y})`, + ]; + + if ( + cursor.data && + Object.keys(cursor.data as Record).length > 0 + ) { + lines.push( + `${indent}${formatLabel("Data")} ${JSON.stringify(cursor.data)}`, + ); + } + + return lines.join("\n"); +} + +/** + * Format a Lock as a multi-line labeled block. + */ +export function formatLockBlock(lock: Lock): string { + const lines: string[] = [ + `${formatLabel("Lock ID")} ${formatResource(lock.id)}`, + `${formatLabel("Status")} ${formatEventType(lock.status)}`, + `${formatLabel("Timestamp")} ${formatMessageTimestamp(lock.timestamp)}`, + `${formatLabel("Member")}`, + formatMemberBlock(lock.member, { indent: " " }), + ]; + + if ( + lock.attributes && + Object.keys(lock.attributes as Record).length > 0 + ) { + lines.push( + `${formatLabel("Attributes")} ${JSON.stringify(lock.attributes)}`, + ); + } + + if (lock.reason) { + lines.push( + `${formatLabel("Reason")} ${lock.reason.message || lock.reason.toString()}`, + ); + } + + return lines.join("\n"); +} + +/** + * Format a location update event as a multi-line labeled block. + */ +export function formatLocationUpdateBlock(update: { + member: SpaceMember; + currentLocation: unknown; + previousLocation: unknown; +}): string { + const lines: string[] = [ + `${formatLabel("Client ID")} ${formatClientId(update.member.clientId)}`, + `${formatLabel("Connection ID")} ${update.member.connectionId}`, + `${formatLabel("Current Location")} ${JSON.stringify(update.currentLocation)}`, + ]; + + if (update.previousLocation != null) { + lines.push( + `${formatLabel("Previous Location")} ${JSON.stringify(update.previousLocation)}`, + ); + } + + return lines.join("\n"); +} diff --git a/test/e2e/spaces/spaces-e2e.test.ts b/test/e2e/spaces/spaces-e2e.test.ts index d48a0ae8..f30ca118 100644 --- a/test/e2e/spaces/spaces-e2e.test.ts +++ b/test/e2e/spaces/spaces-e2e.test.ts @@ -191,7 +191,7 @@ describe("Spaces E2E Tests", () => { `bin/run.js spaces locations subscribe ${testSpaceId} --client-id ${client1Id} --duration 20`, outputPath, { - readySignal: "Fetching current locations for space", + readySignal: "Subscribing to location updates", timeoutMs: process.env.CI ? 40000 : 30000, // Increased timeout retryCount: 2, }, @@ -400,7 +400,7 @@ describe("Spaces E2E Tests", () => { if ( output.includes(client2Id) && - output.includes("position:") && + output.includes("Position:") && output.includes("TestUser2") && output.includes("#ff0000") ) { diff --git a/test/helpers/mock-ably-spaces.ts b/test/helpers/mock-ably-spaces.ts index 22641ca1..661d0a3a 100644 --- a/test/helpers/mock-ably-spaces.ts +++ b/test/helpers/mock-ably-spaces.ts @@ -158,6 +158,8 @@ function createMockSpaceMembers(): MockSpaceMembers { connectionId: "mock-connection-id", isConnected: true, profileData: {}, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, }), _emitter: emitter, _emit: (member: SpaceMember) => { @@ -174,7 +176,7 @@ function createMockSpaceLocations(): MockSpaceLocations { return { set: vi.fn().mockImplementation(async () => {}), - getAll: vi.fn().mockResolvedValue([]), + getAll: vi.fn().mockResolvedValue({}), getSelf: vi.fn().mockResolvedValue(null), subscribe: vi.fn((eventOrCallback, callback?) => { const cb = callback ?? eventOrCallback; @@ -204,7 +206,21 @@ function createMockSpaceLocks(): MockSpaceLocks { const emitter = new EventEmitter(); return { - acquire: vi.fn().mockResolvedValue({ id: "mock-lock-id" }), + acquire: vi.fn().mockResolvedValue({ + id: "mock-lock-id", + status: "locked", + member: { + clientId: "mock-client-id", + connectionId: "mock-connection-id", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + timestamp: Date.now(), + attributes: undefined, + reason: undefined, + }), release: vi.fn().mockImplementation(async () => {}), get: vi.fn().mockResolvedValue(null), getAll: vi.fn().mockResolvedValue([]), @@ -237,7 +253,7 @@ function createMockSpaceCursors(): MockSpaceCursors { return { set: vi.fn().mockImplementation(async () => {}), - getAll: vi.fn().mockResolvedValue([]), + getAll: vi.fn().mockResolvedValue({}), subscribe: vi.fn((eventOrCallback, callback?) => { const cb = callback ?? eventOrCallback; const event = callback ? eventOrCallback : null; diff --git a/test/unit/commands/spaces/cursors/get-all.test.ts b/test/unit/commands/spaces/cursors/get-all.test.ts index f5774460..7c6362b7 100644 --- a/test/unit/commands/spaces/cursors/get-all.test.ts +++ b/test/unit/commands/spaces/cursors/get-all.test.ts @@ -26,20 +26,20 @@ describe("spaces:cursors:get-all command", () => { it("should get all cursors from a space", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([ - { + space.cursors.getAll.mockResolvedValue({ + "conn-1": { clientId: "user-1", connectionId: "conn-1", position: { x: 100, y: 200 }, data: { color: "red" }, }, - { + "conn-2": { clientId: "user-2", connectionId: "conn-2", position: { x: 300, y: 400 }, data: { color: "blue" }, }, - ]); + }); const { stdout } = await runCommand( ["spaces:cursors:get-all", "test-space", "--json"], @@ -53,15 +53,15 @@ describe("spaces:cursors:get-all command", () => { ); expect(space.cursors.getAll).toHaveBeenCalled(); - // The command outputs multiple JSON lines - check the content contains expected data - expect(stdout).toContain("test-space"); + // The command outputs JSON with cursors array + expect(stdout).toContain("cursors"); expect(stdout).toContain("success"); }); it("should handle no cursors found", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); const { stdout } = await runCommand( ["spaces:cursors:get-all", "test-space", "--json"], @@ -98,14 +98,14 @@ describe("spaces:cursors:get-all command", () => { it("should output JSON envelope with type and command for cursor results", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([ - { + space.cursors.getAll.mockResolvedValue({ + "conn-1": { clientId: "user-1", connectionId: "conn-1", position: { x: 10, y: 20 }, data: null, }, - ]); + }); const { stdout } = await runCommand( ["spaces:cursors:get-all", "test-space", "--json"], @@ -120,7 +120,6 @@ describe("spaces:cursors:get-all command", () => { expect(resultRecord).toHaveProperty("type", "result"); expect(resultRecord).toHaveProperty("command"); expect(resultRecord).toHaveProperty("success", true); - expect(resultRecord).toHaveProperty("spaceName", "test-space"); expect(resultRecord!.cursors).toBeInstanceOf(Array); }); }); @@ -130,7 +129,7 @@ describe("spaces:cursors:get-all command", () => { const realtimeMock = getMockAblyRealtime(); const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); await runCommand( ["spaces:cursors:get-all", "test-space", "--json"], diff --git a/test/unit/commands/spaces/cursors/set.test.ts b/test/unit/commands/spaces/cursors/set.test.ts index 82b6863b..13410be0 100644 --- a/test/unit/commands/spaces/cursors/set.test.ts +++ b/test/unit/commands/spaces/cursors/set.test.ts @@ -91,6 +91,8 @@ describe("spaces:cursors:set command", () => { ); expect(stdout).toContain("Set cursor"); expect(stdout).toContain("test-space"); + expect(stdout).toContain("Position:"); + expect(stdout).toContain("(100, 200)"); }); it("should set cursor from --data with position object", async () => { @@ -165,8 +167,12 @@ describe("spaces:cursors:set command", () => { expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "spaces:cursors:set"); expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("spaceName", "test-space"); - expect(result!.cursor.position).toEqual({ x: 100, y: 200 }); + expect(result).toHaveProperty("cursors"); + expect(result!.cursors).toHaveLength(1); + expect(result!.cursors[0]).toHaveProperty("position"); + expect(result!.cursors[0].position).toEqual({ x: 100, y: 200 }); + expect(result!.cursors[0]).toHaveProperty("clientId"); + expect(result!.cursors[0]).toHaveProperty("connectionId"); }); }); diff --git a/test/unit/commands/spaces/cursors/subscribe.test.ts b/test/unit/commands/spaces/cursors/subscribe.test.ts index c53854c0..9be955bf 100644 --- a/test/unit/commands/spaces/cursors/subscribe.test.ts +++ b/test/unit/commands/spaces/cursors/subscribe.test.ts @@ -26,7 +26,7 @@ describe("spaces:cursors:subscribe command", () => { it("should subscribe to cursor updates in a space", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); await runCommand( ["spaces:cursors:subscribe", "test-space"], @@ -43,7 +43,7 @@ describe("spaces:cursors:subscribe command", () => { it("should display initial subscription message", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); const { stdout } = await runCommand( ["spaces:cursors:subscribe", "test-space"], @@ -60,7 +60,7 @@ describe("spaces:cursors:subscribe command", () => { const realtimeMock = getMockAblyRealtime(); const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); // Use SIGINT to exit @@ -78,7 +78,7 @@ describe("spaces:cursors:subscribe command", () => { it("should output JSON event with envelope when cursor update is received", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); // Fire a cursor event synchronously when subscribe is called space.cursors.subscribe.mockImplementation( @@ -100,13 +100,16 @@ describe("spaces:cursors:subscribe command", () => { const records = parseNdjsonLines(stdout); const eventRecords = records.filter( - (r) => r.type === "event" && r.eventType === "cursor_update", + (r) => r.type === "event" && r.cursor, ); expect(eventRecords.length).toBeGreaterThan(0); const event = eventRecords[0]; expect(event).toHaveProperty("command"); - expect(event).toHaveProperty("spaceName", "test-space"); - expect(event).toHaveProperty("position"); + expect(event).toHaveProperty("cursor"); + expect(event.cursor).toHaveProperty("clientId", "user-1"); + expect(event.cursor).toHaveProperty("connectionId", "conn-1"); + expect(event.cursor).toHaveProperty("position"); + expect(event.cursor.position).toEqual({ x: 50, y: 75 }); }); }); @@ -114,7 +117,7 @@ describe("spaces:cursors:subscribe command", () => { it("should wait for cursors channel to attach if not already attached", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.cursors.getAll.mockResolvedValue([]); + space.cursors.getAll.mockResolvedValue({}); // Mock channel as attaching space.cursors.channel.state = "attaching"; diff --git a/test/unit/commands/spaces/list.test.ts b/test/unit/commands/spaces/list.test.ts index 60919eb2..da253e5c 100644 --- a/test/unit/commands/spaces/list.test.ts +++ b/test/unit/commands/spaces/list.test.ts @@ -107,9 +107,6 @@ describe("spaces:list command", () => { const json = JSON.parse(stdout); expect(json).toHaveProperty("spaces"); - expect(json).toHaveProperty("total"); - expect(json).toHaveProperty("shown"); - expect(json).toHaveProperty("hasMore"); expect(json).toHaveProperty("success", true); expect(json.spaces).toBeInstanceOf(Array); expect(json.spaces.length).toBe(2); diff --git a/test/unit/commands/spaces/locations/get-all.test.ts b/test/unit/commands/spaces/locations/get-all.test.ts index 7856093f..16c1331e 100644 --- a/test/unit/commands/spaces/locations/get-all.test.ts +++ b/test/unit/commands/spaces/locations/get-all.test.ts @@ -26,13 +26,9 @@ describe("spaces:locations:get-all command", () => { it("should get all locations from a space", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue([ - { - member: { clientId: "user-1", connectionId: "conn-1" }, - currentLocation: { x: 100, y: 200 }, - previousLocation: null, - }, - ]); + space.locations.getAll.mockResolvedValue({ + "conn-1": { x: 100, y: 200 }, + }); const { stdout } = await runCommand( ["spaces:locations:get-all", "test-space", "--json"], @@ -41,19 +37,15 @@ describe("spaces:locations:get-all command", () => { expect(space.enter).toHaveBeenCalled(); expect(space.locations.getAll).toHaveBeenCalled(); - expect(stdout).toContain("test-space"); + expect(stdout).toContain("locations"); }); it("should output JSON envelope with type and command for location results", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue([ - { - member: { clientId: "user-1", connectionId: "conn-1" }, - currentLocation: { x: 100, y: 200 }, - previousLocation: null, - }, - ]); + space.locations.getAll.mockResolvedValue({ + "conn-1": { x: 100, y: 200 }, + }); const { stdout } = await runCommand( ["spaces:locations:get-all", "test-space", "--json"], @@ -68,14 +60,19 @@ describe("spaces:locations:get-all command", () => { expect(resultRecord).toHaveProperty("type", "result"); expect(resultRecord).toHaveProperty("command"); expect(resultRecord).toHaveProperty("success", true); - expect(resultRecord).toHaveProperty("spaceName", "test-space"); expect(resultRecord!.locations).toBeInstanceOf(Array); + expect(resultRecord!.locations.length).toBe(1); + expect(resultRecord!.locations[0]).toHaveProperty( + "connectionId", + "conn-1", + ); + expect(resultRecord!.locations[0]).toHaveProperty("location"); }); it("should handle no locations found", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue([]); + space.locations.getAll.mockResolvedValue({}); const { stdout } = await runCommand( ["spaces:locations:get-all", "test-space", "--json"], diff --git a/test/unit/commands/spaces/locations/set.test.ts b/test/unit/commands/spaces/locations/set.test.ts index 47148a6e..1215f476 100644 --- a/test/unit/commands/spaces/locations/set.test.ts +++ b/test/unit/commands/spaces/locations/set.test.ts @@ -112,7 +112,6 @@ describe("spaces:locations:set command", () => { const result = JSON.parse(stdout); expect(result.success).toBe(true); expect(result.location).toEqual(location); - expect(result.spaceName).toBe("test-space"); }); it("should output JSON error on invalid location", async () => { diff --git a/test/unit/commands/spaces/locations/subscribe.test.ts b/test/unit/commands/spaces/locations/subscribe.test.ts index 10167d06..314a3b07 100644 --- a/test/unit/commands/spaces/locations/subscribe.test.ts +++ b/test/unit/commands/spaces/locations/subscribe.test.ts @@ -26,7 +26,6 @@ describe("spaces:locations:subscribe command", () => { it("should subscribe to location updates in a space", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue({}); await runCommand( ["spaces:locations:subscribe", "test-space"], @@ -40,10 +39,9 @@ describe("spaces:locations:subscribe command", () => { ); }); - it("should display initial subscription message", async () => { + it("should display initial subscription message without fetching current locations", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue({}); const { stdout } = await runCommand( ["spaces:locations:subscribe", "test-space"], @@ -51,61 +49,96 @@ describe("spaces:locations:subscribe command", () => { ); expect(stdout).toContain("Subscribing to location updates"); - expect(stdout).toContain("test-space"); + expect(space.locations.getAll).not.toHaveBeenCalled(); }); - it("should fetch and display current locations", async () => { + it("should output location updates in block format", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue({ - "conn-1": { room: "lobby", x: 100 }, - "conn-2": { room: "chat", x: 200 }, - }); - const { stdout } = await runCommand( + // Capture the subscribe handler and invoke it with a mock update + let locationHandler: ((update: unknown) => void) | undefined; + space.locations.subscribe.mockImplementation( + (_event: string, handler: (update: unknown) => void) => { + locationHandler = handler; + }, + ); + + const runPromise = runCommand( ["spaces:locations:subscribe", "test-space"], import.meta.url, ); - expect(space.locations.getAll).toHaveBeenCalled(); - expect(stdout).toContain("Current locations"); + // Wait a tick for the subscribe to be set up + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (locationHandler) { + locationHandler({ + member: { + clientId: "user-1", + connectionId: "conn-1", + }, + currentLocation: { room: "lobby" }, + previousLocation: { room: "entrance" }, + }); + } + + const { stdout } = await runPromise; + + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("Connection ID:"); + expect(stdout).toContain("Current Location:"); + expect(stdout).toContain("Previous Location:"); }); }); describe("JSON output", () => { - it("should output JSON envelope with initial locations snapshot", async () => { + it("should output JSON event envelope for location updates", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue({ - "conn-1": { room: "lobby", x: 100 }, - }); - const { stdout } = await runCommand( + let locationHandler: ((update: unknown) => void) | undefined; + space.locations.subscribe.mockImplementation( + (_event: string, handler: (update: unknown) => void) => { + locationHandler = handler; + }, + ); + + const runPromise = runCommand( ["spaces:locations:subscribe", "test-space", "--json"], import.meta.url, ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (locationHandler) { + locationHandler({ + member: { + clientId: "user-1", + connectionId: "conn-1", + }, + currentLocation: { room: "lobby" }, + previousLocation: null, + }); + } + + const { stdout } = await runPromise; + const records = parseNdjsonLines(stdout); - const resultRecord = records.find( - (r) => - r.type === "result" && - r.eventType === "locations_snapshot" && - Array.isArray(r.locations), - ); - expect(resultRecord).toBeDefined(); - expect(resultRecord).toHaveProperty("command"); - expect(resultRecord).toHaveProperty("success", true); - expect(resultRecord).toHaveProperty("spaceName", "test-space"); - expect(resultRecord!.locations).toBeInstanceOf(Array); + const eventRecord = records.find((r) => r.type === "event" && r.location); + expect(eventRecord).toBeDefined(); + expect(eventRecord).toHaveProperty("command"); + expect(eventRecord!.location).toHaveProperty("member"); + expect(eventRecord!.location.member).toHaveProperty("clientId", "user-1"); + expect(eventRecord!.location).toHaveProperty("currentLocation"); + expect(eventRecord!.location).toHaveProperty("previousLocation"); }); }); describe("cleanup behavior", () => { it("should close client on completion", async () => { const realtimeMock = getMockAblyRealtime(); - const spacesMock = getMockAblySpaces(); - const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockResolvedValue({}); + getMockAblySpaces(); // Use SIGINT to exit @@ -120,24 +153,20 @@ describe("spaces:locations:subscribe command", () => { }); describe("error handling", () => { - it("should handle getAll rejection gracefully", async () => { + it("should handle subscribe error gracefully", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locations.getAll.mockRejectedValue( - new Error("Failed to get locations"), - ); + space.locations.subscribe.mockImplementation(() => { + throw new Error("Failed to subscribe to locations"); + }); - // The command handles the error via fail and exits const { error } = await runCommand( ["spaces:locations:subscribe", "test-space"], import.meta.url, ); - // Command should report the error expect(error).toBeDefined(); - expect(error?.message).toContain("Failed to get locations"); - // Command should NOT continue to subscribe after getAll fails - expect(space.locations.subscribe).not.toHaveBeenCalled(); + expect(error?.message).toContain("Failed to subscribe to locations"); }); }); }); diff --git a/test/unit/commands/spaces/locks/acquire.test.ts b/test/unit/commands/spaces/locks/acquire.test.ts index 9be20aa3..89470542 100644 --- a/test/unit/commands/spaces/locks/acquire.test.ts +++ b/test/unit/commands/spaces/locks/acquire.test.ts @@ -27,8 +27,16 @@ describe("spaces:locks:acquire command", () => { space.locks.acquire.mockResolvedValue({ id: "my-lock", status: "locked", - member: { clientId: "mock-client-id", connectionId: "conn-1" }, + member: { + clientId: "mock-client-id", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, timestamp: Date.now(), + attributes: undefined, reason: undefined, }); @@ -49,8 +57,17 @@ describe("spaces:locks:acquire command", () => { space.locks.acquire.mockResolvedValue({ id: "my-lock", status: "locked", - member: { clientId: "mock-client-id", connectionId: "conn-1" }, + member: { + clientId: "mock-client-id", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, timestamp: Date.now(), + attributes: undefined, + reason: undefined, }); const { stdout } = await runCommand( @@ -100,8 +117,17 @@ describe("spaces:locks:acquire command", () => { space.locks.acquire.mockResolvedValue({ id: "my-lock", status: "locked", - member: { clientId: "mock-client-id", connectionId: "conn-1" }, + member: { + clientId: "mock-client-id", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: 1700000000000 }, + }, timestamp: 1700000000000, + attributes: undefined, + reason: undefined, }); const { stdout } = await runCommand( @@ -111,9 +137,17 @@ describe("spaces:locks:acquire command", () => { const result = JSON.parse(stdout); expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("lock"); - expect(result.lock).toHaveProperty("lockId", "my-lock"); - expect(result.lock).toHaveProperty("status", "locked"); + expect(result).toHaveProperty("locks"); + expect(result.locks).toHaveLength(1); + expect(result.locks[0]).toHaveProperty("id", "my-lock"); + expect(result.locks[0]).toHaveProperty("status", "locked"); + expect(result.locks[0]).toHaveProperty("member"); + expect(result.locks[0].member).toHaveProperty( + "clientId", + "mock-client-id", + ); + expect(result.locks[0]).toHaveProperty("attributes", null); + expect(result.locks[0]).toHaveProperty("reason", null); }); }); diff --git a/test/unit/commands/spaces/locks/get-all.test.ts b/test/unit/commands/spaces/locks/get-all.test.ts index ea3d8391..b8c25e53 100644 --- a/test/unit/commands/spaces/locks/get-all.test.ts +++ b/test/unit/commands/spaces/locks/get-all.test.ts @@ -29,8 +29,18 @@ describe("spaces:locks:get-all command", () => { space.locks.getAll.mockResolvedValue([ { id: "lock-1", - member: { clientId: "user-1", connectionId: "conn-1" }, + member: { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, status: "locked", + timestamp: Date.now(), + attributes: undefined, + reason: undefined, }, ]); @@ -41,7 +51,7 @@ describe("spaces:locks:get-all command", () => { expect(space.enter).toHaveBeenCalled(); expect(space.locks.getAll).toHaveBeenCalled(); - expect(stdout).toContain("test-space"); + expect(stdout).toContain("locks"); }); it("should output JSON envelope with type and command for lock results", async () => { @@ -50,8 +60,18 @@ describe("spaces:locks:get-all command", () => { space.locks.getAll.mockResolvedValue([ { id: "lock-1", - member: { clientId: "user-1", connectionId: "conn-1" }, + member: { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, status: "locked", + timestamp: Date.now(), + attributes: undefined, + reason: undefined, }, ]); @@ -68,8 +88,13 @@ describe("spaces:locks:get-all command", () => { expect(resultRecord).toHaveProperty("type", "result"); expect(resultRecord).toHaveProperty("command"); expect(resultRecord).toHaveProperty("success", true); - expect(resultRecord).toHaveProperty("spaceName", "test-space"); expect(resultRecord!.locks).toBeInstanceOf(Array); + expect(resultRecord!.locks[0]).toHaveProperty("id", "lock-1"); + expect(resultRecord!.locks[0]).toHaveProperty("member"); + expect(resultRecord!.locks[0].member).toHaveProperty( + "clientId", + "user-1", + ); }); it("should handle no locks found", async () => { diff --git a/test/unit/commands/spaces/locks/get.test.ts b/test/unit/commands/spaces/locks/get.test.ts index c8054e5d..fdfdda84 100644 --- a/test/unit/commands/spaces/locks/get.test.ts +++ b/test/unit/commands/spaces/locks/get.test.ts @@ -53,8 +53,18 @@ describe("spaces:locks:get command", () => { const space = spacesMock._getSpace("test-space"); space.locks.get.mockResolvedValue({ id: "my-lock", - member: { clientId: "user-1", connectionId: "conn-1" }, + member: { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, status: "locked", + timestamp: Date.now(), + attributes: undefined, + reason: undefined, }); const { stdout } = await runCommand( @@ -72,8 +82,18 @@ describe("spaces:locks:get command", () => { const space = spacesMock._getSpace("test-space"); space.locks.get.mockResolvedValue({ id: "my-lock", - member: { clientId: "user-1", connectionId: "conn-1" }, + member: { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, status: "locked", + timestamp: Date.now(), + attributes: undefined, + reason: undefined, }); const { stdout } = await runCommand( @@ -83,13 +103,18 @@ describe("spaces:locks:get command", () => { const records = parseNdjsonLines(stdout); const resultRecord = records.find( - (r) => r.type === "result" && r.id === "my-lock", + (r) => r.type === "result" && Array.isArray(r.locks), ); expect(resultRecord).toBeDefined(); expect(resultRecord).toHaveProperty("type", "result"); expect(resultRecord).toHaveProperty("command", "spaces:locks:get"); expect(resultRecord).toHaveProperty("success", true); - expect(resultRecord).toHaveProperty("status", "locked"); + expect(resultRecord!.locks).toHaveLength(1); + expect(resultRecord!.locks[0]).toHaveProperty("id", "my-lock"); + expect(resultRecord!.locks[0]).toHaveProperty("status", "locked"); + expect(resultRecord!.locks[0]).toHaveProperty("member"); + expect(resultRecord!.locks[0]).toHaveProperty("attributes", null); + expect(resultRecord!.locks[0]).toHaveProperty("reason", null); }); it("should handle lock not found", async () => { @@ -103,7 +128,12 @@ describe("spaces:locks:get command", () => { ); expect(space.locks.get).toHaveBeenCalledWith("nonexistent-lock"); - expect(stdout).toBeDefined(); + const records = parseNdjsonLines(stdout); + const resultRecord = records.find( + (r) => r.type === "result" && Array.isArray(r.locks), + ); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.locks).toEqual([]); }); }); diff --git a/test/unit/commands/spaces/locks/subscribe.test.ts b/test/unit/commands/spaces/locks/subscribe.test.ts index 77476dc5..7ccd3f7d 100644 --- a/test/unit/commands/spaces/locks/subscribe.test.ts +++ b/test/unit/commands/spaces/locks/subscribe.test.ts @@ -26,7 +26,6 @@ describe("spaces:locks:subscribe command", () => { it("should subscribe to lock events in a space", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockResolvedValue([]); await runCommand( ["spaces:locks:subscribe", "test-space"], @@ -37,10 +36,9 @@ describe("spaces:locks:subscribe command", () => { expect(space.locks.subscribe).toHaveBeenCalledWith(expect.any(Function)); }); - it("should display initial subscription message", async () => { + it("should display listening message without fetching initial locks", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockResolvedValue([]); const { stdout } = await runCommand( ["spaces:locks:subscribe", "test-space"], @@ -48,60 +46,75 @@ describe("spaces:locks:subscribe command", () => { ); expect(stdout).toContain("Subscribing to lock events"); - expect(stdout).toContain("test-space"); + expect(space.locks.getAll).not.toHaveBeenCalled(); }); - it("should fetch and display current locks", async () => { + it("should output lock events using block format", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockResolvedValue([ - { - id: "lock-1", - status: "locked", - member: { clientId: "user-1", connectionId: "conn-1" }, - }, - { - id: "lock-2", - status: "pending", - member: { clientId: "user-2", connectionId: "conn-2" }, - }, - ]); - const { stdout } = await runCommand( - ["spaces:locks:subscribe", "test-space"], - import.meta.url, + // Capture the subscribe callback and invoke it with a lock event + space.locks.subscribe.mockImplementation( + (callback: (lock: unknown) => void) => { + callback({ + id: "lock-1", + status: "locked", + member: { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + timestamp: Date.now(), + attributes: undefined, + reason: undefined, + }); + return Promise.resolve(); + }, ); - expect(space.locks.getAll).toHaveBeenCalled(); - expect(stdout).toContain("Current locks"); - expect(stdout).toContain("lock-1"); - }); - - it("should show message when no locks exist", async () => { - const spacesMock = getMockAblySpaces(); - const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockResolvedValue([]); - const { stdout } = await runCommand( ["spaces:locks:subscribe", "test-space"], import.meta.url, ); - expect(stdout).toContain("No locks"); + expect(stdout).toContain("Lock ID:"); + expect(stdout).toContain("lock-1"); + expect(stdout).toContain("Status:"); + expect(stdout).toContain("locked"); + expect(stdout).toContain("Member:"); + expect(stdout).toContain("user-1"); }); }); describe("JSON output", () => { - it("should output JSON envelope with initial locks snapshot", async () => { + it("should output JSON event envelope for lock events", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockResolvedValue([ - { - id: "lock-1", - status: "locked", - member: { clientId: "user-1", connectionId: "conn-1" }, + + // Capture the subscribe callback and invoke it with a lock event + space.locks.subscribe.mockImplementation( + (callback: (lock: unknown) => void) => { + callback({ + id: "lock-1", + status: "locked", + member: { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + timestamp: Date.now(), + attributes: undefined, + reason: undefined, + }); + return Promise.resolve(); }, - ]); + ); const { stdout } = await runCommand( ["spaces:locks:subscribe", "test-space", "--json"], @@ -109,26 +122,20 @@ describe("spaces:locks:subscribe command", () => { ); const records = parseNdjsonLines(stdout); - const resultRecord = records.find( - (r) => r.type === "result" && Array.isArray(r.locks), - ); - expect(resultRecord).toBeDefined(); - expect(resultRecord).toHaveProperty("type", "result"); - expect(resultRecord).toHaveProperty("command"); - expect(resultRecord).toHaveProperty("success", true); - expect(resultRecord).toHaveProperty("spaceName", "test-space"); - expect(resultRecord!.locks).toBeInstanceOf(Array); + const eventRecord = records.find((r) => r.type === "event" && r.lock); + expect(eventRecord).toBeDefined(); + expect(eventRecord).toHaveProperty("type", "event"); + expect(eventRecord).toHaveProperty("command"); + expect(eventRecord!.lock).toHaveProperty("id", "lock-1"); + expect(eventRecord!.lock).toHaveProperty("status", "locked"); + expect(eventRecord!.lock).toHaveProperty("member"); }); }); describe("cleanup behavior", () => { it("should close client on completion", async () => { const realtimeMock = getMockAblyRealtime(); - const spacesMock = getMockAblySpaces(); - const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockResolvedValue([]); - - // Use SIGINT to exit + getMockAblySpaces(); await runCommand( ["spaces:locks:subscribe", "test-space"], @@ -141,19 +148,20 @@ describe("spaces:locks:subscribe command", () => { }); describe("error handling", () => { - it("should handle getAll rejection gracefully", async () => { + it("should handle subscribe rejection gracefully", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.locks.getAll.mockRejectedValue(new Error("Failed to get locks")); + space.locks.subscribe.mockRejectedValue( + new Error("Failed to subscribe to locks"), + ); - // The command catches errors and continues - const { stdout } = await runCommand( + const { error } = await runCommand( ["spaces:locks:subscribe", "test-space"], import.meta.url, ); - // Command should have run (output should be present) - expect(stdout).toBeDefined(); + // Command should have attempted to run and reported the error + expect(error).toBeDefined(); }); }); }); diff --git a/test/unit/commands/spaces/members/enter.test.ts b/test/unit/commands/spaces/members/enter.test.ts index eccb28cd..702bcdee 100644 --- a/test/unit/commands/spaces/members/enter.test.ts +++ b/test/unit/commands/spaces/members/enter.test.ts @@ -88,8 +88,16 @@ describe("spaces:members:enter command", () => { const result = JSON.parse(stdout); expect(result.success).toBe(true); - expect(result.spaceName).toBe("test-space"); - expect(result.status).toBe("connected"); + expect(result.members).toBeDefined(); + expect(result.members).toHaveLength(1); + expect(result.members[0]).toHaveProperty("clientId", "mock-client-id"); + expect(result.members[0]).toHaveProperty( + "connectionId", + "mock-connection-id", + ); + expect(result.members[0]).toHaveProperty("isConnected", true); + expect(result.members[0]).toHaveProperty("location", null); + expect(result.members[0]).toHaveProperty("lastEvent"); }); it("should output JSON error on invalid profile", async () => { diff --git a/test/unit/commands/spaces/members/subscribe.test.ts b/test/unit/commands/spaces/members/subscribe.test.ts index 11ccbbf5..30b08495 100644 --- a/test/unit/commands/spaces/members/subscribe.test.ts +++ b/test/unit/commands/spaces/members/subscribe.test.ts @@ -21,67 +21,40 @@ describe("spaces:members:subscribe command", () => { standardFlagTests("spaces:members:subscribe", import.meta.url, ["--json"]); describe("functionality", () => { - it("should display current members from getAll()", async () => { + it("should subscribe to member events and output in block format", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.members.getAll.mockResolvedValue([ - { - clientId: "user-1", - connectionId: "conn-1", - isConnected: true, - profileData: {}, - }, - { - clientId: "user-2", - connectionId: "conn-2", - isConnected: true, - profileData: {}, - }, - ]); - - const { stdout } = await runCommand( - ["spaces:members:subscribe", "test-space"], - import.meta.url, - ); - - expect(space.members.getAll).toHaveBeenCalled(); - expect(stdout).toContain("Current members"); - expect(stdout).toContain("user-1"); - expect(stdout).toContain("user-2"); - }); - it("should show profile data for members", async () => { - const spacesMock = getMockAblySpaces(); - const space = spacesMock._getSpace("test-space"); - space.members.getAll.mockResolvedValue([ - { - clientId: "user-1", - connectionId: "conn-1", - isConnected: true, - profileData: { name: "Alice", role: "admin" }, + // Emit a member event after subscription is set up + space.members.subscribe.mockImplementation( + (event: string, cb: (member: unknown) => void) => { + // Fire the callback asynchronously to simulate an incoming event + setTimeout(() => { + cb({ + clientId: "user-1", + connectionId: "other-conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "update", timestamp: Date.now() }, + }); + }, 10); + return Promise.resolve(); }, - ]); - - const { stdout } = await runCommand( - ["spaces:members:subscribe", "test-space"], - import.meta.url, ); - expect(stdout).toContain("Alice"); - expect(stdout).toContain("admin"); - }); - - it("should show message when no members are present", async () => { - const spacesMock = getMockAblySpaces(); - const space = spacesMock._getSpace("test-space"); - space.members.getAll.mockResolvedValue([]); - const { stdout } = await runCommand( ["spaces:members:subscribe", "test-space"], import.meta.url, ); - expect(stdout).toContain("No members are currently present"); + expect(stdout).toContain("Action:"); + expect(stdout).toContain("update"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Connection ID:"); + expect(stdout).toContain("other-conn-1"); + expect(stdout).toContain("Connected:"); }); }); @@ -89,7 +62,6 @@ describe("spaces:members:subscribe command", () => { it("should subscribe to member update events", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.members.getAll.mockResolvedValue([]); await runCommand( ["spaces:members:subscribe", "test-space"], @@ -105,17 +77,25 @@ describe("spaces:members:subscribe command", () => { }); describe("JSON output", () => { - it("should output JSON for initial members", async () => { + it("should output JSON event for member updates", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.members.getAll.mockResolvedValue([ - { - clientId: "user-1", - connectionId: "conn-1", - isConnected: true, - profileData: { name: "Alice" }, + + space.members.subscribe.mockImplementation( + (event: string, cb: (member: unknown) => void) => { + setTimeout(() => { + cb({ + clientId: "user-1", + connectionId: "other-conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "update", timestamp: Date.now() }, + }); + }, 10); + return Promise.resolve(); }, - ]); + ); const { stdout } = await runCommand( ["spaces:members:subscribe", "test-space", "--json"], @@ -123,9 +103,9 @@ describe("spaces:members:subscribe command", () => { ); const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.members).toHaveLength(1); - expect(result.members[0].clientId).toBe("user-1"); + expect(result.type).toBe("event"); + expect(result.member).toBeDefined(); + expect(result.member.clientId).toBe("user-1"); }); });