diff --git a/.claude/skills/ably-codebase-review/SKILL.md b/.claude/skills/ably-codebase-review/SKILL.md index 703ffe324..dbe59626e 100644 --- a/.claude/skills/ably-codebase-review/SKILL.md +++ b/.claude/skills/ably-codebase-review/SKILL.md @@ -154,6 +154,7 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses 4. Cross-reference: every leaf command should appear in both the `logJsonResult`/`logJsonEvent` list and the `shouldOutputJson` list 5. **Read** streaming commands to verify they use `logJsonEvent`, one-shot commands use `logJsonResult` 6. **Read** each `logJsonResult`/`logJsonEvent` call and verify data is nested under a domain key — singular for events/single items (e.g., `{message: ...}`, `{cursor: ...}`), plural for collections (e.g., `{cursors: [...]}`, `{rules: [...]}`). Top-level envelope fields are `type`, `command`, `success` only. Metadata like `total`, `timestamp`, `appId` may sit alongside the domain key. +7. **Check** hold commands (set, enter, acquire) emit `logJsonStatus("holding", ...)` after `logJsonResult` — this signals to JSON consumers that the command is alive and waiting for Ctrl+C / `--duration` **Reasoning guidance:** - Commands that ONLY have human output (no JSON path) are deviations @@ -161,6 +162,7 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses - Topic index commands (showing help) don't need JSON output - Data spread at the top level without a domain key is a deviation — nest under a singular or plural domain noun - Metadata fields (`total`, `timestamp`, `hasMore`, `appId`) alongside the domain key are acceptable — they describe the result, not the domain objects +- Hold commands missing `logJsonStatus` after `logJsonResult` are deviations — JSON consumers need the hold signal ### Agent 6: Test Pattern Sweep diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index 0a5434c12..dd05e0d0c 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -667,18 +667,15 @@ Commands must behave strictly according to their documented purpose — no unint - **NOT enter presence/space** — `getAll()`, `get()` do NOT require `space.enter()` - **NOT subscribe** to events or poll — fetch once, output, exit -**Set commands** — one-shot mutations: -- Enter space (required by SDK), set value, output, **exit** -- **NOT subscribe** after setting — that is what subscribe commands are for - -**Enter / acquire commands** — hold state until Ctrl+C / `--duration`: -- Enter space, output confirmation with all relevant fields, then `waitAndTrackCleanup` -- **NOT subscribe** to other events +**Set / enter / acquire commands** — hold state until Ctrl+C / `--duration`: +- Enter space (manual: `enterSpace: false` + `space.enter()` + `markAsEntered()`), perform operation, output confirmation, then hold with `waitAndTrackCleanup` +- Emit `formatListening("Holding .")` (human) and `logJsonStatus("holding", ...)` (JSON) +- **NOT subscribe** to other events — that is what subscribe commands are for **Side-effect rules:** - `space.enter()` only when SDK requires it (set, enter, acquire) - Call `this.markAsEntered()` after every `space.enter()` (enables cleanup) -- `initializeSpace(enterSpace: true)` calls `markAsEntered()` automatically +- For hold commands, always use manual entry (`enterSpace: false` + `space.enter()` + `markAsEntered()`) for consistency ```typescript // WRONG — subscribe enters the space @@ -696,14 +693,15 @@ const data = await this.space!.locations.getAll(); // CORRECT — get-all just fetches const data = await this.space!.locations.getAll(); -// WRONG — set command subscribes after setting -await this.space!.locations.set(location); -this.space!.locations.subscribe("update", handler); // NO -await this.waitAndTrackCleanup(flags, "location"); // NO - -// CORRECT — set command exits after setting +// CORRECT — set command holds after setting +await this.initializeSpace(flags, spaceName, { enterSpace: false }); +await this.space!.enter(); +this.markAsEntered(); await this.space!.locations.set(location); -// run() completes, finally() handles cleanup +// output result... +this.log(formatListening("Holding location.")); +this.logJsonStatus("holding", "Holding location. Press Ctrl+C to exit.", flags); +await this.waitAndTrackCleanup(flags, "location", flags.duration); ``` --- @@ -747,6 +745,32 @@ this.logJsonResult({ channels: items, total, hasMore }, flags); // channel Metadata fields (`total`, `timestamp`, `hasMore`, `appId`) may sit alongside the collection key since they describe the result, not the domain objects. +### Hold status for long-running commands (logJsonStatus) + +Long-running commands that hold state (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must emit a status line after the result so JSON consumers know the command is alive and waiting: + +```typescript +// After the result output: +if (this.shouldOutputJson(flags)) { + this.logJsonResult({ member: formatMemberOutput(self!) }, flags); +} else { + this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); + // ... labels ... + this.log(formatListening("Holding presence.")); +} + +// logJsonStatus has built-in shouldOutputJson guard — no outer if needed +this.logJsonStatus("holding", "Holding presence. Press Ctrl+C to exit.", flags); + +await this.waitAndTrackCleanup(flags, "member", flags.duration); +``` + +This emits two NDJSON lines in `--json` mode: +```jsonl +{"type":"result","command":"spaces:members:enter","success":true,"member":{...}} +{"type":"status","command":"spaces:members:enter","status":"holding","message":"Holding presence. Press Ctrl+C to exit."} +``` + ### Choosing the domain key name | Scenario | Key | Example | diff --git a/.claude/skills/ably-review/SKILL.md b/.claude/skills/ably-review/SKILL.md index 530b33d67..6fd540730 100644 --- a/.claude/skills/ably-review/SKILL.md +++ b/.claude/skills/ably-review/SKILL.md @@ -119,6 +119,7 @@ For each changed command file, run the relevant checks. Spawn agents for paralle 3. **Grep** for `shouldOutputJson` — verify human output is guarded 4. **Read** the file to verify streaming commands use `logJsonEvent` and one-shot commands use `logJsonResult` 5. **Read** `logJsonResult`/`logJsonEvent` call sites and check data is nested under a domain key (singular for events/single items, plural for collections) — not spread at top level. Top-level envelope fields are `type`, `command`, `success` only. Metadata like `total`, `timestamp`, `appId` may sit alongside the domain key. +6. **Check** hold commands (set, enter, acquire) emit `logJsonStatus("holding", ...)` after `logJsonResult` — this signals to JSON consumers that the command is alive and waiting for Ctrl+C / `--duration` **Control API helper check (grep — for Control API commands only):** 1. **Grep** for `resolveAppId` — should use `requireAppId` instead (encapsulates null check and `fail()`) diff --git a/AGENTS.md b/AGENTS.md index ffed27390..617eab843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,7 +223,8 @@ All output helpers use the `format` prefix and are exported from `src/utils/outp - **Pagination warning**: `formatPaginationWarning(pagesConsumed, itemCount, isBillable?)` — shows "Fetched N pages" when `pagesConsumed > 1`. Pass `isBillable: true` for history commands (billable API calls). Guard with `!this.shouldOutputJson(flags)`. - **Pagination next hint**: `buildPaginationNext(hasMore, lastTimestamp?)` — returns `{ hint, start? }` for JSON output when `hasMore` is true. Pass `lastTimestamp` only for history commands (which have `--start`). - **JSON guard**: All human-readable output (progress, success, listening messages) must be wrapped in `if (!this.shouldOutputJson(flags))` so it doesn't pollute `--json` output. Only JSON payloads should be emitted when `--json` is active. -- **JSON envelope**: Use `this.logJsonResult(data, flags)` for one-shot results and `this.logJsonEvent(data, flags)` for streaming events. The envelope adds three top-level fields (`type`, `command`, `success?`). Nest domain data under a **domain key** (see "JSON data nesting convention" below). Do NOT add ad-hoc `success: true/false` — the envelope handles it. `--json` produces compact single-line output (NDJSON for streaming). `--pretty-json` is unchanged. +- **JSON envelope**: Use `this.logJsonResult(data, flags)` for one-shot results, `this.logJsonEvent(data, flags)` for streaming events, and `this.logJsonStatus(status, message, flags)` for hold/status signals in long-running commands. The envelope adds top-level fields (`type`, `command`, `success?`). Nest domain data under a **domain key** (see "JSON data nesting convention" below). Do NOT add ad-hoc `success: true/false` — the envelope handles it. `--json` produces compact single-line output (NDJSON for streaming). `--pretty-json` is unchanged. +- **JSON hold status**: Long-running hold commands (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must emit a `logJsonStatus("holding", "Holding . Press Ctrl+C to exit.", flags)` line after the result. This tells LLM agents and scripts that the command is alive and waiting. `logJsonStatus` has a built-in `shouldOutputJson` guard — no outer `if` needed. - **JSON errors**: Use `this.fail(error, flags, component, context?)` as the single error funnel in command `run()` methods. It logs the CLI event, preserves structured error data (Ably codes, HTTP status), emits JSON error envelope when `--json` is active, and calls `this.error()` for human-readable output. Returns `never` — no `return;` needed after calling it. Do NOT call `this.error()` directly — it is an internal implementation detail of `fail`. - **History output**: Use `[index] [timestamp]` on the same line as a heading: `` `${formatIndex(index + 1)} ${formatTimestamp(timestamp)}` ``, then fields indented below. This is distinct from **get-all output** which uses `[index]` alone on its own line. See `references/patterns.md` "History results" and "One-shot results" for both patterns. @@ -246,9 +247,9 @@ See `references/patterns.md` "JSON Data Nesting Convention" in the `ably-new-com Each command type has strict rules about what side effects it may have. Remove unintended side effects (e.g., auto-entering presence) and support passive ("dumb") operations where applicable. Key principles: - **Subscribe** = passive observer (no `space.enter()`, no fetching initial state) - **Get-all / get** = one-shot query (no `space.enter()`, no subscribing) -- **Set** = one-shot mutation (enter, set, exit — no subscribing after) -- **Enter / acquire** = hold state until Ctrl+C / `--duration` +- **Set / enter / acquire** = hold state until Ctrl+C / `--duration` (enter, operate, hold — no subscribing after) - Call `space.enter()` only when SDK requires it; always call `this.markAsEntered()` after +- Hold commands use manual entry (`enterSpace: false` + `space.enter()` + `markAsEntered()`) for consistency See `references/patterns.md` "Command behavior semantics" in the `ably-new-command` skill for full rules, side-effect table, and code examples. diff --git a/README.md b/README.md index a358cfa09..08af0a3d5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ npm install -g @ably/cli $ ably COMMAND running command... $ ably (--version) -@ably/cli/0.17.0 darwin-arm64 node-v24.4.1 +@ably/cli/0.17.0 darwin-arm64 node-v25.3.0 $ ably --help [COMMAND] USAGE $ ably COMMAND @@ -200,22 +200,25 @@ $ ably-interactive * [`ably rooms typing subscribe ROOM`](#ably-rooms-typing-subscribe-room) * [`ably spaces`](#ably-spaces) * [`ably spaces cursors`](#ably-spaces-cursors) -* [`ably spaces cursors get-all SPACE`](#ably-spaces-cursors-get-all-space) -* [`ably spaces cursors set SPACE`](#ably-spaces-cursors-set-space) -* [`ably spaces cursors subscribe SPACE`](#ably-spaces-cursors-subscribe-space) +* [`ably spaces cursors get-all SPACE_NAME`](#ably-spaces-cursors-get-all-space_name) +* [`ably spaces cursors set SPACE_NAME`](#ably-spaces-cursors-set-space_name) +* [`ably spaces cursors subscribe SPACE_NAME`](#ably-spaces-cursors-subscribe-space_name) * [`ably spaces list`](#ably-spaces-list) * [`ably spaces locations`](#ably-spaces-locations) -* [`ably spaces locations get-all SPACE`](#ably-spaces-locations-get-all-space) -* [`ably spaces locations set SPACE`](#ably-spaces-locations-set-space) -* [`ably spaces locations subscribe SPACE`](#ably-spaces-locations-subscribe-space) +* [`ably spaces locations get-all SPACE_NAME`](#ably-spaces-locations-get-all-space_name) +* [`ably spaces locations set SPACE_NAME`](#ably-spaces-locations-set-space_name) +* [`ably spaces locations subscribe SPACE_NAME`](#ably-spaces-locations-subscribe-space_name) * [`ably spaces locks`](#ably-spaces-locks) -* [`ably spaces locks acquire SPACE LOCKID`](#ably-spaces-locks-acquire-space-lockid) -* [`ably spaces locks get SPACE LOCKID`](#ably-spaces-locks-get-space-lockid) -* [`ably spaces locks get-all SPACE`](#ably-spaces-locks-get-all-space) -* [`ably spaces locks subscribe SPACE`](#ably-spaces-locks-subscribe-space) +* [`ably spaces locks acquire SPACE_NAME LOCKID`](#ably-spaces-locks-acquire-space_name-lockid) +* [`ably spaces locks get SPACE_NAME LOCKID`](#ably-spaces-locks-get-space_name-lockid) +* [`ably spaces locks get-all SPACE_NAME`](#ably-spaces-locks-get-all-space_name) +* [`ably spaces locks subscribe SPACE_NAME`](#ably-spaces-locks-subscribe-space_name) * [`ably spaces members`](#ably-spaces-members) -* [`ably spaces members enter SPACE`](#ably-spaces-members-enter-space) -* [`ably spaces members subscribe SPACE`](#ably-spaces-members-subscribe-space) +* [`ably spaces members enter SPACE_NAME`](#ably-spaces-members-enter-space_name) +* [`ably spaces members subscribe SPACE_NAME`](#ably-spaces-members-subscribe-space_name) +* [`ably spaces occupancy`](#ably-spaces-occupancy) +* [`ably spaces occupancy get SPACE_NAME`](#ably-spaces-occupancy-get-space_name) +* [`ably spaces occupancy subscribe SPACE_NAME`](#ably-spaces-occupancy-subscribe-space_name) * [`ably stats`](#ably-stats) * [`ably stats account`](#ably-stats-account) * [`ably stats app [ID]`](#ably-stats-app-id) @@ -4402,6 +4405,7 @@ COMMANDS ably spaces locations Commands for location management in Ably Spaces ably spaces locks Commands for component locking in Ably Spaces ably spaces members Commands for managing members in Ably Spaces + ably spaces occupancy Commands for working with occupancy in Ably Spaces ``` _See code: [src/commands/spaces/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/index.ts)_ @@ -4427,16 +4431,16 @@ EXAMPLES _See code: [src/commands/spaces/cursors/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/cursors/index.ts)_ -## `ably spaces cursors get-all SPACE` +## `ably spaces cursors get-all SPACE_NAME` Get all current cursors in a space ``` USAGE - $ ably spaces cursors get-all SPACE [-v] [--json | --pretty-json] [--client-id ] + $ ably spaces cursors get-all SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] ARGUMENTS - SPACE Space to get cursors from + SPACE_NAME Name of the space to get cursors from FLAGS -v, --verbose Output verbose logs @@ -4458,17 +4462,17 @@ EXAMPLES _See code: [src/commands/spaces/cursors/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/cursors/get-all.ts)_ -## `ably spaces cursors set SPACE` +## `ably spaces cursors set SPACE_NAME` Set a cursor with position data in a space ``` USAGE - $ ably spaces cursors set SPACE [-v] [--json | --pretty-json] [--client-id ] [--data ] [--x ] - [--y ] [--simulate] [-D ] + $ ably spaces cursors set SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [--data ] [--x + ] [--y ] [--simulate] [-D ] ARGUMENTS - SPACE The space to set cursor in + SPACE_NAME Name of the space to set cursor in FLAGS -D, --duration= Automatically exit after N seconds @@ -4507,16 +4511,16 @@ EXAMPLES _See code: [src/commands/spaces/cursors/set.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/cursors/set.ts)_ -## `ably spaces cursors subscribe SPACE` +## `ably spaces cursors subscribe SPACE_NAME` Subscribe to cursor movements in a space ``` USAGE - $ ably spaces cursors subscribe SPACE [-v] [--json | --pretty-json] [--client-id ] [-D ] + $ ably spaces cursors subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] ARGUMENTS - SPACE Space to subscribe to cursors for + SPACE_NAME Name of the space to subscribe to cursors for FLAGS -D, --duration= Automatically exit after N seconds @@ -4594,16 +4598,16 @@ EXAMPLES _See code: [src/commands/spaces/locations/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locations/index.ts)_ -## `ably spaces locations get-all SPACE` +## `ably spaces locations get-all SPACE_NAME` Get all current locations in a space ``` USAGE - $ ably spaces locations get-all SPACE [-v] [--json | --pretty-json] [--client-id ] + $ ably spaces locations get-all SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] ARGUMENTS - SPACE Space to get locations from + SPACE_NAME Name of the space to get locations from FLAGS -v, --verbose Output verbose logs @@ -4625,16 +4629,17 @@ EXAMPLES _See code: [src/commands/spaces/locations/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locations/get-all.ts)_ -## `ably spaces locations set SPACE` +## `ably spaces locations set SPACE_NAME` -Set your location in a space +Set location in a space ``` USAGE - $ ably spaces locations set SPACE --location [-v] [--json | --pretty-json] [--client-id ] [-D ] + $ ably spaces locations set SPACE_NAME --location [-v] [--json | --pretty-json] [--client-id ] [-D + ] ARGUMENTS - SPACE Space to set location in + SPACE_NAME Name of the space to set location in FLAGS -D, --duration= Automatically exit after N seconds @@ -4646,7 +4651,7 @@ FLAGS --pretty-json Output in colorized JSON format DESCRIPTION - Set your location in a space + Set location in a space EXAMPLES $ ably spaces locations set my-space --location '{"x":10,"y":20}' @@ -4658,16 +4663,16 @@ EXAMPLES _See code: [src/commands/spaces/locations/set.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locations/set.ts)_ -## `ably spaces locations subscribe SPACE` +## `ably spaces locations subscribe SPACE_NAME` Subscribe to location updates for members in a space ``` USAGE - $ ably spaces locations subscribe SPACE [-v] [--json | --pretty-json] [--client-id ] [-D ] + $ ably spaces locations subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] ARGUMENTS - SPACE Space to subscribe to locations for + SPACE_NAME Name of the space to subscribe to locations for FLAGS -D, --duration= Automatically exit after N seconds @@ -4715,18 +4720,18 @@ EXAMPLES _See code: [src/commands/spaces/locks/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locks/index.ts)_ -## `ably spaces locks acquire SPACE LOCKID` +## `ably spaces locks acquire SPACE_NAME LOCKID` Acquire a lock in a space ``` USAGE - $ ably spaces locks acquire SPACE LOCKID [-v] [--json | --pretty-json] [--client-id ] [--data ] [-D + $ ably spaces locks acquire SPACE_NAME LOCKID [-v] [--json | --pretty-json] [--client-id ] [--data ] [-D ] ARGUMENTS - SPACE Space to acquire lock in - LOCKID ID of the lock to acquire + SPACE_NAME Name of the space to acquire lock in + LOCKID ID of the lock to acquire FLAGS -D, --duration= Automatically exit after N seconds @@ -4750,17 +4755,17 @@ EXAMPLES _See code: [src/commands/spaces/locks/acquire.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locks/acquire.ts)_ -## `ably spaces locks get SPACE LOCKID` +## `ably spaces locks get SPACE_NAME LOCKID` Get a lock in a space ``` USAGE - $ ably spaces locks get SPACE LOCKID [-v] [--json | --pretty-json] [--client-id ] + $ ably spaces locks get SPACE_NAME LOCKID [-v] [--json | --pretty-json] [--client-id ] ARGUMENTS - SPACE Space to get lock from - LOCKID Lock ID to get + SPACE_NAME Name of the space to get lock from + LOCKID Lock ID to get FLAGS -v, --verbose Output verbose logs @@ -4782,16 +4787,16 @@ EXAMPLES _See code: [src/commands/spaces/locks/get.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locks/get.ts)_ -## `ably spaces locks get-all SPACE` +## `ably spaces locks get-all SPACE_NAME` Get all current locks in a space ``` USAGE - $ ably spaces locks get-all SPACE [-v] [--json | --pretty-json] [--client-id ] + $ ably spaces locks get-all SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] ARGUMENTS - SPACE Space to get locks from + SPACE_NAME Name of the space to get locks from FLAGS -v, --verbose Output verbose logs @@ -4813,16 +4818,16 @@ EXAMPLES _See code: [src/commands/spaces/locks/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/locks/get-all.ts)_ -## `ably spaces locks subscribe SPACE` +## `ably spaces locks subscribe SPACE_NAME` Subscribe to lock events in a space ``` USAGE - $ ably spaces locks subscribe SPACE [-v] [--json | --pretty-json] [--client-id ] [-D ] + $ ably spaces locks subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] ARGUMENTS - SPACE Space to subscribe to locks for + SPACE_NAME Name of the space to subscribe to locks for FLAGS -D, --duration= Automatically exit after N seconds @@ -4868,16 +4873,17 @@ EXAMPLES _See code: [src/commands/spaces/members/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/index.ts)_ -## `ably spaces members enter SPACE` +## `ably spaces members enter SPACE_NAME` Enter a space and remain present until terminated ``` USAGE - $ ably spaces members enter SPACE [-v] [--json | --pretty-json] [--client-id ] [--profile ] [-D ] + $ ably spaces members enter SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [--profile ] [-D + ] ARGUMENTS - SPACE Space to enter + SPACE_NAME Name of the space to enter FLAGS -D, --duration= Automatically exit after N seconds @@ -4903,16 +4909,16 @@ EXAMPLES _See code: [src/commands/spaces/members/enter.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/enter.ts)_ -## `ably spaces members subscribe SPACE` +## `ably spaces members subscribe SPACE_NAME` Subscribe to member presence events in a space ``` USAGE - $ ably spaces members subscribe SPACE [-v] [--json | --pretty-json] [--client-id ] [-D ] + $ ably spaces members subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] ARGUMENTS - SPACE Space to subscribe to members for + SPACE_NAME Name of the space to subscribe to members for FLAGS -D, --duration= Automatically exit after N seconds @@ -4937,6 +4943,86 @@ EXAMPLES _See code: [src/commands/spaces/members/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/subscribe.ts)_ +## `ably spaces occupancy` + +Commands for working with occupancy in Ably Spaces + +``` +USAGE + $ ably spaces occupancy + +DESCRIPTION + Commands for working with occupancy in Ably Spaces + +EXAMPLES + $ ably spaces occupancy get my-space + + $ ably spaces occupancy subscribe my-space +``` + +_See code: [src/commands/spaces/occupancy/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/occupancy/index.ts)_ + +## `ably spaces occupancy get SPACE_NAME` + +Get current occupancy metrics for a space + +``` +USAGE + $ ably spaces occupancy get SPACE_NAME [-v] [--json | --pretty-json] + +ARGUMENTS + SPACE_NAME Space name to get occupancy for + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get current occupancy metrics for a space + +EXAMPLES + $ ably spaces occupancy get my-space + + $ ably spaces occupancy get my-space --json + + $ ably spaces occupancy get my-space --pretty-json +``` + +_See code: [src/commands/spaces/occupancy/get.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/occupancy/get.ts)_ + +## `ably spaces occupancy subscribe SPACE_NAME` + +Subscribe to occupancy events on a space + +``` +USAGE + $ ably spaces occupancy subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] + +ARGUMENTS + SPACE_NAME Space name to subscribe to occupancy events + +FLAGS + -D, --duration= Automatically exit after N seconds + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Subscribe to occupancy events on a space + +EXAMPLES + $ ably spaces occupancy subscribe my-space + + $ ably spaces occupancy subscribe my-space --json + + $ ably spaces occupancy subscribe my-space --duration 30 +``` + +_See code: [src/commands/spaces/occupancy/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/occupancy/subscribe.ts)_ + ## `ably stats` View statistics for your Ably account or apps diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 0682224da..7578c0393 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -54,7 +54,7 @@ This document outlines the directory structure of the Ably CLI project. │ │ │ └── channels/ # Channel subscriptions (list, list-channels, save, remove, remove-where) │ │ ├── queues/ # Queue management │ │ ├── rooms/ # Ably Chat rooms (send, subscribe, presence, reactions, typing, etc.) -│ │ ├── spaces/ # Ably Spaces (members, cursors, locations, locks) +│ │ ├── spaces/ # Ably Spaces (members, cursors, locations, locks, occupancy) │ │ ├── stats/ # Usage statistics │ │ ├── support/ # Support contact info │ │ ├── test/ # Diagnostic test commands diff --git a/src/base-command.ts b/src/base-command.ts index 586b5c202..96b6a05ab 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -14,7 +14,11 @@ import { getFriendlyAblyErrorHint } from "./utils/errors.js"; import { coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; import { BaseFlags, CommandConfig } from "./types/cli.js"; -import { buildJsonRecord, formatWarning } from "./utils/output.js"; +import { + JsonRecordType, + buildJsonRecord, + formatWarning, +} from "./utils/output.js"; import { getCliVersion } from "./utils/version.js"; import Spaces from "@ably/spaces"; import { ChatClient } from "@ably/chat"; @@ -828,7 +832,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { * or pretty-printed for --pretty-json. */ protected formatJsonRecord( - type: "error" | "event" | "log" | "result", + type: JsonRecordType, data: Record, flags: BaseFlags, ): string { @@ -840,14 +844,30 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { data: Record, flags: BaseFlags, ): void { - this.log(this.formatJsonRecord("result", data, flags)); + this.log(this.formatJsonRecord(JsonRecordType.Result, data, flags)); } protected logJsonEvent( data: Record, flags: BaseFlags, ): void { - this.log(this.formatJsonRecord("event", data, flags)); + this.log(this.formatJsonRecord(JsonRecordType.Event, data, flags)); + } + + protected logJsonStatus( + status: string, + message: string, + flags: BaseFlags, + ): void { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonRecord( + JsonRecordType.Status, + { status, message }, + flags, + ), + ); + } } protected getClientOptions(flags: BaseFlags): Ably.ClientOptions { @@ -923,7 +943,9 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { timestamp: new Date().toISOString(), }; // Log to stderr with standard JSON envelope for consistency - this.logToStderr(this.formatJsonRecord("log", errorData, flags)); + this.logToStderr( + this.formatJsonRecord(JsonRecordType.Log, errorData, flags), + ); } // If not verbose JSON and level > 1, suppress non-error SDK logs } else { @@ -1031,7 +1053,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { timestamp: new Date().toISOString(), ...data, }; - this.log(this.formatJsonRecord("log", logEntry, flags)); + this.log(this.formatJsonRecord(JsonRecordType.Log, logEntry, flags)); } else { // Output human-readable log in normal (verbose) mode this.log(`${chalk.dim(`[${component}]`)} ${message}`); @@ -1529,7 +1551,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { if (friendlyHint) { jsonData.hint = friendlyHint; } - this.log(this.formatJsonRecord("error", jsonData, flags)); + this.log(this.formatJsonRecord(JsonRecordType.Error, jsonData, flags)); this.exit(1); } diff --git a/src/commands/spaces/cursors/get-all.ts b/src/commands/spaces/cursors/get-all.ts index 0e2d1592f..b6cc231ec 100644 --- a/src/commands/spaces/cursors/get-all.ts +++ b/src/commands/spaces/cursors/get-all.ts @@ -1,32 +1,25 @@ +import { type CursorUpdate } from "@ably/spaces"; import { Args } from "@oclif/core"; -import chalk from "chalk"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import isTestMode from "../../../utils/test-mode.js"; import { + formatCountLabel, + formatHeading, + formatIndex, formatProgress, - formatSuccess, formatResource, - formatClientId, + 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 = { - space: Args.string({ - description: "Space to get cursors from", + space_name: Args.string({ + description: "Name of the space to get cursors from", required: true, }), }; @@ -46,7 +39,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesCursorsGetAll); - const { space: spaceName } = args; + const { space_name: spaceName } = args; try { await this.initializeSpace(flags, spaceName, { @@ -54,310 +47,39 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { setupConnectionLogging: false, }); - // Get the space if (!this.shouldOutputJson(flags)) { this.log( - formatProgress(`Connecting to space: ${formatResource(spaceName)}`), + formatProgress( + `Fetching cursors for space ${formatResource(spaceName)}`, + ), ); } - // Enter the space - await this.space!.enter(); - - // Wait for space to be properly entered before fetching cursors - await new Promise((resolve, reject) => { - // Set a reasonable timeout to avoid hanging indefinitely - const timeout = setTimeout(() => { - reject(new Error("Timed out waiting for space connection")); - }, 5000); - - const checkSpaceStatus = () => { - try { - // 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 { - this.log( - formatSuccess(`Entered space: ${formatResource(spaceName)}.`), - ); - } - - resolve(); - } else if ( - this.realtimeClient!.connection.state === "failed" || - this.realtimeClient!.connection.state === "closed" || - this.realtimeClient!.connection.state === "suspended" - ) { - clearTimeout(timeout); - reject( - new Error( - `Space connection failed with state: ${this.realtimeClient!.connection.state}`, - ), - ); - } else { - // Still connecting, check again shortly - setTimeout(checkSpaceStatus, 100); - } - } catch (error) { - clearTimeout(timeout); - reject(error); - } - }; - - checkSpaceStatus(); - }); - - // Subscribe to cursor updates to ensure we receive remote cursors - let cursorUpdateReceived = false; - const cursorMap = new Map(); - - // Show initial message - if (!this.shouldOutputJson(flags)) { - const waitSeconds = isTestMode() ? "0.5" : "5"; - this.log(`Collecting cursor positions for ${waitSeconds} seconds...`); - this.log(chalk.dim("─".repeat(60))); - } - - const cursorUpdateHandler = (cursor: CursorUpdate) => { - cursorUpdateReceived = true; - - // Update the cursor map - if (cursor.connectionId) { - cursorMap.set(cursor.connectionId, cursor); - - // Show live cursor position updates - if ( - !this.shouldOutputJson(flags) && - this.shouldUseTerminalUpdates() - ) { - const clientDisplay = cursor.clientId || "Unknown"; - const x = cursor.position.x; - const y = cursor.position.y; - - this.log( - `${chalk.gray("►")} ${formatClientId(clientDisplay)}: (${chalk.yellow(x)}, ${chalk.yellow(y)})`, - ); - } - } - }; - - try { - await this.space!.cursors.subscribe("update", cursorUpdateHandler); - } catch (error) { - // If subscription fails, continue anyway - if (!this.shouldOutputJson(flags)) { - this.debug(`Cursor subscription error: ${error}`); - } - } - - // Wait for 5 seconds (or shorter in test mode) - const waitTime = isTestMode() ? 500 : 5000; - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, waitTime); - }); - - // Unsubscribe from cursor updates - this.space!.cursors.unsubscribe("update", cursorUpdateHandler); - - // Ensure connection is stable before calling getAll() - if (this.realtimeClient!.connection.state !== "connected") { - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timed out waiting for connection to stabilize")); - }, 5000); - - this.realtimeClient!.connection.once("connected", () => { - clearTimeout(timeout); - resolve(); - }); - - if (this.realtimeClient!.connection.state === "connected") { - clearTimeout(timeout); - resolve(); - } - }); - } - - // Now get all cursors (including locally cached ones) and merge with live updates - try { - 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); - } - }); - } - } 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", - ), - ); - } - } + const allCursors = await this.space!.cursors.getAll(); - const cursors = [...cursorMap.values()]; + const cursors: CursorUpdate[] = Object.values(allCursors).filter( + (cursor): cursor is CursorUpdate => cursor != null, + ); 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) => formatCursorOutput(cursor)), }, flags, ); + } else if (cursors.length === 0) { + this.logToStderr(formatWarning("No active cursors found in space.")); } else { - if (!cursorUpdateReceived && cursors.length === 0) { - this.log(chalk.dim("─".repeat(60))); - this.log( - chalk.yellow( - "No cursor updates are being sent in this space. Make sure other clients are actively setting cursor positions.", - ), - ); - return; - } - - if (cursors.length === 0) { - this.log(chalk.dim("─".repeat(60))); - this.log(chalk.yellow("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, - ); - - 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(" │"), - ); + cursors.forEach((cursor: CursorUpdate, index: number) => { + this.log(`${formatIndex(index + 1)}`); + this.log(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 f8bcf3fc1..54855a6ce 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,22 +11,10 @@ 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({ - description: "The space to set cursor in", + space_name: Args.string({ + description: "Name of the space to set cursor in", required: true, }), }; @@ -70,7 +57,6 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { private simulationIntervalId: NodeJS.Timeout | null = null; - // Override finally to ensure resources are cleaned up async finally(err: Error | undefined): Promise { if (this.simulationIntervalId) { clearInterval(this.simulationIntervalId); @@ -82,21 +68,19 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesCursorsSet); - const { space: spaceName } = args; + const { space_name: spaceName } = args; try { // Validate and parse cursor data - either x/y flags or --data JSON let cursorData: Record; if (flags.simulate) { - // For simulate mode, use provided x/y or generate random starting position const startX = flags.x ?? Math.floor(Math.random() * 1000); const startY = flags.y ?? Math.floor(Math.random() * 1000); cursorData = { position: { x: startX, y: startY }, }; - // If --data is also provided with simulate, treat it as additional cursor data if (flags.data) { try { const additionalData = JSON.parse(flags.data); @@ -111,12 +95,10 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { } } } else if (flags.x !== undefined && flags.y !== undefined) { - // Use x & y flags cursorData = { position: { x: flags.x, y: flags.y }, }; - // If --data is also provided with x/y flags, treat it as additional cursor data if (flags.data) { try { const additionalData = JSON.parse(flags.data); @@ -131,7 +113,6 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { } } } else if (flags.data) { - // Use --data JSON format try { cursorData = JSON.parse(flags.data); } catch { @@ -143,7 +124,6 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { ); } - // Validate position when using --data if ( !cursorData.position || typeof (cursorData.position as Record).x !== @@ -166,7 +146,13 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { ); } - await this.initializeSpace(flags, spaceName, { enterSpace: true }); + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Entering space")); + } + + await this.initializeSpace(flags, spaceName, { enterSpace: false }); + + await this.enterCurrentSpace(flags); const { position, data } = cursorData as { position: CursorPosition; @@ -191,30 +177,30 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult( { - cursor: cursorForOutput, - spaceName, + cursor: { + clientId: this.realtimeClient!.auth.clientId, + connectionId: this.realtimeClient!.connection.id, + position, + data: 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[] = [ + `${formatLabel("Position X")} ${position.x}`, + `${formatLabel("Position Y")} ${position.y}`, + ]; + if (data) { + lines.push(`${formatLabel("Data")} ${JSON.stringify(data)}`); + } + this.log(lines.join("\n")); } - // Decide how long to remain connected - if (flags.duration === 0) { - // Give Ably a moment to propagate the cursor update before exiting so that - // subscribers in automated tests have a chance to receive the event. - await new Promise((resolve) => setTimeout(resolve, 600)); - - // In immediate exit mode, we don't keep the process alive beyond this. - this.exit(0); - } - - // Start simulation if requested + // In simulate mode, keep running with periodic cursor updates if (flags.simulate) { this.logCliEvent( flags, @@ -231,7 +217,6 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { this.simulationIntervalId = setInterval(async () => { try { - // Generate random position within reasonable bounds const simulatedX = Math.floor(Math.random() * 1000); const simulatedY = Math.floor(Math.random() * 800); @@ -268,27 +253,22 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { }, 250); } - // Inform the user and wait until interrupted or timeout (if provided) - this.logCliEvent( - flags, - "cursor", - "waiting", - "Cursor set – waiting for further instructions", - { duration: flags.duration ?? "indefinite" }, - ); - + // Hold in both simulate and non-simulate modes if (!this.shouldOutputJson(flags)) { this.log( - flags.duration - ? `Waiting ${flags.duration}s before exiting… Press Ctrl+C to exit sooner.` - : formatListening("Cursor set."), + formatListening( + flags.simulate ? "Simulating cursor movement." : "Holding cursor.", + ), ); } - await this.waitAndTrackCleanup(flags, "cursor", flags.duration); + this.logJsonStatus( + "holding", + "Holding cursor. Press Ctrl+C to exit.", + flags, + ); - // After cleanup (handled in finally), ensure the process exits so user doesn't need multiple Ctrl-C - this.exit(0); + await this.waitAndTrackCleanup(flags, "cursor", flags.duration); } catch (error) { this.fail(error, flags, "cursorSet", { spaceName }); } diff --git a/src/commands/spaces/cursors/subscribe.ts b/src/commands/spaces/cursors/subscribe.ts index b0aba2653..83b9d4ad3 100644 --- a/src/commands/spaces/cursors/subscribe.ts +++ b/src/commands/spaces/cursors/subscribe.ts @@ -4,17 +4,18 @@ import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { formatListening, - formatResource, - formatSuccess, + formatProgress, formatTimestamp, - formatClientId, - formatLabel, } from "../../../utils/output.js"; +import { + formatCursorBlock, + formatCursorOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesCursorsSubscribe extends SpacesBaseCommand { static override args = { - space: Args.string({ - description: "Space to subscribe to cursors for", + space_name: Args.string({ + description: "Name of the space to subscribe to cursors for", required: true, }), }; @@ -38,12 +39,15 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesCursorsSubscribe); - const { space: spaceName } = args; + const { space_name: spaceName } = args; try { - await this.initializeSpace(flags, spaceName, { enterSpace: true }); + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Subscribing to cursor updates")); + } + + await this.initializeSpace(flags, spaceName, { enterSpace: false }); - // Subscribe to cursor updates this.logCliEvent( flags, "cursor", @@ -52,39 +56,30 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { ); try { - // Define the listener function 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", { @@ -93,10 +88,7 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { } }; - // Workaround for known SDK issue: cursors.subscribe() fails if the underlying ::$cursors channel is not attached await this.waitForCursorsChannelAttachment(flags); - - // Subscribe using the listener await this.space!.cursors.subscribe("update", this.listener); this.logCliEvent( @@ -111,32 +103,13 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { }); } - this.logCliEvent( - flags, - "cursor", - "listening", - "Listening for cursor updates...", - ); - - if (!this.shouldOutputJson(flags)) { - // Log the ready signal for E2E tests - this.log("Subscribing to cursor movements"); - } - - // Print success message if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), - ); this.log(formatListening("Listening for cursor movements.")); } - // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "cursor", flags.duration); } catch (error) { this.fail(error, flags, "cursorSubscribe", { spaceName }); - } finally { - // Cleanup is now handled by base class finally() method } } } diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 11ff68765..8d4bb4dc8 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -2,7 +2,6 @@ import { Flags } from "@oclif/core"; import { productApiFlags } from "../../flags.js"; import { - formatLabel, formatCountLabel, formatLimitWarning, formatResource, @@ -14,23 +13,8 @@ import { } from "../../utils/pagination.js"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; -interface SpaceMetrics { - connections?: number; - presenceConnections?: number; - presenceMembers?: number; - publishers?: number; - subscribers?: number; -} - -interface SpaceStatus { - occupancy?: { - metrics?: SpaceMetrics; - }; -} - interface SpaceItem { spaceName: string; - status?: SpaceStatus; channelId?: string; [key: string]: unknown; } @@ -80,7 +64,6 @@ export default class SpacesList extends SpacesBaseCommand { params.prefix = flags.prefix; } - // Fetch channels const channelsResponse = await rest.request( "get", "/channels", @@ -136,7 +119,6 @@ export default class SpacesList extends SpacesBaseCommand { this.logJsonResult( { spaces: spaces.map((space) => ({ - metrics: space.status?.occupancy?.metrics || {}, spaceName: space.spaceName, })), hasMore, @@ -152,38 +134,10 @@ export default class SpacesList extends SpacesBaseCommand { return; } - this.log(`Found ${formatCountLabel(spaces.length, "active space")}:`); + this.log(`Found ${formatCountLabel(spaces.length, "active space")}:\n`); spaces.forEach((space) => { this.log(`${formatResource(space.spaceName)}`); - - // Show occupancy if available - if (space.status?.occupancy?.metrics) { - const { metrics } = space.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 spaces }); if (hasMore) { @@ -192,7 +146,7 @@ export default class SpacesList extends SpacesBaseCommand { flags.limit, "spaces", ); - if (warning) this.log(warning); + if (warning) this.log(`\n${warning}`); } } } catch (error) { diff --git a/src/commands/spaces/locations/get-all.ts b/src/commands/spaces/locations/get-all.ts index 4149dbe12..293e187de 100644 --- a/src/commands/spaces/locations/get-all.ts +++ b/src/commands/spaces/locations/get-all.ts @@ -1,53 +1,22 @@ 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 = { - space: Args.string({ - description: "Space to get locations from", + space_name: Args.string({ + description: "Name of the space to get locations from", required: true, }), }; @@ -67,7 +36,7 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesLocationsGetAll); - const { space: spaceName } = args; + const { space_name: spaceName } = args; try { await this.initializeSpace(flags, spaceName, { @@ -75,55 +44,6 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { setupConnectionLogging: false, }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Connecting to space: ${formatResource(spaceName)}`), - ); - } - - await this.space!.enter(); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timed out waiting for space connection")); - }, 5000); - - const checkSpaceStatus = () => { - try { - if (this.realtimeClient!.connection.state === "connected") { - clearTimeout(timeout); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Connected to space: ${formatResource(spaceName)}.`, - ), - ); - } - - resolve(); - } else if ( - this.realtimeClient!.connection.state === "failed" || - this.realtimeClient!.connection.state === "closed" || - this.realtimeClient!.connection.state === "suspended" - ) { - clearTimeout(timeout); - reject( - new Error( - `Space connection failed with connection state: ${this.realtimeClient!.connection.state}`, - ), - ); - } else { - setTimeout(checkSpaceStatus, 100); - } - } catch (error) { - clearTimeout(timeout); - reject(error); - } - }; - - checkSpaceStatus(); - }); - if (!this.shouldOutputJson(flags)) { this.log( formatProgress( @@ -132,139 +52,47 @@ 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) { - this.log( - chalk.yellow("No locations are currently set in this space."), + } else if (entries.length === 0) { + this.logToStderr( + 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)}`); + this.log(` ${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 caaa7c9ed..6a2ae8c80 100644 --- a/src/commands/spaces/locations/set.ts +++ b/src/commands/spaces/locations/set.ts @@ -1,32 +1,25 @@ -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"; import { formatSuccess, formatListening, + formatProgress, formatResource, - formatTimestamp, - formatClientId, formatLabel, + formatClientId, } from "../../../utils/output.js"; -// Define the type for location subscription -interface LocationSubscription { - unsubscribe: () => void; -} - export default class SpacesLocationsSet extends SpacesBaseCommand { static override args = { - space: Args.string({ - description: "Space to set location in", + space_name: Args.string({ + description: "Name of the space to set location in", required: true, }), }; - static override description = "Set your location in a space"; + static override description = "Set location in a space"; static override examples = [ '$ ably spaces locations set my-space --location \'{"x":10,"y":20}\'', @@ -44,120 +37,21 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { ...durationFlag, }; - private subscription: LocationSubscription | null = null; - private locationHandler: - | ((locationUpdate: LocationsEvents.UpdateEvent) => void) - | null = null; - private isE2EMode = false; // Track if we're in E2E mode to skip cleanup - - // Override finally to ensure resources are cleaned up - async finally(err: Error | undefined): Promise { - // For E2E tests with duration=0, skip all cleanup to avoid hanging - if (this.isE2EMode) { - return; - } - - // Clear location before leaving space - if (this.space) { - try { - await Promise.race([ - this.space.locations.set(null), - new Promise((resolve) => setTimeout(resolve, 1000)), - ]); - } catch { - // Ignore cleanup errors - } - } - - return super.finally(err); - } - async run(): Promise { const { args, flags } = await this.parse(SpacesLocationsSet); - const { space: spaceName } = args; + const { space_name: spaceName } = args; - // Parse location data first const location = this.parseJsonFlag(flags.location, "location", flags); - this.logCliEvent( - flags, - "location", - "dataParsed", - "Location data parsed successfully", - { location }, - ); - - // Check if we should exit immediately (optimized path for E2E tests) - const shouldExitImmediately = - typeof flags.duration === "number" && flags.duration === 0; - - if (shouldExitImmediately) { - // Set E2E mode flag to skip cleanup in finally block - this.isE2EMode = true; - - // For E2E mode, suppress unhandled promise rejections from Ably SDK cleanup - const originalHandler = process.listeners("unhandledRejection"); - process.removeAllListeners("unhandledRejection"); - process.on("unhandledRejection", (reason, promise) => { - // Ignore connection-related errors during E2E test cleanup - const reasonStr = String(reason); - if ( - reasonStr.includes("Connection closed") || - reasonStr.includes("80017") - ) { - // Silently ignore these errors in E2E mode - return; - } - // Re-emit other errors to original handlers - originalHandler.forEach((handler) => { - if (typeof handler === "function") { - handler(reason, promise); - } - }); - }); - // Optimized path for E2E tests - minimal setup and cleanup - try { - const setupResult = await this.setupSpacesClient(flags, spaceName); - this.realtimeClient = setupResult.realtimeClient; - this.space = setupResult.space; - - // Enter the space and set location - await this.space.enter(); - this.logCliEvent(flags, "spaces", "entered", "Entered space", { - clientId: this.realtimeClient.auth.clientId, - }); - - await this.space.locations.set(location); - this.logCliEvent(flags, "location", "setSuccess", "Set location", { - location, - }); - - if (this.shouldOutputJson(flags)) { - this.logJsonResult({ location, spaceName }, flags); - } else { - this.log( - formatSuccess( - `Location set in space: ${formatResource(spaceName)}.`, - ), - ); - } - } 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); - } - // Don't call this.error() in E2E mode as it sets exit code to 1 + try { + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Entering space")); } - // For E2E tests, force immediate exit regardless of any errors - this.exit(0); - } + await this.initializeSpace(flags, spaceName, { enterSpace: false }); - // Original path for interactive use - try { - await this.initializeSpace(flags, spaceName, { enterSpace: true }); + await this.enterCurrentSpace(flags); - // Set the location this.logCliEvent(flags, "location", "setting", "Setting location", { location, }); @@ -165,102 +59,32 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { this.logCliEvent(flags, "location", "setSuccess", "Set location", { location, }); - if (!this.shouldOutputJson(flags)) { + + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ location }, flags); + } else { this.log( formatSuccess(`Location set in space: ${formatResource(spaceName)}.`), ); - } - - // Subscribe to location updates from other users - this.logCliEvent( - flags, - "location", - "subscribing", - "Watching for other location changes...", - ); - if (!this.shouldOutputJson(flags)) { this.log( - `\n${formatListening("Watching for other location changes.")}\n`, + `${formatLabel("Client ID")} ${formatClientId(this.realtimeClient!.auth.clientId)}`, ); - } - - // Store subscription handlers - 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 - const selfConnectionId = this.realtimeClient!.connection.id; - if (connectionId === selfConnectionId) { - return; - } - - const eventData = { - action: "update", - location: currentLocation, - member: { - clientId: member.clientId, - connectionId: member.connectionId, - }, - timestamp, - }; - this.logCliEvent( - flags, - "location", - "updateReceived", - "Location update received", - eventData, + this.log( + `${formatLabel("Connection ID")} ${this.realtimeClient!.connection.id}`, ); + this.log(`${formatLabel("Location")} ${JSON.stringify(location)}`); + this.log(formatListening("Holding location.")); + } - 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)}`, - ); - } - }; - - // Subscribe to updates - this.space!.locations.subscribe("update", this.locationHandler); - this.subscription = { - unsubscribe: () => { - if (this.locationHandler && this.space) { - this.space.locations.unsubscribe("update", this.locationHandler); - this.locationHandler = null; - } - }, - }; - - this.logCliEvent( - flags, - "location", - "subscribed", - "Subscribed to location updates", - ); - - this.logCliEvent( + this.logJsonStatus( + "holding", + "Holding location. Press Ctrl+C to exit.", flags, - "location", - "listening", - "Listening for location updates...", ); - // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "location", flags.duration); } catch (error) { this.fail(error, flags, "locationSet"); - } finally { - // Cleanup is now handled by base class finally() method } } } diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index 9ff8b31e5..608cd82be 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -1,44 +1,19 @@ 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 = { - space: Args.string({ - description: "Space to subscribe to locations for", + space_name: Args.string({ + description: "Name of the space to subscribe to locations for", required: true, }), }; @@ -61,122 +36,17 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesLocationsSubscribe); - const { space: spaceName } = args; - this.logCliEvent( - flags, - "subscribe.run", - "start", - `Starting spaces locations subscribe for space: ${spaceName}`, - ); + const { space_name: spaceName } = args; try { - // Always show the readiness signal first, before attempting auth if (!this.shouldOutputJson(flags)) { - this.log("Subscribing to location updates"); + this.log(formatProgress("Subscribing to location updates")); } - this.logCliEvent( - flags, - "subscribe.run", - "initialSignalLogged", - "Initial readiness signal logged.", - ); - await this.initializeSpace(flags, spaceName, { enterSpace: true }); + await this.initializeSpace(flags, spaceName, { enterSpace: false }); - // 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.log(formatListening("Listening for location updates.")); } this.logCliEvent( @@ -185,58 +55,42 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { "subscribing", "Subscribing to location updates", ); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Subscribing to location updates.")); - } - this.logCliEvent( - flags, - "location.subscribe", - "readySignalLogged", - "Final readiness signal 'Subscribing to location updates' logged.", - ); try { - // Define the location update handler 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", { @@ -245,7 +99,6 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { } }; - // Subscribe to location updates this.space!.locations.subscribe("update", locationHandler); this.logCliEvent( @@ -260,28 +113,9 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { }); } - this.logCliEvent( - flags, - "location", - "listening", - "Listening for location updates...", - ); - - // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "location", flags.duration); } catch (error) { this.fail(error, flags, "locationSubscribe", { spaceName }); - } finally { - // Wrap all cleanup in a timeout to prevent hanging - if (!this.shouldOutputJson(flags || {})) { - if (this.cleanupInProgress) { - this.log(formatSuccess("Graceful shutdown complete.")); - } else { - this.log( - formatSuccess("Duration elapsed, command finished cleanly."), - ); - } - } } } } diff --git a/src/commands/spaces/locks/acquire.ts b/src/commands/spaces/locks/acquire.ts index 146b97759..02e89663d 100644 --- a/src/commands/spaces/locks/acquire.ts +++ b/src/commands/spaces/locks/acquire.ts @@ -7,14 +7,18 @@ import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { formatSuccess, formatListening, + formatProgress, formatResource, - formatLabel, } from "../../../utils/output.js"; +import { + formatLockBlock, + formatLockOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesLocksAcquire extends SpacesBaseCommand { static override args = { - space: Args.string({ - description: "Space to acquire lock in", + space_name: Args.string({ + description: "Name of the space to acquire lock in", required: true, }), lockId: Args.string({ @@ -68,11 +72,15 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesLocksAcquire); - const { space: spaceName } = args; + const { space_name: spaceName } = args; this.lockId = args.lockId; const { lockId } = this; try { + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Entering space")); + } + await this.initializeSpace(flags, spaceName, { enterSpace: false }); // Parse lock data if provided @@ -90,11 +98,7 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { } // Enter the space first - this.logCliEvent(flags, "spaces", "entering", "Entering space..."); - await this.space!.enter(); - this.logCliEvent(flags, "spaces", "entered", "Entered space", { - clientId: this.realtimeClient!.auth.clientId, - }); + await this.enterCurrentSpace(flags); // Try to acquire the lock try { @@ -109,34 +113,20 @@ 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({ lock: 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(`\n${formatListening("Holding lock.")}`); + this.log(formatLockBlock(lock)); + this.log(formatListening("Holding lock.")); } } catch (error) { this.fail(error, flags, "lockAcquire", { @@ -144,6 +134,12 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { }); } + this.logJsonStatus( + "holding", + "Holding lock. Press Ctrl+C to exit.", + flags, + ); + this.logCliEvent( flags, "lock", diff --git a/src/commands/spaces/locks/get-all.ts b/src/commands/spaces/locks/get-all.ts index 1f0ccd097..06b7ce104 100644 --- a/src/commands/spaces/locks/get-all.ts +++ b/src/commands/spaces/locks/get-all.ts @@ -1,30 +1,25 @@ +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, + formatWarning, } 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 = { - space: Args.string({ - description: "Space to get locks from", + space_name: Args.string({ + description: "Name of the space to get locks from", required: true, }), }; @@ -44,7 +39,7 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesLocksGetAll); - const { space: spaceName } = args; + const { space_name: spaceName } = args; try { await this.initializeSpace(flags, spaceName, { @@ -52,58 +47,6 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { setupConnectionLogging: false, }); - // Get the space - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Connecting to space: ${formatResource(spaceName)}`), - ); - } - - await this.space!.enter(); - - // Wait for space to be properly entered before fetching locks - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timed out waiting for space connection")); - }, 5000); - - const checkSpaceStatus = () => { - try { - if (this.realtimeClient!.connection.state === "connected") { - clearTimeout(timeout); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Connected to space: ${formatResource(spaceName)}.`, - ), - ); - } - - resolve(); - } else if ( - this.realtimeClient!.connection.state === "failed" || - this.realtimeClient!.connection.state === "closed" || - this.realtimeClient!.connection.state === "suspended" - ) { - clearTimeout(timeout); - reject( - new Error( - `Space connection failed with connection state: ${this.realtimeClient!.connection.state}`, - ), - ); - } else { - setTimeout(checkSpaceStatus, 100); - } - } catch (error) { - clearTimeout(timeout); - reject(error); - } - }; - - checkSpaceStatus(); - }); - - // Get all locks if (!this.shouldOutputJson(flags)) { this.log( formatProgress( @@ -112,56 +55,29 @@ 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) { - this.log(chalk.yellow("No locks are currently active in this space.")); + } else if (locks.length === 0) { + this.logToStderr( + formatWarning("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)}`); + this.log(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 5148ba181..23b357adf 100644 --- a/src/commands/spaces/locks/get.ts +++ b/src/commands/spaces/locks/get.ts @@ -1,18 +1,17 @@ import { Args } from "@oclif/core"; -import chalk from "chalk"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; +import { formatResource, formatWarning } 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 = { - space: Args.string({ - description: "Space to get lock from", + space_name: Args.string({ + description: "Name of the space to get lock from", required: true, }), lockId: Args.string({ @@ -36,7 +35,7 @@ export default class SpacesLocksGet extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesLocksGet); - const { space: spaceName } = args; + const { space_name: spaceName } = args; const { lockId } = args; try { @@ -45,21 +44,16 @@ export default class SpacesLocksGet extends SpacesBaseCommand { setupConnectionLogging: false, }); - await this.space!.enter(); - if (!this.shouldOutputJson(flags)) { - this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); - } - try { const lock = await this.space!.locks.get(lockId); if (!lock) { if (this.shouldOutputJson(flags)) { - this.logJsonResult({ found: false, lockId }, flags); + this.logJsonResult({ lock: null }, flags); } else { this.log( - chalk.yellow( - `Lock ${formatResource(lockId)} not found in space ${formatResource(spaceName)}`, + formatWarning( + `Lock ${formatResource(lockId)} not found in space ${formatResource(spaceName)}.`, ), ); } @@ -68,14 +62,9 @@ export default class SpacesLocksGet extends SpacesBaseCommand { } if (this.shouldOutputJson(flags)) { - this.logJsonResult( - structuredClone(lock) as Record, - flags, - ); + this.logJsonResult({ lock: 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 ef3035b51..9f36bde83 100644 --- a/src/commands/spaces/locks/subscribe.ts +++ b/src/commands/spaces/locks/subscribe.ts @@ -1,22 +1,23 @@ 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 { - formatHeading, - formatLabel, formatListening, + formatMessageTimestamp, formatProgress, - formatResource, formatTimestamp, } from "../../../utils/output.js"; +import { + formatLockBlock, + formatLockOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesLocksSubscribe extends SpacesBaseCommand { static override args = { - space: Args.string({ - description: "Space to subscribe to locks for", + space_name: Args.string({ + description: "Name of the space to subscribe to locks for", required: true, }), }; @@ -38,177 +39,39 @@ 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; - this.logCliEvent( - flags, - "subscribe.run", - "start", - `Starting spaces locks subscribe for space: ${spaceName}`, - ); + const { space_name: spaceName } = args; try { - // Always show the readiness signal first, before attempting auth if (!this.shouldOutputJson(flags)) { - this.log("Subscribing to lock events"); + this.log(formatProgress("Subscribing to lock events")); } - this.logCliEvent( - flags, - "subscribe.run", - "initialSignalLogged", - "Initial readiness signal logged.", - ); - - await this.initializeSpace(flags, spaceName, { enterSpace: true }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Connecting to space: ${formatResource(spaceName)}`), - ); - } + await this.initializeSpace(flags, spaceName, { enterSpace: false }); - // 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, "lock", "subscribing", "Subscribing to lock events", ); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Subscribing to lock events.")); - } - this.logCliEvent( - flags, - "lock.subscribe", - "readySignalLogged", - "Final readiness signal 'Subscribing to lock events' logged.", - ); - // Define the listener function 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(formatMessageTimestamp(lock.timestamp))); + this.log(formatLockBlock(lock)); + this.log(""); } }; - // Subscribe using the stored listener await this.space!.locks.subscribe(this.listener); this.logCliEvent( @@ -218,19 +81,13 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { "Successfully subscribed to lock events", ); - this.logCliEvent( - flags, - "lock", - "listening", - "Listening for lock events...", - ); + if (!this.shouldOutputJson(flags)) { + this.log(formatListening("Listening for lock events.")); + } - // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "lock", flags.duration); } catch (error) { this.fail(error, flags, "lockSubscribe"); - } finally { - // Cleanup is now handled by base class finally() method } } } diff --git a/src/commands/spaces/members/enter.ts b/src/commands/spaces/members/enter.ts index bdaaf44f5..04bd383f9 100644 --- a/src/commands/spaces/members/enter.ts +++ b/src/commands/spaces/members/enter.ts @@ -1,4 +1,4 @@ -import type { ProfileData, SpaceMember } from "@ably/spaces"; +import type { ProfileData } from "@ably/spaces"; import { Args, Flags } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; @@ -6,17 +6,17 @@ import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { formatSuccess, formatListening, + formatProgress, formatResource, - formatTimestamp, - formatPresenceAction, - formatClientId, formatLabel, + formatClientId, } from "../../../utils/output.js"; +import { formatMemberOutput } from "../../../utils/spaces-output.js"; export default class SpacesMembersEnter extends SpacesBaseCommand { static override args = { - space: Args.string({ - description: "Space to enter", + space_name: Args.string({ + description: "Name of the space to enter", required: true, }), }; @@ -44,18 +44,11 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesMembersEnter); - const { space: spaceName } = args; - - // Keep track of the last event we've seen for each client to avoid duplicates - const lastSeenEvents = new Map< - string, - { action: string; timestamp: number } - >(); + const { space_name: spaceName } = args; try { - // Always show the readiness signal first, before attempting auth if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Entering space.")); + this.log(formatProgress("Entering space")); } await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -75,210 +68,38 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { } // Enter the space with optional profile - this.logCliEvent( - flags, - "member", - "enteringSpace", - "Attempting to enter space", - { profileData }, - ); - await this.space!.enter(profileData); - const enteredEventData = { - connectionId: this.realtimeClient!.connection.id, - profile: profileData, - spaceName, - status: "connected", - }; - this.logCliEvent( + await this.enterCurrentSpace( flags, - "member", - "enteredSpace", - "Entered space", - enteredEventData, + profileData as Record, ); if (this.shouldOutputJson(flags)) { - this.logJsonResult(enteredEventData, flags); + const self = await this.space!.members.getSelf(); + this.logJsonResult({ member: formatMemberOutput(self!) }, flags); } else { this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); + this.log( + `${formatLabel("Client ID")} ${formatClientId(this.realtimeClient!.auth.clientId)}`, + ); + this.log( + `${formatLabel("Connection ID")} ${this.realtimeClient!.connection.id}`, + ); if (profileData) { - this.log( - `${formatLabel("Profile")} ${JSON.stringify(profileData, null, 2)}`, - ); - } else { - // No profile data provided - this.logCliEvent( - flags, - "member", - "noProfileData", - "No profile data provided", - ); + this.log(`${formatLabel("Profile")} ${JSON.stringify(profileData)}`); } + this.log(formatListening("Holding presence.")); } - // Subscribe to member presence events to show other members' activities - this.logCliEvent( + this.logJsonStatus( + "holding", + "Holding presence. Press Ctrl+C to exit.", flags, - "member", - "subscribing", - "Subscribing to member updates", - ); - if (!this.shouldOutputJson(flags)) { - this.log(`\n${formatListening("Watching for other members.")}\n`); - } - - // Define the listener function - const listener = (member: SpaceMember) => { - const timestamp = new Date().toISOString(); - const now = Date.now(); - - // Determine the action from the member's lastEvent - const action = member.lastEvent?.name || "unknown"; - const clientId = member.clientId || "Unknown"; - const connectionId = member.connectionId || "Unknown"; - - // Skip self events - check connection ID - const selfConnectionId = this.realtimeClient!.connection.id; - if (member.connectionId === selfConnectionId) { - return; - } - - // Create a unique key for this client+connection combination - const clientKey = `${clientId}:${connectionId}`; - - // Check if we've seen this exact event recently (within 500ms) - // This helps avoid duplicate enter/leave events that might come through - const lastEvent = lastSeenEvents.get(clientKey); - - if ( - lastEvent && - lastEvent.action === action && - now - lastEvent.timestamp < 500 - ) { - this.logCliEvent( - flags, - "member", - "duplicateEventSkipped", - `Skipping duplicate event '${action}' for ${clientId}`, - { action, clientId }, - ); - return; // Skip duplicate events within 500ms window - } - - // Update the last seen event for this client+connection - lastSeenEvents.set(clientKey, { - action, - 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, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonEvent(memberEventData, 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", - ); - } - } - }; - - // Subscribe using the stored listener - await this.space!.members.subscribe("update", listener); - - this.logCliEvent( - flags, - "member", - "subscribed", - "Subscribed to member updates", - ); - - this.logCliEvent( - flags, - "member", - "listening", - "Listening for member updates...", ); // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "member", flags.duration); } catch (error) { this.fail(error, flags, "memberEnter"); - } finally { - if (!this.shouldOutputJson(flags || {})) { - if (this.cleanupInProgress) { - this.log(formatSuccess("Graceful shutdown complete.")); - } else { - // Normal completion without user interrupt - this.logCliEvent( - flags || {}, - "member", - "completedNormally", - "Command completed normally", - ); - } - } } } } diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 03ec36088..67a786a99 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -1,23 +1,23 @@ 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, + formatMessageTimestamp, formatProgress, formatTimestamp, - formatLabel, } from "../../../utils/output.js"; +import { + formatMemberEventBlock, + formatMemberOutput, +} from "../../../utils/spaces-output.js"; export default class SpacesMembersSubscribe extends SpacesBaseCommand { static override args = { - space: Args.string({ - description: "Space to subscribe to members for", + space_name: Args.string({ + description: "Name of the space to subscribe to members for", required: true, }), }; @@ -42,7 +42,7 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { async run(): Promise { const { args, flags } = await this.parse(SpacesMembersSubscribe); - const { space: spaceName } = args; + const { space_name: spaceName } = args; // Keep track of the last event we've seen for each client to avoid duplicates const lastSeenEvents = new Map< @@ -56,77 +56,10 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { this.log(formatProgress("Subscribing to member updates")); } - 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`); - } - } - } + await this.initializeSpace(flags, spaceName, { enterSpace: false }); if (!this.shouldOutputJson(flags)) { - this.log(`\n${formatListening("Listening for member events.")}\n`); + this.log(formatListening("Listening for member events.")); } // Subscribe to member presence events @@ -138,7 +71,6 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { ); // Define the listener function this.listener = (member: SpaceMember) => { - const timestamp = new Date().toISOString(); const now = Date.now(); // Determine the action from the member's lastEvent @@ -179,52 +111,22 @@ 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)}`, + formatTimestamp(formatMessageTimestamp(member.lastEvent.timestamp)), ); - - 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(formatMemberEventBlock(member, action)); + this.log(""); } }; @@ -249,8 +151,6 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { await this.waitAndTrackCleanup(flags, "member", flags.duration); } catch (error) { this.fail(error, flags, "memberSubscribe"); - } finally { - // Cleanup is now handled by base class finally() method } } } diff --git a/src/commands/spaces/occupancy/get.ts b/src/commands/spaces/occupancy/get.ts new file mode 100644 index 000000000..97b36a7ff --- /dev/null +++ b/src/commands/spaces/occupancy/get.ts @@ -0,0 +1,102 @@ +import { Args } from "@oclif/core"; + +import { AblyBaseCommand } from "../../../base-command.js"; +import { productApiFlags } from "../../../flags.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; + +const SPACE_CHANNEL_TAG = "::$space"; + +interface OccupancyMetrics { + connections: number; + presenceConnections: number; + presenceMembers: number; + presenceSubscribers: number; + publishers: number; + subscribers: number; +} + +export default class SpacesOccupancyGet extends AblyBaseCommand { + static override args = { + space_name: Args.string({ + description: "Space name to get occupancy for", + required: true, + }), + }; + + static override description = "Get current occupancy metrics for a space"; + + static override examples = [ + "$ ably spaces occupancy get my-space", + "$ ably spaces occupancy get my-space --json", + "$ ably spaces occupancy get my-space --pretty-json", + ]; + + static override flags = { + ...productApiFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesOccupancyGet); + + try { + const client = await this.createAblyRestClient(flags); + if (!client) return; + + const spaceName = args.space_name; + const channelName = `${spaceName}${SPACE_CHANNEL_TAG}`; + + const channelDetails = await client.request( + "get", + `/channels/${encodeURIComponent(channelName)}`, + 2, + { occupancy: "metrics" }, + null, + ); + + const occupancyData = channelDetails.items?.[0] || {}; + const occupancyMetrics: OccupancyMetrics = occupancyData.status?.occupancy + ?.metrics || { + connections: 0, + presenceConnections: 0, + presenceMembers: 0, + presenceSubscribers: 0, + publishers: 0, + subscribers: 0, + }; + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + occupancy: { + spaceName, + metrics: occupancyMetrics, + }, + }, + flags, + ); + } else { + this.log(`Occupancy metrics for space ${formatResource(spaceName)}:\n`); + 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, "spacesOccupancyGet", { + spaceName: args.space_name, + }); + } + } +} diff --git a/src/commands/spaces/occupancy/index.ts b/src/commands/spaces/occupancy/index.ts new file mode 100644 index 000000000..ce60e8ba4 --- /dev/null +++ b/src/commands/spaces/occupancy/index.ts @@ -0,0 +1,14 @@ +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class SpacesOccupancyIndex extends BaseTopicCommand { + protected topicName = "spaces:occupancy"; + protected commandGroup = "Spaces occupancy"; + + static override description = + "Commands for working with occupancy in Ably Spaces"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> get my-space", + "<%= config.bin %> <%= command.id %> subscribe my-space", + ]; +} diff --git a/src/commands/spaces/occupancy/subscribe.ts b/src/commands/spaces/occupancy/subscribe.ts new file mode 100644 index 000000000..f8de63b38 --- /dev/null +++ b/src/commands/spaces/occupancy/subscribe.ts @@ -0,0 +1,157 @@ +import { Args } from "@oclif/core"; +import * as Ably from "ably"; + +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"; + +const SPACE_CHANNEL_TAG = "::$space"; + +export default class SpacesOccupancySubscribe extends AblyBaseCommand { + static override args = { + space_name: Args.string({ + description: "Space name to subscribe to occupancy events", + required: true, + }), + }; + + static override description = "Subscribe to occupancy events on a space"; + + static override examples = [ + "$ ably spaces occupancy subscribe my-space", + "$ ably spaces occupancy subscribe my-space --json", + "$ ably spaces occupancy subscribe my-space --duration 30", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + ...durationFlag, + }; + + private client: Ably.Realtime | null = null; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesOccupancySubscribe); + let channel: Ably.RealtimeChannel | null = null; + + try { + this.client = await this.createAblyRealtimeClient(flags); + if (!this.client) return; + + const spaceName = args.space_name; + const channelName = `${spaceName}${SPACE_CHANNEL_TAG}`; + const occupancyEventName = "[meta]occupancy"; + + // Get channel with occupancy metrics enabled + channel = this.client.channels.get(channelName, { + params: { occupancy: "metrics" }, + }); + + // Set up connection and channel state logging + this.setupConnectionStateLogging(this.client, flags, { + includeUserFriendlyMessages: true, + }); + this.setupChannelStateLogging(channel, flags, { + includeUserFriendlyMessages: true, + }); + + this.logCliEvent( + flags, + "spacesOccupancy", + "subscribing", + `Subscribing to occupancy events on space: ${spaceName}`, + { spaceName, channel: channelName }, + ); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Subscribing to occupancy events on space: ${formatResource(spaceName)}`, + ), + ); + } + + await channel.subscribe(occupancyEventName, (message: Ably.Message) => { + const timestamp = formatMessageTimestamp(message.timestamp); + const event = { + spaceName, + event: occupancyEventName, + data: message.data, + timestamp, + }; + + this.logCliEvent( + flags, + "spacesOccupancy", + "occupancyUpdate", + `Occupancy update received for space ${spaceName}`, + event, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent({ occupancy: event }, flags); + } else { + this.log(formatTimestamp(timestamp)); + this.log(`${formatLabel("Space")} ${formatResource(spaceName)}`); + 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 space: ${formatResource(spaceName)}.`, + ), + ); + this.log(formatListening("Listening for occupancy events.")); + } + + this.logCliEvent( + flags, + "spacesOccupancy", + "listening", + "Listening for occupancy events. Press Ctrl+C to exit.", + ); + + await this.waitAndTrackCleanup(flags, "spacesOccupancy", flags.duration); + } catch (error) { + this.fail(error, flags, "spacesOccupancySubscribe", { + spaceName: args.space_name, + }); + } + } +} diff --git a/src/services/stats-display.ts b/src/services/stats-display.ts index a9366fcb1..9ceef04c0 100644 --- a/src/services/stats-display.ts +++ b/src/services/stats-display.ts @@ -1,6 +1,10 @@ import chalk from "chalk"; import isTestMode from "../utils/test-mode.js"; -import { buildJsonRecord, formatJsonString } from "../utils/output.js"; +import { + JsonRecordType, + buildJsonRecord, + formatJsonString, +} from "../utils/output.js"; export interface StatsDisplayOptions { command?: string; @@ -94,7 +98,7 @@ export class StatsDisplay { public display(stats: StatsDisplayData): void { if (this.options.json) { const record = buildJsonRecord( - this.options.live ? "event" : "result", + this.options.live ? JsonRecordType.Event : JsonRecordType.Result, this.options.command || "unknown", stats as Record, ); diff --git a/src/spaces-base-command.ts b/src/spaces-base-command.ts index dbaf06898..396ebba80 100644 --- a/src/spaces-base-command.ts +++ b/src/spaces-base-command.ts @@ -31,69 +31,124 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { protected spaces: Spaces | null = null; protected realtimeClient: Ably.Realtime | null = null; protected parsedFlags: BaseFlags = {}; + protected hasEnteredSpace = false; + + protected markAsEntered(): void { + this.hasEnteredSpace = true; + } + + /** + * Enter the space and mark as entered in one call. + * Always use this instead of calling space.enter() + markAsEntered() separately + * to ensure cleanup (space.leave()) is never accidentally skipped. + */ + protected async enterCurrentSpace( + flags: BaseFlags, + profileData?: Record, + ): Promise { + this.logCliEvent(flags, "spaces", "entering", "Entering space..."); + await this.space!.enter(profileData); + this.markAsEntered(); + this.logCliEvent(flags, "spaces", "entered", "Entered space", { + clientId: this.realtimeClient!.auth.clientId, + }); + } async finally(error: Error | undefined): Promise { - // Always clean up connections + // The Spaces SDK subscribes to channel.presence internally (in the Space + // constructor) but provides no dispose/cleanup method. When the connection + // closes, the SDK's internal handlers receive errors that surface as + // unhandled rejections crashing the process. We suppress these during + // cleanup, matching the ChatBaseCommand pattern of tolerating SDK errors + // during teardown. + const suppressedErrors: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + suppressedErrors.push(reason); + this.debug(`Suppressed unhandled rejection during cleanup: ${reason}`); + }; + + process.on("unhandledRejection", onUnhandledRejection); + try { - // Unsubscribe from all namespace listeners if (this.space !== null) { + // Unsubscribe from all namespace listeners try { await this.space.members.unsubscribe(); await this.space.locks.unsubscribe(); this.space.locations.unsubscribe(); this.space.cursors.unsubscribe(); } catch (error) { - // Log but don't throw unsubscribe errors - if (!this.shouldOutputJson(this.parsedFlags)) { - this.debug(`Namespace unsubscribe error: ${error}`); + this.debug(`Namespace unsubscribe error: ${error}`); + } + + // Unsubscribe the SDK's internal presence handler on the space channel. + // This removes the Spaces SDK's listener but cannot fully prevent + // errors from the Ably SDK's own channel state transitions during close. + // NOTE: Accesses @ably/spaces internal `Space.channel` property (verified + // against @ably/spaces v0.4.0). The SDK has no public dispose() method. + // If this breaks after a Spaces SDK upgrade, check the Space class for + // a renamed/removed `channel` property or a new cleanup API. + try { + const spaceChannel = ( + this.space as unknown as { channel: Ably.RealtimeChannel } + ).channel; + if (spaceChannel) { + spaceChannel.presence.unsubscribe(); } + } catch (error) { + this.debug(`Space channel presence unsubscribe error: ${error}`); } - await this.space!.leave(); - // Wait a bit after leaving space - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Spaces maintains an internal map of members which have timeouts. This keeps node alive. - // This is a workaround to hold off until those timeouts are cleared by the client, as otherwise - // we'll get unhandled presence rejections as the connection closes. - await new Promise((resolve) => { - let intervalId: ReturnType; - const maxWaitMs = 10000; // 10 second timeout - const startTime = Date.now(); - const getAll = async () => { - // Avoid waiting forever - if (Date.now() - startTime > maxWaitMs) { - clearInterval(intervalId); - this.debug("Timed out waiting for space members to clear"); - resolve(); - return; - } - - const members = await this.space!.members.getAll(); - if (members.filter((member) => !member.isConnected).length === 0) { - clearInterval(intervalId); - this.debug("space members cleared"); - resolve(); - } else { - this.debug( - `waiting for spaces members to clear, ${members.length} remaining`, - ); - } - }; - - intervalId = setInterval(() => { - getAll(); - }, 1000); - }); + // Only leave and wait for member cleanup if we actually entered the space + if (this.hasEnteredSpace) { + await this.space!.leave(); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Spaces maintains an internal map of members which have timeouts. This keeps node alive. + // This is a workaround to hold off until those timeouts are cleared by the client, as otherwise + // we'll get unhandled presence rejections as the connection closes. + await new Promise((resolve) => { + let intervalId: ReturnType; + const maxWaitMs = 10000; + const startTime = Date.now(); + const getAll = async () => { + if (Date.now() - startTime > maxWaitMs) { + clearInterval(intervalId); + this.debug("Timed out waiting for space members to clear"); + resolve(); + return; + } + + const members = await this.space!.members.getAll(); + if ( + members.filter((member) => !member.isConnected).length === 0 + ) { + clearInterval(intervalId); + this.debug("space members cleared"); + resolve(); + } else { + this.debug( + `waiting for spaces members to clear, ${members.length} remaining`, + ); + } + }; + + intervalId = setInterval(() => { + getAll(); + }, 1000); + }); + } } } catch (error) { - // Log but don't throw cleanup errors - if (!this.shouldOutputJson(this.parsedFlags)) { - this.debug(`Space leave error: ${error}`); - } + this.debug(`Space cleanup error: ${error}`); } await super.finally(error); + + // Allow a tick for any remaining SDK-internal rejections to fire + // before removing the suppression handler. + await new Promise((resolve) => setTimeout(resolve, 50)); + process.removeListener("unhandledRejection", onUnhandledRejection); } // Ensure we have the spaces client and its related authentication resources @@ -245,11 +300,7 @@ export abstract class SpacesBaseCommand extends AblyBaseCommand { this.parsedFlags = flags; if (enterSpace) { - this.logCliEvent(flags, "spaces", "entering", "Entering space..."); - await this.space!.enter(); - this.logCliEvent(flags, "spaces", "entered", "Entered space", { - clientId: this.realtimeClient!.auth.clientId, - }); + await this.enterCurrentSpace(flags); } } diff --git a/src/utils/output.ts b/src/utils/output.ts index e700379b1..7a213feb3 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -360,7 +360,13 @@ export function formatAnnotationsOutput( return blocks.join("\n\n"); } -export type JsonRecordType = "error" | "event" | "log" | "result"; +export enum JsonRecordType { + Error = "error", + Event = "event", + Log = "log", + Result = "result", + Status = "status", +} /** * Build a typed JSON envelope record. @@ -378,7 +384,7 @@ export function buildJsonRecord( // Strip reserved envelope keys from data to prevent payload collisions. // Also strip `success` from error records — errors are always success: false. const reservedKeys = new Set(["type", "command"]); - if (type === "error") { + if (type === JsonRecordType.Error) { reservedKeys.add("success"); } const safeData = Object.fromEntries( @@ -387,8 +393,8 @@ export function buildJsonRecord( return { type, command, - ...(type === "result" || type === "error" - ? { success: type !== "error" } + ...(type === JsonRecordType.Result || type === JsonRecordType.Error + ? { success: type !== JsonRecordType.Error } : {}), ...safeData, }; diff --git a/src/utils/spaces-output.ts b/src/utils/spaces-output.ts new file mode 100644 index 000000000..c54a698d8 --- /dev/null +++ b/src/utils/spaces-output.ts @@ -0,0 +1,227 @@ +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 X")} ${cursor.position.x}`, + `${indent}${formatLabel("Position Y")} ${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 d48a0ae89..f1780b9a5 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, }, @@ -212,7 +212,7 @@ describe("Spaces E2E Tests", () => { }; const setLocationResult = await runBackgroundProcessAndGetOutput( - `bin/run.js spaces locations set ${testSpaceId} --location '${JSON.stringify(locationData)}' --client-id ${client2Id} --duration 0`, + `bin/run.js spaces locations set ${testSpaceId} --location '${JSON.stringify(locationData)}' --client-id ${client2Id} --duration 5`, process.env.CI ? 15000 : 15000, // Timeout for the command ); @@ -252,7 +252,7 @@ describe("Spaces E2E Tests", () => { }; const updateLocationResult = await runBackgroundProcessAndGetOutput( - `bin/run.js spaces locations set ${testSpaceId} --location '${JSON.stringify(newLocationData)}' --client-id ${client2Id} --duration 0`, + `bin/run.js spaces locations set ${testSpaceId} --location '${JSON.stringify(newLocationData)}' --client-id ${client2Id} --duration 5`, process.env.CI ? 15000 : 15000, // Increased local timeout ); @@ -349,7 +349,7 @@ describe("Spaces E2E Tests", () => { `bin/run.js spaces cursors subscribe ${testSpaceId} --client-id ${client1Id} --duration 20`, outputPath, { - readySignal: "Subscribing to cursor movements", + readySignal: "Subscribing to cursor updates", timeoutMs: 60000, // Increased timeout significantly retryCount: 3, }, @@ -370,7 +370,7 @@ describe("Spaces E2E Tests", () => { let currentOutput = await readProcessOutput(outputPath); if ( !currentOutput.includes("Entered space:") && - !currentOutput.includes("Subscribing to cursor movements") + !currentOutput.includes("Subscribing to cursor updates") ) { // The cursor subscribe process might have failed, let's skip this test shouldSkipCursorTest = true; @@ -400,7 +400,7 @@ describe("Spaces E2E Tests", () => { if ( output.includes(client2Id) && - output.includes("position:") && + output.includes("Position X:") && output.includes("TestUser2") && output.includes("#ff0000") ) { diff --git a/test/helpers/mock-ably-spaces.ts b/test/helpers/mock-ably-spaces.ts index 22641ca19..661d0a3aa 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 f57744606..919a6b885 100644 --- a/test/unit/commands/spaces/cursors/get-all.test.ts +++ b/test/unit/commands/spaces/cursors/get-all.test.ts @@ -26,42 +26,38 @@ 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"], import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); - expect(space.cursors.subscribe).toHaveBeenCalledWith( - "update", - expect.any(Function), - ); + expect(space.enter).not.toHaveBeenCalled(); 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 +94,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 +116,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 +125,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"], @@ -138,7 +133,6 @@ describe("spaces:cursors:get-all command", () => { ); // Verify cleanup was performed - expect(space.leave).toHaveBeenCalled(); expect(realtimeMock.close).toHaveBeenCalled(); }); }); diff --git a/test/unit/commands/spaces/cursors/set.test.ts b/test/unit/commands/spaces/cursors/set.test.ts index 82b6863ba..55c841577 100644 --- a/test/unit/commands/spaces/cursors/set.test.ts +++ b/test/unit/commands/spaces/cursors/set.test.ts @@ -91,6 +91,10 @@ describe("spaces:cursors:set command", () => { ); expect(stdout).toContain("Set cursor"); expect(stdout).toContain("test-space"); + expect(stdout).toContain("Position X:"); + expect(stdout).toContain("100"); + expect(stdout).toContain("Position Y:"); + expect(stdout).toContain("200"); }); it("should set cursor from --data with position object", async () => { @@ -114,6 +118,19 @@ describe("spaces:cursors:set command", () => { ); }); + it("should display hold message in non-simulate mode", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:cursors:set", "test-space", "--x", "100", "--y", "200"], + import.meta.url, + ); + + expect(stdout).toContain("Holding cursor."); + expect(stdout).toContain("Press Ctrl+C to exit."); + }); + it("should merge --data with --x/--y as additional cursor data", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); @@ -142,7 +159,7 @@ describe("spaces:cursors:set command", () => { }); describe("JSON output", () => { - it("should output JSON on success", async () => { + it("should output JSON result and hold status", async () => { const spacesMock = getMockAblySpaces(); spacesMock._getSpace("test-space"); @@ -165,8 +182,17 @@ 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("cursor"); + const cursor = result!.cursor as Record; + expect(cursor).toHaveProperty("position"); + expect(cursor.position).toEqual({ x: 100, y: 200 }); + expect(cursor).toHaveProperty("clientId"); + expect(cursor).toHaveProperty("connectionId"); + + const status = records.find((r) => r.type === "status"); + expect(status).toBeDefined(); + expect(status).toHaveProperty("status", "holding"); + expect(status!.message).toContain("Holding cursor"); }); }); diff --git a/test/unit/commands/spaces/cursors/subscribe.test.ts b/test/unit/commands/spaces/cursors/subscribe.test.ts index c53854c08..16a6a05a9 100644 --- a/test/unit/commands/spaces/cursors/subscribe.test.ts +++ b/test/unit/commands/spaces/cursors/subscribe.test.ts @@ -26,14 +26,14 @@ 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"], import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.toHaveBeenCalled(); expect(space.cursors.subscribe).toHaveBeenCalledWith( "update", expect.any(Function), @@ -43,15 +43,15 @@ 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"], import.meta.url, ); - expect(stdout).toContain("Subscribing"); - expect(stdout).toContain("test-space"); + expect(stdout).toContain("Subscribing to cursor updates"); + expect(stdout).toContain("Listening for cursor movements"); }); }); @@ -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"; @@ -141,10 +144,10 @@ describe("spaces:cursors:subscribe command", () => { }); describe("error handling", () => { - it("should handle space entry failure", async () => { + it("should handle subscribe failure", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.enter.mockRejectedValue(new Error("Connection failed")); + space.cursors.subscribe.mockRejectedValue(new Error("Connection failed")); const { error } = await runCommand( ["spaces:cursors:subscribe", "test-space"], diff --git a/test/unit/commands/spaces/list.test.ts b/test/unit/commands/spaces/list.test.ts index 9488a6f99..72345867f 100644 --- a/test/unit/commands/spaces/list.test.ts +++ b/test/unit/commands/spaces/list.test.ts @@ -53,92 +53,94 @@ describe("spaces:list command", () => { }); }); - it("should filter to ::$space channels only", async () => { - const { stdout } = await runCommand(["spaces:list"], import.meta.url); + standardHelpTests("spaces:list", import.meta.url); + standardArgValidationTests("spaces:list", import.meta.url); + standardFlagTests("spaces:list", import.meta.url, ["--json"]); - expect(stdout).toContain("space1"); - expect(stdout).toContain("space2"); - expect(stdout).not.toContain("regular-channel"); - }); + describe("functionality", () => { + it("should list active spaces successfully", async () => { + const { stdout, error } = await runCommand( + ["spaces:list"], + import.meta.url, + ); - it("should deduplicate spaces from sub-channels", async () => { - const { stdout } = await runCommand(["spaces:list"], import.meta.url); + expect(error).toBeUndefined(); + expect(stdout).toContain("space1"); + expect(stdout).toContain("space2"); + expect(stdout).not.toContain("regular-channel"); + }); - // space1 has 2 sub-channels but should appear only once - expect(stdout).toContain("2"); - expect(stdout).toContain("active spaces"); - }); + it("should filter to ::$space channels only", async () => { + const { stdout } = await runCommand(["spaces:list"], import.meta.url); - it("should extract space name from channel ID", async () => { - const { stdout } = await runCommand(["spaces:list"], import.meta.url); + expect(stdout).toContain("space1"); + expect(stdout).toContain("space2"); + expect(stdout).not.toContain("regular-channel"); + }); - expect(stdout).toContain("space1"); - expect(stdout).not.toContain("::$space::$locks"); - }); + it("should deduplicate spaces from sub-channels", async () => { + const { stdout } = await runCommand(["spaces:list"], import.meta.url); - it("should respect --limit flag", async () => { - const { stdout } = await runCommand( - ["spaces:list", "--limit", "1"], - import.meta.url, - ); + // space1 has 2 sub-channels but should appear only once + expect(stdout).toContain("2"); + expect(stdout).toContain("active spaces"); + }); - expect(stdout).toContain("space1"); - expect(stdout).not.toContain("space2"); - }); + it("should extract space name from channel ID", async () => { + const { stdout } = await runCommand(["spaces:list"], import.meta.url); - it("should forward --prefix flag to API", async () => { - const mock = getMockAblyRest(); + expect(stdout).toContain("space1"); + expect(stdout).not.toContain("::$space::$locks"); + }); - await runCommand(["spaces:list", "--prefix", "space1"], import.meta.url); + it("should show 'No active spaces' on empty response", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + ...createMockPaginatedResult([]), + statusCode: 200, + }); - expect(mock.request).toHaveBeenCalledOnce(); - expect(mock.request.mock.calls[0][3]).toHaveProperty("prefix", "space1"); - }); + const { stdout } = await runCommand(["spaces:list"], import.meta.url); - it("should show 'No active spaces' on empty response", async () => { - const mock = getMockAblyRest(); - mock.request.mockResolvedValue({ - ...createMockPaginatedResult([]), - statusCode: 200, + expect(stdout).toContain("No active spaces found"); }); - const { stdout } = await runCommand(["spaces:list"], import.meta.url); - - expect(stdout).toContain("No active spaces found"); - }); - - it("should output JSON with correct structure", async () => { - const { stdout } = await runCommand( - ["spaces:list", "--json"], - import.meta.url, - ); + it("should output JSON with correct structure", async () => { + const { stdout } = await runCommand( + ["spaces:list", "--json"], + import.meta.url, + ); - const json = JSON.parse(stdout); - expect(json).toHaveProperty("spaces"); - expect(json).toHaveProperty("total"); - expect(json).toHaveProperty("hasMore"); - expect(json).toHaveProperty("success", true); - expect(json.spaces).toBeInstanceOf(Array); - expect(json.spaces.length).toBe(2); - expect(json.spaces[0]).toHaveProperty("spaceName", "space1"); - expect(json.spaces[1]).toHaveProperty("spaceName", "space2"); + const json = JSON.parse(stdout); + expect(json).toHaveProperty("spaces"); + expect(json).toHaveProperty("total"); + expect(json).toHaveProperty("hasMore"); + expect(json).toHaveProperty("success", true); + expect(json.spaces).toBeInstanceOf(Array); + expect(json.spaces.length).toBe(2); + expect(json.spaces[0]).toHaveProperty("spaceName", "space1"); + expect(json.spaces[1]).toHaveProperty("spaceName", "space2"); + }); }); - standardHelpTests("spaces:list", import.meta.url); - standardArgValidationTests("spaces:list", import.meta.url); - standardFlagTests("spaces:list", import.meta.url, ["--json"]); - - describe("functionality", () => { - it("should list active spaces successfully", async () => { - const { stdout, error } = await runCommand( - ["spaces:list"], + describe("flags", () => { + it("should respect --limit flag", async () => { + const { stdout } = await runCommand( + ["spaces:list", "--limit", "1"], import.meta.url, ); - expect(error).toBeUndefined(); expect(stdout).toContain("space1"); - expect(stdout).toContain("space2"); - expect(stdout).not.toContain("regular-channel"); + expect(stdout).not.toContain("space2"); + }); + + it("should forward --prefix flag to API", async () => { + const mock = getMockAblyRest(); + + await runCommand(["spaces:list", "--prefix", "space1"], import.meta.url); + + expect(mock.request).toHaveBeenCalledOnce(); + expect(mock.request.mock.calls[0][3]).toHaveProperty("prefix", "space1"); }); }); diff --git a/test/unit/commands/spaces/locations/get-all.test.ts b/test/unit/commands/spaces/locations/get-all.test.ts index 7856093f6..75353b449 100644 --- a/test/unit/commands/spaces/locations/get-all.test.ts +++ b/test/unit/commands/spaces/locations/get-all.test.ts @@ -26,34 +26,26 @@ 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"], import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.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,22 @@ 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", { + x: 100, + y: 200, + }); }); 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 47148a6ea..028f69c95 100644 --- a/test/unit/commands/spaces/locations/set.test.ts +++ b/test/unit/commands/spaces/locations/set.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblySpaces } from "../../../../helpers/mock-ably-spaces.js"; import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardFlagTests, @@ -87,10 +88,23 @@ describe("spaces:locations:set command", () => { expect(stdout).toContain("Location set"); expect(stdout).toContain("test-space"); }); + + it("should display hold message", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:locations:set", "test-space", "--location", '{"x":1}'], + import.meta.url, + ); + + expect(stdout).toContain("Holding location."); + expect(stdout).toContain("Press Ctrl+C to exit."); + }); }); describe("JSON output", () => { - it("should output JSON on success with --duration 0", async () => { + it("should output JSON result and hold status", async () => { const spacesMock = getMockAblySpaces(); spacesMock._getSpace("test-space"); @@ -103,16 +117,20 @@ describe("spaces:locations:set command", () => { "--location", JSON.stringify(location), "--json", - "--duration", - "0", ], import.meta.url, ); - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.location).toEqual(location); - expect(result.spaceName).toBe("test-space"); + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result!.success).toBe(true); + expect(result!.location).toEqual(location); + + const status = records.find((r) => r.type === "status"); + expect(status).toBeDefined(); + expect(status).toHaveProperty("status", "holding"); + expect(status!.message).toContain("Holding location"); }); it("should output JSON error on invalid location", async () => { @@ -130,9 +148,11 @@ describe("spaces:locations:set command", () => { // fail calls exit(1) which throws in test mode expect(error).toBeDefined(); - const result = JSON.parse(stdout); - expect(result.success).toBe(false); - expect(result.error).toContain("Invalid location JSON"); + const records = parseNdjsonLines(stdout); + const errorRecord = records.find((r) => r.type === "error"); + expect(errorRecord).toBeDefined(); + expect(errorRecord!.success).toBe(false); + expect(errorRecord!.error).toContain("Invalid location JSON"); }); }); diff --git a/test/unit/commands/spaces/locations/subscribe.test.ts b/test/unit/commands/spaces/locations/subscribe.test.ts index 10167d06c..20f931996 100644 --- a/test/unit/commands/spaces/locations/subscribe.test.ts +++ b/test/unit/commands/spaces/locations/subscribe.test.ts @@ -26,24 +26,22 @@ 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"], import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.toHaveBeenCalled(); expect(space.locations.subscribe).toHaveBeenCalledWith( "update", expect.any(Function), ); }); - 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 9be20aa3b..24b7275c2 100644 --- a/test/unit/commands/spaces/locks/acquire.test.ts +++ b/test/unit/commands/spaces/locks/acquire.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblySpaces } from "../../../../helpers/mock-ably-spaces.js"; import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -27,8 +28,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 +58,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( @@ -94,14 +112,23 @@ describe("spaces:locks:acquire command", () => { expect(error?.message).toContain("Lock already held"); }); - it("should output JSON on success", async () => { + it("should output JSON result and hold status", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); 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( @@ -109,11 +136,24 @@ describe("spaces:locks:acquire command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("lock"); - expect(result.lock).toHaveProperty("lockId", "my-lock"); - expect(result.lock).toHaveProperty("status", "locked"); + const lock = result!.lock as Record; + expect(lock).toHaveProperty("id", "my-lock"); + expect(lock).toHaveProperty("status", "locked"); + expect(lock).toHaveProperty("member"); + const member = lock.member as Record; + expect(member).toHaveProperty("clientId", "mock-client-id"); + expect(lock).toHaveProperty("attributes", null); + expect(lock).toHaveProperty("reason", null); + + const status = records.find((r) => r.type === "status"); + expect(status).toBeDefined(); + expect(status).toHaveProperty("status", "holding"); + expect(status!.message).toContain("Holding lock"); }); }); diff --git a/test/unit/commands/spaces/locks/get-all.test.ts b/test/unit/commands/spaces/locks/get-all.test.ts index ea3d8391b..966cbe0ca 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, }, ]); @@ -39,9 +49,9 @@ describe("spaces:locks:get-all command", () => { import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.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 c8054e5d6..55e91deca 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( @@ -62,7 +72,7 @@ describe("spaces:locks:get command", () => { import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.toHaveBeenCalled(); expect(space.locks.get).toHaveBeenCalledWith("my-lock"); expect(stdout).toContain("my-lock"); }); @@ -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( @@ -82,14 +102,16 @@ describe("spaces:locks:get command", () => { ); const records = parseNdjsonLines(stdout); - const resultRecord = records.find( - (r) => r.type === "result" && r.id === "my-lock", - ); + const resultRecord = records.find((r) => r.type === "result" && r.lock); 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!.lock).toHaveProperty("id", "my-lock"); + expect(resultRecord!.lock).toHaveProperty("status", "locked"); + expect(resultRecord!.lock).toHaveProperty("member"); + expect(resultRecord!.lock).toHaveProperty("attributes", null); + expect(resultRecord!.lock).toHaveProperty("reason", null); }); it("should handle lock not found", async () => { @@ -103,7 +125,10 @@ 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"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.lock).toBeNull(); }); }); diff --git a/test/unit/commands/spaces/locks/subscribe.test.ts b/test/unit/commands/spaces/locks/subscribe.test.ts index 77476dc5d..290921603 100644 --- a/test/unit/commands/spaces/locks/subscribe.test.ts +++ b/test/unit/commands/spaces/locks/subscribe.test.ts @@ -26,21 +26,19 @@ 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"], import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.toHaveBeenCalled(); 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,21 @@ 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(); + expect(error?.message).toContain("Failed to subscribe"); }); }); }); diff --git a/test/unit/commands/spaces/members/enter.test.ts b/test/unit/commands/spaces/members/enter.test.ts index eccb28cd2..4609a98d1 100644 --- a/test/unit/commands/spaces/members/enter.test.ts +++ b/test/unit/commands/spaces/members/enter.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblySpaces } from "../../../../helpers/mock-ably-spaces.js"; import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -62,22 +63,8 @@ describe("spaces:members:enter command", () => { }); }); - describe("member event handling", () => { - it("should subscribe to member update events", async () => { - const spacesMock = getMockAblySpaces(); - const space = spacesMock._getSpace("test-space"); - - await runCommand(["spaces:members:enter", "test-space"], import.meta.url); - - expect(space.members.subscribe).toHaveBeenCalledWith( - "update", - expect.any(Function), - ); - }); - }); - describe("JSON output", () => { - it("should output JSON on success", async () => { + it("should output JSON result and hold status", async () => { const spacesMock = getMockAblySpaces(); spacesMock._getSpace("test-space"); @@ -86,10 +73,22 @@ describe("spaces:members:enter command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result.success).toBe(true); - expect(result.spaceName).toBe("test-space"); - expect(result.status).toBe("connected"); + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result!.success).toBe(true); + expect(result!.member).toBeDefined(); + const member = result!.member as Record; + expect(member).toHaveProperty("clientId", "mock-client-id"); + expect(member).toHaveProperty("connectionId", "mock-connection-id"); + expect(member).toHaveProperty("isConnected", true); + expect(member).toHaveProperty("location", null); + expect(member).toHaveProperty("lastEvent"); + + const status = records.find((r) => r.type === "status"); + expect(status).toBeDefined(); + expect(status).toHaveProperty("status", "holding"); + expect(status!.message).toContain("Holding presence"); }); 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 11ccbbf54..602ace389 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,14 +62,13 @@ 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"], import.meta.url, ); - expect(space.enter).toHaveBeenCalled(); + expect(space.enter).not.toHaveBeenCalled(); expect(space.members.subscribe).toHaveBeenCalledWith( "update", expect.any(Function), @@ -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"); }); }); @@ -133,7 +113,7 @@ describe("spaces:members:subscribe command", () => { it("should handle errors gracefully", async () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - space.enter.mockRejectedValue(new Error("Connection failed")); + space.members.subscribe.mockRejectedValue(new Error("Connection failed")); const { error } = await runCommand( ["spaces:members:subscribe", "test-space"], diff --git a/test/unit/commands/spaces/occupancy/get.test.ts b/test/unit/commands/spaces/occupancy/get.test.ts new file mode 100644 index 000000000..29b7aba96 --- /dev/null +++ b/test/unit/commands/spaces/occupancy/get.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("spaces:occupancy:get command", () => { + beforeEach(() => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + status: { + occupancy: { + metrics: { + connections: 10, + presenceConnections: 5, + presenceMembers: 8, + presenceSubscribers: 4, + publishers: 2, + subscribers: 6, + }, + }, + }, + }, + ], + }); + }); + + standardHelpTests("spaces:occupancy:get", import.meta.url); + + standardArgValidationTests("spaces:occupancy:get", import.meta.url, { + requiredArgs: ["test-space"], + }); + + standardFlagTests("spaces:occupancy:get", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should retrieve and display occupancy data for a space", async () => { + const mock = getMockAblyRest(); + + const { stdout } = await runCommand( + ["spaces:occupancy:get", "test-space"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledOnce(); + const [method, path, version, params, body] = mock.request.mock.calls[0]; + expect(method).toBe("get"); + expect(path).toBe( + `/channels/${encodeURIComponent("test-space::$space")}`, + ); + expect(version).toBe(2); + expect(params).toEqual({ occupancy: "metrics" }); + expect(body).toBeNull(); + + expect(stdout).toContain("test-space"); + 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 output JSON envelope with spaceName and metrics", async () => { + const { stdout } = await runCommand( + ["spaces:occupancy:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "spaces:occupancy:get"); + expect(result).toHaveProperty("success", true); + const occupancy = (result as Record).occupancy as Record< + string, + unknown + >; + expect(occupancy).toBeDefined(); + expect(occupancy).toHaveProperty("spaceName", "test-space"); + expect(occupancy).toHaveProperty("metrics"); + expect(occupancy.metrics).toMatchObject({ + connections: 10, + presenceConnections: 5, + presenceMembers: 8, + presenceSubscribers: 4, + publishers: 2, + subscribers: 6, + }); + }); + + it("should handle empty occupancy metrics", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [{}], + }); + + const { stdout } = await runCommand( + ["spaces:occupancy:get", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Connections: 0"); + expect(stdout).toContain("Publishers: 0"); + expect(stdout).toContain("Subscribers: 0"); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + mock.request.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["spaces:occupancy:get", "test-space"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/spaces/occupancy/subscribe.test.ts b/test/unit/commands/spaces/occupancy/subscribe.test.ts new file mode 100644 index 000000000..dac6daf81 --- /dev/null +++ b/test/unit/commands/spaces/occupancy/subscribe.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { captureJsonLogs } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("spaces:occupancy:subscribe command", () => { + beforeEach(() => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-space::$space"); + + 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(); + } + }); + }); + + standardHelpTests("spaces:occupancy:subscribe", import.meta.url); + + standardArgValidationTests("spaces:occupancy:subscribe", import.meta.url, { + requiredArgs: ["test-space"], + }); + + standardFlagTests("spaces:occupancy:subscribe", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should subscribe and show initial messages", async () => { + const { stdout } = await runCommand( + ["spaces:occupancy:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to occupancy events on space"); + expect(stdout).toContain("test-space"); + }); + + it("should get channel with mapped name and occupancy params", async () => { + const mock = getMockAblyRealtime(); + + await runCommand( + ["spaces:occupancy:subscribe", "test-space"], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-space::$space", { + params: { occupancy: "metrics" }, + }); + }); + + it("should subscribe to [meta]occupancy event", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-space::$space"); + + await runCommand( + ["spaces:occupancy:subscribe", "test-space"], + import.meta.url, + ); + + expect(channel.subscribe).toHaveBeenCalledWith( + "[meta]occupancy", + expect.any(Function), + ); + }); + + it("should emit JSON events with correct envelope", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-space::$space"); + + 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; + } + }, + ); + + const records = await captureJsonLogs(async () => { + const commandPromise = runCommand( + ["spaces:occupancy:subscribe", "test-space", "--json"], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(occupancyCallback).not.toBeNull(); + }); + + occupancyCallback!({ + data: { connections: 5, publishers: 2 }, + timestamp: Date.now(), + }); + + await commandPromise; + }); + + const events = records.filter( + (r) => r.type === "event" && (r as Record).occupancy, + ); + expect(events.length).toBeGreaterThan(0); + expect(events[0]).toHaveProperty("type", "event"); + expect(events[0]).toHaveProperty("command", "spaces:occupancy:subscribe"); + const occupancy = (events[0] as Record) + .occupancy as Record; + expect(occupancy).toHaveProperty("spaceName", "test-space"); + }); + }); + + describe("error handling", () => { + it("should handle subscription errors gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-space::$space"); + + channel.subscribe.mockImplementation(() => { + throw new Error("Subscription failed"); + }); + + const { error } = await runCommand( + ["spaces:occupancy:subscribe", "test-space"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Subscription failed/i); + }); + + it("should handle capability errors", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-space::$space"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["spaces:occupancy:subscribe", "test-space"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + }); + }); +}); diff --git a/test/unit/commands/spaces/spaces.test.ts b/test/unit/commands/spaces/spaces.test.ts index 413f856af..0ddfd0326 100644 --- a/test/unit/commands/spaces/spaces.test.ts +++ b/test/unit/commands/spaces/spaces.test.ts @@ -78,7 +78,7 @@ describe("spaces commands", () => { expect(stdout).toContain("Enter a space"); expect(stdout).toContain("USAGE"); - expect(stdout).toContain("SPACE"); + expect(stdout).toContain("SPACE_NAME"); }); it("should enter a space successfully", async () => { @@ -125,7 +125,7 @@ describe("spaces commands", () => { expect(stdout).toContain("Subscribe to member"); expect(stdout).toContain("USAGE"); - expect(stdout).toContain("SPACE"); + expect(stdout).toContain("SPACE_NAME"); }); it("should subscribe and display member events with action and client info", async () => { @@ -175,9 +175,9 @@ describe("spaces commands", () => { import.meta.url, ); - expect(stdout).toContain("Set your location"); + expect(stdout).toContain("Set location"); expect(stdout).toContain("USAGE"); - expect(stdout).toContain("SPACE"); + expect(stdout).toContain("SPACE_NAME"); }); it("should set location with --location flag", async () => { @@ -208,7 +208,7 @@ describe("spaces commands", () => { expect(stdout).toContain("Acquire a lock"); expect(stdout).toContain("USAGE"); - expect(stdout).toContain("SPACE"); + expect(stdout).toContain("SPACE_NAME"); expect(stdout).toContain("LOCKID"); }); @@ -243,7 +243,7 @@ describe("spaces commands", () => { expect(stdout).toContain("cursor"); expect(stdout).toContain("USAGE"); - expect(stdout).toContain("SPACE"); + expect(stdout).toContain("SPACE_NAME"); }); it("should set cursor with x and y flags", async () => {