diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index e5b5fa57..00000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,262 +0,0 @@ -# CLAUDE.md - Ably CLI Main Project - -## โš ๏ธ STOP - MANDATORY WORKFLOW - -**DO NOT SKIP - Run these IN ORDER for EVERY change:** - -```bash -pnpm prepare # 1. Build + update manifest/README -pnpm exec eslint . # 2. Lint (MUST be 0 errors) -pnpm test:unit # 3. Test (at minimum) - # 4. Update docs if needed -``` - -**If you skip these steps, the work is NOT complete.** - -## ๐Ÿ—‚๏ธ Project Context - -**First, verify where you are:** -```bash -pwd # Should show: .../cli/main or similar -ls -la .cursor/rules/ # Should show .mdc files -``` - -**This project (`main`) is the Ably CLI npm package.** It may be: -1. Part of a larger workspace (with sibling `cli-terminal-server`) -2. Opened standalone - -## ๐Ÿ“š Essential Reading - -**MANDATORY - Read these .cursor/rules files before ANY work:** - -1. `Workflow.mdc` - The mandatory development workflow -2. `Development.mdc` - Coding standards -3. `AI-Assistance.mdc` - How to work with this codebase - -**Finding the rules:** -```bash -# From this project root: -cat .cursor/rules/Workflow.mdc -cat .cursor/rules/Development.mdc -cat .cursor/rules/AI-Assistance.mdc -``` - -## โŒ Common Pitfalls - DO NOT DO THESE - -1. **Skip tests** - Only skip with documented valid reason -2. **Use `_` prefix for unused variables** - Remove the code instead -3. **Leave debug code** - Remove ALL console.log, DEBUG_TEST, test-*.mjs -4. **Use `// eslint-disable`** - Fix the root cause -5. **Remove tests without asking** - Always get permission first -6. **NODE_ENV** - To check if the CLI is in test mode, use the `isTestMode()` helper function. -7. **`process.exit`** - When creating a command, use `this.exit()` for consistent test mode handling. -8. **`console.log` / `console.error`** - In commands, always use `this.log()` (stdout) and `this.logToStderr()` (stderr). `console.*` bypasses oclif and can't be captured by tests. - -## โœ… Correct Practices - -### When Tests Fail -```typescript -// โŒ WRONG -it.skip('test name', () => { - -// โœ… CORRECT - Document why -it.skip('should handle Ctrl+C on empty prompt', function(done) { - // SKIPPED: This test is flaky in non-TTY environments - // The readline SIGINT handler doesn't work properly with piped stdio -``` - -### When Linting Fails -```typescript -// โŒ WRONG - Workaround -let _unusedVar = getValue(); - -// โœ… CORRECT - Remove unused code -// Delete the line entirely -``` - -### Debug Cleanup Checklist -```bash -# After debugging, ALWAYS check: -find . -name "test-*.mjs" -type f -grep -r "DEBUG_TEST" src/ test/ -grep -r "console.log" src/ # Except legitimate output -``` - -## ๐Ÿš€ Quick Reference - -```bash -# Full validation -pnpm validate - -# Run specific test -pnpm test test/unit/commands/interactive.test.ts - -# Lint specific file -pnpm exec eslint src/commands/interactive.ts - -# Dev mode -pnpm dev -``` - -## ๐Ÿ“ Project Structure - -``` -. -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ commands/ # CLI commands (oclif) -โ”‚ โ”œโ”€โ”€ services/ # Business logic -โ”‚ โ”œโ”€โ”€ utils/ # Utilities -โ”‚ โ””โ”€โ”€ base-command.ts -โ”œโ”€โ”€ test/ -โ”‚ โ”œโ”€โ”€ unit/ # Fast, mocked -โ”‚ โ”œโ”€โ”€ integration/ # Real execution -โ”‚ โ””โ”€โ”€ e2e/ # Full scenarios -โ”œโ”€โ”€ .cursor/ -โ”‚ โ””โ”€โ”€ rules/ # MUST READ -โ””โ”€โ”€ package.json # Scripts defined here -``` - -## ๐Ÿ—๏ธ Flag Architecture - -Flags are NOT global. Each command explicitly declares only the flags it needs via composable flag sets defined in `src/flags.ts`: - -- **`coreGlobalFlags`** โ€” `--verbose`, `--json`, `--pretty-json`, `--web-cli-help` (hidden) (on every command via `AblyBaseCommand.globalFlags`) -- **`productApiFlags`** โ€” core + hidden product API flags (`port`, `tlsPort`, `tls`). Use for commands that talk to the Ably product API. -- **`controlApiFlags`** โ€” core + hidden control API flags (`control-host`, `dashboard-host`). Use for commands that talk to the Control API. -- **`clientIdFlag`** โ€” `--client-id`. Add to any command that creates a realtime connection (publish, subscribe, presence enter/subscribe, spaces enter/get/subscribe, locks acquire/get/subscribe, cursors set/get/subscribe, locations set/get/subscribe, etc.). The rule: if the command calls `space.enter()`, creates a realtime client, or joins a channel, include `clientIdFlag`. Do NOT add globally. -- **`timeRangeFlags`** โ€” `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). -- **`endpointFlag`** โ€” `--endpoint`. Hidden, only on `accounts login` and `accounts switch`. - -**When creating a new command:** -```typescript -// Product API command (channels, spaces, rooms, etc.) -import { productApiFlags, clientIdFlag } from "../../flags.js"; -static override flags = { - ...productApiFlags, - ...clientIdFlag, // Only if command needs client identity - // command-specific flags... -}; - -// Control API command (apps, keys, queues, etc.) -import { controlApiFlags } from "../../flags.js"; -static override flags = { - ...controlApiFlags, - // command-specific flags... -}; -``` - -**Auth** is managed via `ably login` (stored config). Environment variables override stored config for CI, scripting, or testing: -- `ABLY_API_KEY`, `ABLY_TOKEN`, `ABLY_ACCESS_TOKEN` - -Do NOT add `--api-key`, `--token`, or `--access-token` flags to commands. - -## ๐Ÿงช Writing Tests - -**Auth in tests โ€” do NOT use CLI flags (`--api-key`, `--token`, `--access-token`):** -**Unit tests** โ€” Auth is provided automatically by `MockConfigManager` (see `test/helpers/mock-config-manager.ts`). No env vars needed. Only set `ABLY_API_KEY` when specifically testing env var override behavior. -```typescript -// โŒ WRONG โ€” don't pass auth flags -runCommand(["channels", "publish", "my-channel", "hello", "--api-key", key]); - -// โœ… CORRECT โ€” MockConfigManager provides auth automatically -runCommand(["channels", "publish", "my-channel", "hello"]); - -// โœ… CORRECT โ€” use getMockConfigManager() to access test auth values -import { getMockConfigManager } from "../../helpers/mock-config-manager.js"; -const mockConfig = getMockConfigManager(); -const apiKey = mockConfig.getApiKey()!; -const appId = mockConfig.getCurrentAppId()!; -``` - -**E2E tests** โ€” Commands run as real subprocesses, so auth must be passed via env vars: -```typescript -// โœ… CORRECT โ€” pass auth via env vars for E2E -runCommand(["channels", "publish", "my-channel", "hello"], { - env: { ABLY_API_KEY: key }, -}); - -// โœ… CORRECT โ€” spawn with env vars -spawn("node", [cliPath, "channels", "subscribe", "my-channel"], { - env: { ...process.env, ABLY_API_KEY: key }, -}); - -// โœ… Control API commands use ABLY_ACCESS_TOKEN -runCommand(["stats", "account"], { - env: { ABLY_ACCESS_TOKEN: token }, -}); -``` - -**Test structure:** -- `test/unit/` โ€” Fast, mocked tests. Auth via `MockConfigManager` (automatic). Only set `ABLY_API_KEY` env var when testing env var override behavior. -- `test/e2e/` โ€” Full scenarios against real Ably. Auth via env vars (`ABLY_API_KEY`, `ABLY_ACCESS_TOKEN`). -- Helpers in `test/helpers/` โ€” `runCommand()`, `runLongRunningBackgroundProcess()`, `e2e-test-helper.ts`, `mock-config-manager.ts`. - -**Running tests:** -```bash -pnpm test:unit # All unit tests -pnpm test:e2e # All E2E tests -pnpm test test/unit/commands/foo.test.ts # Specific test -``` - -## ๐Ÿ” Related Projects - -If this is part of a workspace, there may be: -- `../cli-terminal-server/` - WebSocket terminal server -- `../` - Workspace root with its own `.claude/CLAUDE.md` - -But focus on THIS project unless specifically asked about others. - -## CLI Output & Flag Conventions - -### Output patterns (use helpers from src/utils/output.ts) -- **Progress**: `progress("Attaching to channel: " + resource(name))` โ€” no color on action text, `progress()` appends `...` automatically. Never manually write `"Doing something..."` โ€” always use `progress("Doing something")`. -- **Success**: `success("Message published to channel " + resource(name) + ".")` โ€” green โœ“, **must** end with `.` (not `!`). Never use `chalk.green("โœ“ ...")` directly โ€” always use the `success()` helper. -- **Listening**: `listening("Listening for messages.")` โ€” dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `success()` call โ€” use a separate `listening()` call. -- **Resource names**: Always `resource(name)` (cyan), never quoted โ€” including in `logCliEvent` messages. -- **Timestamps**: `formatTimestamp(ts)` โ€” dim `[timestamp]` for event streams. Exported as `formatTimestamp` to avoid clashing with local `timestamp` variables. -- **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 errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode โ€” always emit a JSON error object or use `this.jsonError()`. -- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` ``. Consistent across all history commands (channels, logs, connection-lifecycle, push). - -### Additional output patterns (direct chalk, not helpers) -- **Secondary labels**: `chalk.dim("Label:")` โ€” for field names in structured output (e.g., `${chalk.dim("Profile:")} ${value}`) -- **Client IDs**: `chalk.blue(clientId)` โ€” for user/client identifiers in events -- **Event types**: `chalk.yellow(eventType)` โ€” for action/event type labels -- **Warnings**: `chalk.yellow("Warning: ...")` โ€” for non-fatal warnings -- **Errors**: Use `this.error()` (oclif standard) for fatal errors, not `this.log(chalk.red(...))` -- **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'` - -### Help output theme -Help colors are configured via `package.json > oclif.theme` (oclif's built-in theme system). The custom help class in `src/help.ts` also applies colors to COMMANDS sections it builds manually. Color scheme: -- **Commands/bin/topics**: cyan โ€” primary actionable items -- **Flags/args**: whiteBright โ€” bright but secondary to commands -- **Section headers**: bold โ€” USAGE, FLAGS, COMMANDS, etc. -- **Command summaries**: whiteBright โ€” descriptions in command listings -- **Defaults/options**: yellow โ€” `[default: N]`, `` -- **Required flags**: red โ€” `(required)` marker -- **`$` prompt**: green โ€” shell prompt in examples/usage -- **Flag separator**: dim โ€” comma between `-c, --count` - -When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers, `chalk.cyan()` for command names, and `chalk.whiteBright()` for descriptions to stay consistent. - -### Flag conventions -- All flags kebab-case: `--my-flag` (never camelCase) -- `--app`: `"The app ID or name (defaults to current app)"` (for commands with `resolveAppId`), `"The app ID (defaults to current app)"` (for commands without) -- `--limit`: `"Maximum number of results to return (default: N)"` -- `--duration`: `"Automatically exit after N seconds"`, alias `-D` -- `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"` -- `--start`/`--end`: Use `timeRangeFlags` from `src/flags.ts` and parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). -- `--direction`: `"Direction of message retrieval (default: backwards)"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`. -- Channels use "publish", Rooms use "send" (matches SDK terminology) -- Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`) - -## โœ“ Before Marking Complete - -- [ ] `pnpm prepare` succeeds -- [ ] `pnpm exec eslint .` shows 0 errors -- [ ] `pnpm test:unit` passes -- [ ] No debug artifacts remain -- [ ] Docs updated if needed -- [ ] Followed oclif patterns - -**Quality matters. This is read by developers.** \ No newline at end of file diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 00000000..be77ac83 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/.cursor/rules/.cursorindexingignore b/.cursor/rules/.cursorindexingignore deleted file mode 100644 index 68347b34..00000000 --- a/.cursor/rules/.cursorindexingignore +++ /dev/null @@ -1,2 +0,0 @@ -# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references -.specstory/** diff --git a/.cursor/rules/AI-Assistance.mdc b/.cursor/rules/AI-Assistance.mdc deleted file mode 100644 index a010a62b..00000000 --- a/.cursor/rules/AI-Assistance.mdc +++ /dev/null @@ -1,198 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# AI Assistance Guidelines - -This document provides guidance for AI assistants working with the Ably CLI codebase. These guidelines are agent-agnostic and designed to help any AI assistant provide high-quality contributions. - -## Approaching This Codebase - -### Understanding the Project - -1. **Start with Key Documentation**: - - [Project Structure](mdc:docs/Project-Structure.md) - - [Testing Strategy](mdc:docs/Testing.md) - - [Mandatory Workflow](mdc:.cursor/rules/Workflow.mdc) - -2. **Recognize Context Boundaries**: - - The CLI is focused on Ably services - do not suggest features outside this scope - - Always check if a feature already exists before suggesting implementation - -### Common Patterns - -1. **Command Structure**: - ```typescript - // Commands should follow the oclif structure - export default class MyCommand extends Command { - static description = 'Clear description of what the command does' - - static examples = [ - '<%= config.bin %> commandName', - '<%= config.bin %> commandName --some-flag', - ] - - static flags = { - // Flag definitions - } - - static args = { - // Argument definitions - } - - async run() { - const {args, flags} = await this.parse(MyCommand) - // Implementation - } - } - ``` - -2. **Authorization Pattern**: - ```typescript - // Command should support auth via the standard auth helper - import { getAuth } from '../../helpers/auth' - - // Then in the run method: - const auth = await getAuth(flags) - const client = new Ably.Rest(auth) - ``` - -3. **Error Handling**: - ```typescript - try { - // Operation that might fail - } catch (error) { - if (error.code === 40100) { - this.error('Authentication failed. Check your API key or token.') - } else { - this.error(`Failed: ${error.message}`) - } - } - ``` - -### Anti-Patterns to Avoid - -1. **โŒ Direct HTTP Requests for Data Plane APIs** - ```typescript - // WRONG: Using fetch directly for data plane operations - const response = await fetch('https://rest.ably.io/channels/...') - ``` - - **โœ… Correct Approach** - ```typescript - // CORRECT: Using the Ably SDK - const client = new Ably.Rest(auth) - const channel = client.channels.get('channel-name') - ``` - -2. **โŒ Inconsistent Command Structure** - ```typescript - // WRONG: Non-standard command structure - export default class { - async execute(args) { - // Implementation - } - } - ``` - -3. **โŒ Hardcoded Endpoints** - ```typescript - // WRONG: Hardcoded endpoint URLs - const url = 'https://control.ably.net/v1/apps' - ``` - - **โœ… Correct Approach** - ```typescript - // CORRECT: Using the config - const controlHost = flags.controlHost || config.controlHost || 'control.ably.net' - const url = `https://${controlHost}/v1/apps` - ``` - -## CLI Output & Flag Conventions - -### Output patterns (use helpers from src/utils/output.ts) -- **Progress**: `progress("Attaching to channel: " + resource(name))` โ€” no color on action text, ends with `...` -- **Success**: `success("Message published to channel " + resource(name) + ".")` โ€” green โœ“, ends with `.` -- **Listening**: `listening("Listening for messages.")` โ€” dim, includes "Press Ctrl+C to exit." -- **Resource names**: Always `resource(name)` (cyan), never quoted -- **Timestamps**: `formatTimestamp(ts)` โ€” dim `[timestamp]` for event streams. Import as `formatTimestamp` to avoid clashing with local `timestamp` variables. - -### Additional output patterns (direct chalk, not helpers) -- **Secondary labels**: `chalk.dim("Label:")` โ€” for field names in structured output (e.g., `${chalk.dim("Profile:")} ${value}`) -- **Client IDs**: `chalk.blue(clientId)` โ€” for user/client identifiers in events -- **Event types**: `chalk.yellow(eventType)` โ€” for action/event type labels -- **Warnings**: `chalk.yellow("Warning: ...")` โ€” for non-fatal warnings -- **Errors**: Use `this.error()` (oclif standard) for fatal errors, not `this.log(chalk.red(...))` -- **JSON errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode. -- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` `` -- **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'` - -### Flag conventions -- All flags kebab-case: `--my-flag` (never camelCase) -- `--app`: `"The app ID or name (defaults to current app)"` (for commands with `resolveAppId`), `"The app ID (defaults to current app)"` (for commands without) -- `--limit`: `"Maximum number of results to return (default: N)"` -- `--duration`: `"Automatically exit after N seconds"`, alias `-D` -- `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"` -- `--start`/`--end`: Use `timeRangeFlags` from `src/flags.ts` and parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). -- `--direction`: `"Direction of message retrieval (default: backwards)"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`. -- Channels use "publish", Rooms use "send" (matches SDK terminology) -- Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`) - -## Effective Testing - -1. **Mocking Ably SDK Example**: - ```typescript - // Example of proper Ably SDK mocking - import * as sinon from 'sinon' - - describe('my command', () => { - let mockChannel: any - let mockClient: any - - beforeEach(() => { - mockChannel = { - publish: sinon.stub().resolves(), - presence: { - get: sinon.stub().resolves([]), - enter: sinon.stub().resolves(), - }, - } - - mockClient = { - channels: { - get: sinon.stub().returns(mockChannel), - }, - close: sinon.stub().resolves(), - } - - // Important: stub the constructor, not just methods - sinon.stub(Ably, 'Rest').returns(mockClient) - }) - - afterEach(() => { - sinon.restore() // Don't forget cleanup! - }) - }) - ``` - -2. **Testing Resource Cleanup**: - Always ensure resources are properly closed/cleaned up, especially in tests: - ```typescript - // Example of proper resource cleanup - afterEach(async () => { - await client.close() - sinon.restore() - }) - ``` - -## Contributing Workflow - -All contributions must follow the [Mandatory Workflow](mdc:.cursor/rules/Workflow.mdc): - -1. **Build**: Run `pnpm prepare` -2. **Lint**: Run `pnpm exec eslint .` -3. **Test**: Run appropriate tests -4. **Document**: Update relevant docs - -See documentation in `.cursor/rules/Workflow.mdc` for details. diff --git a/.cursor/rules/Ably.mdc b/.cursor/rules/Ably.mdc deleted file mode 100644 index 5d2555e2..00000000 --- a/.cursor/rules/Ably.mdc +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Ably knowledge - -- When in doubt about how Ably works, please refer to the Ably docs online at https://ably.com/docs so that you can provide idiomatic suggestions in the CLI. -- The docs you need to pay attention to are: - - Ably Pub/Sub guides https://ably.com/docs/basics and API references at https://ably.com/docs/api/realtime-sdk (note at this time, some of the API references are out of date, so please use https://ably.com/docs/sdk/js/v2.0/ when this is mentioned on the page) - - Ably Chat guides at https://ably.com/docs/chat and API references at https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/modules/chat-js.html - - Ably Spaces guies at https://ably.com/docs/spaces and API references at https://sdk.ably.com/builds/ably/spaces/main/typedoc/index.html - - Control API docs at https://ably.com/docs/account/control-api and Control API reference at https://ably.com/docs/api/control-api. - - Broader Ably platform docs at https://ably.com/docs/platform. -- The CLI will always use the relevant product Ably SDKs for all data plane commands. In the rare instances that an API exists in the data plane REST API, but there is no corresponding method in the SDK, then the request method in the Pub/Sub SDK should be used to communicate with that REST API. -- The Control API does not have an official SDK, so raw HTTP requests will be used by the CLI. \ No newline at end of file diff --git a/.cursor/rules/Development.mdc b/.cursor/rules/Development.mdc deleted file mode 100644 index b7fef5b5..00000000 --- a/.cursor/rules/Development.mdc +++ /dev/null @@ -1,48 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Node & TypeScript - -- Use TypeScript and follow best practice naming conventions -- ESLint is used to ensure all code adheres best practices, ensure you check that all code passes the linter before concluding your work. You can use `pnpm exec eslint -- [filepath]` to run the linter on single files. -- This CLI is using the very popular and well maintained oclif framework, see https://github.com/oclif/oclif and https://oclif.io/. Please read the docs and follow best practice. - -## Mandatory Steps for All Code Changes - -**Before considering any task complete, you MUST perform the following steps:** - -1. **Run Build:** Execute `pnpm prepare` to ensure the TypeScript code compiles successfully, the `oclif.manifest.json` is updated, and the `README.md` reflects any command changes. -2. **Run Linter:** Execute `pnpm exec eslint .` (or `pnpm exec eslint -- [filepath]` for specific files) and fix all reported issues. Code MUST pass lint checks. -3. **Run Tests:** Execute relevant tests locally (e.g., using `pnpm test`) to verify that your changes haven't introduced regressions. Ensure all affected tests pass. For new features, add appropriate unit, integration, or end-to-end tests. -4. **Update Documentation:** Update all relevant documentation, including: - * `README.md` (especially the command list if commands were changed/added). - * Files within the `docs/` directory (`Project-Structure.md`, `Testing.md` etc.) if the changes affect structure or testing procedures. - * Files within the `.cursor/rules/` directory if the changes impact development guidelines, Ably usage, or project structure. - -**Failure to complete these steps means the work is not finished.** - -## Builds, linting and tests - -- After code changes are made, you must run a build to check changes made with `pnpm build`. This will ensure TypeScript is compiled. If you want the `oclif.manifest.json` file to be updated and the `README.md` to be regenerated to reflect the command structure too, use `pnpm prepare`. -- All code must pass lint checks with `pnpm exec eslint` -- Code changes may break tests. We do not want to wait until the changes are committed and pushed via git triggering a CI build which runes all tests. Following code changes, run tests locally that could be affected by your code changes to help short-circuit the process of finding out builds have broken tests. - -## Libraries and dependencies - -- When installing libraries, do not rely on your own training data. Your training data has a cut-off date. You're probably not aware of all of the latest developments in the JavaScript and TypeScript world. This means that instead of picking a version manually (via updating the package.json file), you should use a script to install the latest version of a library. This will ensure you're always using the latest version. - ```sh - pnpm add -D @typescript-eslint/eslint-plugin - ``` -- Avoid unnecessary dependencies, keep the dependencies to a pragmatic minimum i.e. don't write code when libraries exist to solve these common problems, equally don't go mad installing libraries for every problem. -- This project uses `pnpm` for package management as opposed to `npm` or `yard`. - -# Code quality - -- This CLI is used by software developers who will look at the code written in this CLI. The quality of the code matters to Ably and to them. The target audience for this CLI are experienced developers and care about code quality as much as we do. Always put in extra effort to ensure that code produced would be objectively considered best in class and contemporary. - -# Core Maintenance - -- Whenever new features or changes are made, you must look at the .cursor/rules and /docs and update them to reflect any changes made. -- When new features are added or changes made, tests must be updated or added, and it is your responsibility to ensure the tests pass before deeming your work complete. diff --git a/.cursor/rules/Project.mdc b/.cursor/rules/Project.mdc deleted file mode 100644 index 19b0b20f..00000000 --- a/.cursor/rules/Project.mdc +++ /dev/null @@ -1,11 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Project rules - -The following documents must be read and considered before making any changes to this project. - -1. [Testing.md](mdc:docs/Testing.md) - details the strategy and approach for testing the CLI, ensuring its quality, reliability, and adherence to the defined requirements. -2. [Project-Structure.md](mdc:docs/Project-Structure.md) - provides an overview of the project's structure and purpose of each folder/file. diff --git a/.cursor/rules/Workflow.mdc b/.cursor/rules/Workflow.mdc deleted file mode 100644 index 3e892eb6..00000000 --- a/.cursor/rules/Workflow.mdc +++ /dev/null @@ -1,34 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Mandatory Development Workflow - -**IMPORTANT:** Before considering any task complete, you **MUST** perform and verify the following steps in order. Failure to complete these steps means the work is **not finished**. - -1. **Run Build:** - * Execute `pnpm prepare`. - * **Purpose:** Ensures TypeScript compiles, `oclif.manifest.json` is updated, and `README.md` reflects command changes. - * **Verification:** Check for build errors in the output. Ensure `oclif.manifest.json` and `README.md` changes (if any) are sensible. - -2. **Run Linter:** - * Execute `pnpm exec eslint .` (or `pnpm exec eslint -- [filepath]` for specific files). - * **Purpose:** Ensures code adheres to project style and best practices. Catches potential errors. - * **Verification:** Ensure the command exits with code 0 (no errors). **Do not** use workarounds like prefixing unused variables with `_`; address the root cause (e.g., remove unused code). See `development.mdc` for more details on linting philosophy. - -3. **Run Tests:** - * Execute relevant tests locally (e.g., `pnpm test:unit`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm test:playwright`, or specific file paths like `pnpm test test/unit/commands/some-command.test.ts`). - * **Purpose:** Verifies changes haven't introduced regressions and that new features work as expected. - * **Verification:** Ensure all executed tests pass. If tests fail, debug them (see `docs/Debugging.md`). Add or update tests (Unit, Integration, E2E) as appropriate for your changes. Refer to `docs/Testing.md` for the testing strategy. - -4. **Update Documentation & Rules:** - * Review and update all relevant documentation and rule files based on your changes. - * **Checklist:** - * `README.md`: Especially the command list if commands were added/changed (often handled by `pnpm prepare`). - * `docs/*.md`: Any file impacted by the changes (e.g., `Project-Structure.md`, `Testing.md`). - * `.cursor/rules/*.mdc`: Any rule file impacted by changes to development practices, Ably usage, project structure, etc. - * **Purpose:** Keeps project knowledge current for both humans and AI agents. - * **Verification:** Manually confirm that documentation accurately reflects the implemented changes. - -**Only after successfully completing ALL four steps should you consider your task complete.** diff --git a/AGENTS.md b/AGENTS.md index 0b5b2502..f07a41ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,248 @@ -# AI Assistant: START HERE +# AGENTS.md - Ably CLI -You MUST read and follow: `.claude/CLAUDE.md` +## Mandatory Workflow + +**Run these IN ORDER for EVERY change:** -Run this command first: ```bash -cat .claude/CLAUDE.md +pnpm prepare # 1. Build + update manifest/README +pnpm exec eslint . # 2. Lint (MUST be 0 errors) +pnpm test:unit # 3. Test (at minimum) + # 4. Update docs if needed +``` + +**If you skip these steps, the work is NOT complete.** + +## Project Context + +This is the Ably CLI npm package (`@ably/cli`), built with the [oclif framework](https://oclif.io/). + ``` +. +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ commands/ # CLI commands (oclif) +โ”‚ โ”œโ”€โ”€ services/ # Business logic +โ”‚ โ”œโ”€โ”€ utils/ # Utilities +โ”‚ โ””โ”€โ”€ base-command.ts +โ”œโ”€โ”€ test/ +โ”‚ โ”œโ”€โ”€ unit/ # Fast, mocked +โ”‚ โ”œโ”€โ”€ integration/ # Multi-component, mocked external services +โ”‚ โ”œโ”€โ”€ e2e/ # Full scenarios against real Ably +โ”‚ โ””โ”€โ”€ helpers/ # runCommand(), MockConfigManager, etc. +โ”œโ”€โ”€ docs/ # Project docs (Testing.md, Project-Structure.md, etc.) +โ””โ”€โ”€ package.json # Scripts defined here +``` + +## Common Pitfalls - DO NOT DO THESE + +1. **Skip tests** - Only skip with documented valid reason +2. **Use `_` prefix for unused variables** - Remove the code instead +3. **Leave debug code** - Remove ALL console.log, DEBUG_TEST, test-*.mjs +4. **Use `// eslint-disable`** - Fix the root cause +5. **Remove tests without asking** - Always get permission first +6. **NODE_ENV** - To check if the CLI is in test mode, use the `isTestMode()` helper function. +7. **`process.exit`** - When creating a command, use `this.exit()` for consistent test mode handling. +8. **`console.log` / `console.error`** - In commands, always use `this.log()` (stdout) and `this.logToStderr()` (stderr). `console.*` bypasses oclif and can't be captured by tests. + +## Correct Practices + +### When Tests Fail +```typescript +// WRONG +it.skip('test name', () => { + +// CORRECT - Document why +it.skip('should handle Ctrl+C on empty prompt', function(done) { + // SKIPPED: This test is flaky in non-TTY environments + // The readline SIGINT handler doesn't work properly with piped stdio +``` + +### When Linting Fails +```typescript +// WRONG - Workaround +let _unusedVar = getValue(); + +// CORRECT - Remove unused code +// Delete the line entirely +``` + +### Debug Cleanup Checklist +```bash +# After debugging, ALWAYS check: +find . -name "test-*.mjs" -type f +grep -r "DEBUG_TEST" src/ test/ +grep -r "console.log" src/ # Except legitimate output +``` + +## Quick Reference + +```bash +# Full validation +pnpm validate + +# Run specific test +pnpm test test/unit/commands/interactive.test.ts + +# Lint specific file +pnpm exec eslint src/commands/interactive.ts + +# Dev mode +pnpm dev +``` + +## Flag Architecture + +Flags are NOT global. Each command explicitly declares only the flags it needs via composable flag sets defined in `src/flags.ts`: + +- **`coreGlobalFlags`** โ€” `--verbose`, `--json`, `--pretty-json`, `--web-cli-help` (hidden) (on every command via `AblyBaseCommand.globalFlags`) +- **`productApiFlags`** โ€” core + hidden product API flags (`port`, `tlsPort`, `tls`). Use for commands that talk to the Ably product API. +- **`controlApiFlags`** โ€” core + hidden control API flags (`control-host`, `dashboard-host`). Use for commands that talk to the Control API. +- **`clientIdFlag`** โ€” `--client-id`. Add to any command that creates a realtime connection (publish, subscribe, presence enter/subscribe, spaces enter/get/subscribe, locks acquire/get/subscribe, cursors set/get/subscribe, locations set/get/subscribe, etc.). The rule: if the command calls `space.enter()`, creates a realtime client, or joins a channel, include `clientIdFlag`. Do NOT add globally. +- **`timeRangeFlags`** โ€” `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). +- **`endpointFlag`** โ€” `--endpoint`. Hidden, only on `accounts login` and `accounts switch`. + +**When creating a new command:** +```typescript +// Product API command (channels, spaces, rooms, etc.) +import { productApiFlags, clientIdFlag } from "../../flags.js"; +static override flags = { + ...productApiFlags, + ...clientIdFlag, // Only if command needs client identity + // command-specific flags... +}; + +// Control API command (apps, keys, queues, etc.) +import { controlApiFlags } from "../../flags.js"; +static override flags = { + ...controlApiFlags, + // command-specific flags... +}; +``` + +**Auth** is managed via `ably login` (stored config). Environment variables override stored config for CI, scripting, or testing: +- `ABLY_API_KEY`, `ABLY_TOKEN`, `ABLY_ACCESS_TOKEN` + +Do NOT add `--api-key`, `--token`, or `--access-token` flags to commands. + +## Writing Tests + +**Auth in tests โ€” do NOT use CLI flags (`--api-key`, `--token`, `--access-token`):** +**Unit tests** โ€” Auth is provided automatically by `MockConfigManager` (see `test/helpers/mock-config-manager.ts`). No env vars needed. Only set `ABLY_API_KEY` when specifically testing env var override behavior. +```typescript +// WRONG โ€” don't pass auth flags +runCommand(["channels", "publish", "my-channel", "hello", "--api-key", key]); + +// CORRECT โ€” MockConfigManager provides auth automatically +runCommand(["channels", "publish", "my-channel", "hello"]); + +// CORRECT โ€” use getMockConfigManager() to access test auth values +import { getMockConfigManager } from "../../helpers/mock-config-manager.js"; +const mockConfig = getMockConfigManager(); +const apiKey = mockConfig.getApiKey()!; +const appId = mockConfig.getCurrentAppId()!; +``` + +**E2E tests** โ€” Commands run as real subprocesses, so auth must be passed via env vars: +```typescript +// CORRECT โ€” pass auth via env vars for E2E +runCommand(["channels", "publish", "my-channel", "hello"], { + env: { ABLY_API_KEY: key }, +}); + +// CORRECT โ€” spawn with env vars +spawn("node", [cliPath, "channels", "subscribe", "my-channel"], { + env: { ...process.env, ABLY_API_KEY: key }, +}); + +// Control API commands use ABLY_ACCESS_TOKEN +runCommand(["stats", "account"], { + env: { ABLY_ACCESS_TOKEN: token }, +}); +``` + +**Test structure:** +- `test/unit/` โ€” Fast, mocked tests. Auth via `MockConfigManager` (automatic). Only set `ABLY_API_KEY` env var when testing env var override behavior. +- `test/integration/` โ€” Integration tests (e.g., interactive mode). Mocked external services but tests multi-component interaction. +- `test/e2e/` โ€” Full scenarios against real Ably. Auth via env vars (`ABLY_API_KEY`, `ABLY_ACCESS_TOKEN`). +- Helpers in `test/helpers/` โ€” `runCommand()`, `runLongRunningBackgroundProcess()`, `e2e-test-helper.ts`, `mock-config-manager.ts`. + +**Running tests:** +```bash +pnpm test:unit # All unit tests +pnpm test:integration # Integration tests +pnpm test:e2e # All E2E tests +pnpm test test/unit/commands/foo.test.ts # Specific test +``` + +## CLI Output & Flag Conventions + +### Output patterns (use helpers from src/utils/output.ts) +- **Progress**: `progress("Attaching to channel: " + resource(name))` โ€” no color on action text, `progress()` appends `...` automatically. Never manually write `"Doing something..."` โ€” always use `progress("Doing something")`. +- **Success**: `success("Message published to channel " + resource(name) + ".")` โ€” green checkmark, **must** end with `.` (not `!`). Never use `chalk.green(...)` directly โ€” always use the `success()` helper. +- **Listening**: `listening("Listening for messages.")` โ€” dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `success()` call โ€” use a separate `listening()` call. +- **Resource names**: Always `resource(name)` (cyan), never quoted โ€” including in `logCliEvent` messages. +- **Timestamps**: `formatTimestamp(ts)` โ€” dim `[timestamp]` for event streams. Exported as `formatTimestamp` to avoid clashing with local `timestamp` variables. +- **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 errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode โ€” always emit a JSON error object or use `this.jsonError()`. +- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` ``. Consistent across all history commands (channels, logs, connection-lifecycle, push). + +### Additional output patterns (direct chalk, not helpers) +- **Secondary labels**: `chalk.dim("Label:")` โ€” for field names in structured output (e.g., `${chalk.dim("Profile:")} ${value}`) +- **Client IDs**: `chalk.blue(clientId)` โ€” for user/client identifiers in events +- **Event types**: `chalk.yellow(eventType)` โ€” for action/event type labels +- **Warnings**: `chalk.yellow("Warning: ...")` โ€” for non-fatal warnings +- **Errors**: Use `this.error()` (oclif standard) for fatal errors, not `this.log(chalk.red(...))` +- **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'` + +### Help output theme +Help colors are configured via `package.json > oclif.theme` (oclif's built-in theme system). The custom help class in `src/help.ts` also applies colors to COMMANDS sections it builds manually. Color scheme: +- **Commands/bin/topics**: cyan โ€” primary actionable items +- **Flags/args**: whiteBright โ€” bright but secondary to commands +- **Section headers**: bold โ€” USAGE, FLAGS, COMMANDS, etc. +- **Command summaries**: whiteBright โ€” descriptions in command listings +- **Defaults/options**: yellow โ€” `[default: N]`, `` +- **Required flags**: red โ€” `(required)` marker +- **`$` prompt**: green โ€” shell prompt in examples/usage +- **Flag separator**: dim โ€” comma between `-c, --count` + +When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers, `chalk.cyan()` for command names, and `chalk.whiteBright()` for descriptions to stay consistent. + +### Flag conventions +- All flags kebab-case: `--my-flag` (never camelCase) +- `--app`: `"The app ID or name (defaults to current app)"` (for commands with `resolveAppId`), `"The app ID (defaults to current app)"` (for commands without) +- `--limit`: `"Maximum number of results to return (default: N)"` +- `--duration`: `"Automatically exit after N seconds"`, alias `-D` +- `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"` +- `--start`/`--end`: Use `timeRangeFlags` from `src/flags.ts` and parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). +- `--direction`: `"Direction of message retrieval (default: backwards)"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`. +- Channels use "publish", Rooms use "send" (matches SDK terminology) +- Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`) + +## Ably Knowledge + +- When in doubt about how Ably works, refer to the Ably docs at https://ably.com/docs. +- Key docs: + - Pub/Sub: https://ably.com/docs/basics and API ref at https://ably.com/docs/api/realtime-sdk (use https://ably.com/docs/sdk/js/v2.0/ when referenced) + - Chat: https://ably.com/docs/chat and API ref at https://sdk.ably.com/builds/ably/ably-chat-js/main/typedoc/modules/chat-js.html + - Spaces: https://ably.com/docs/spaces and API ref at https://sdk.ably.com/builds/ably/spaces/main/typedoc/index.html + - Control API: https://ably.com/docs/account/control-api and ref at https://ably.com/docs/api/control-api + - Platform: https://ably.com/docs/platform +- The CLI uses Ably SDKs for all data plane commands. When an API exists in the data plane REST API but has no corresponding SDK method, use the Pub/Sub SDK's request method. +- The Control API has no official SDK, so raw HTTP requests are used. + +## Development Standards + +- Use TypeScript and follow standard naming conventions. +- This project uses `pnpm` (not npm or yarn). +- When installing libraries, use `pnpm add` (not manual package.json edits) to ensure latest versions. +- Avoid unnecessary dependencies โ€” don't write code when libraries solve common problems, but don't install a library for every problem either. +- Code quality matters. The target audience is experienced developers who will read this code. -## Important Notes +## Before Marking Complete -- This is the Ably CLI npm package (@ably/cli) -- Follow the MANDATORY workflow for ALL code changes: - ```bash - pnpm prepare # 1. Build + update manifest/README - pnpm exec eslint . # 2. Lint (MUST be 0 errors) - pnpm test:unit # 3. Test (at minimum) - ``` -- Read `.cursor/rules/*.mdc` files for detailed guidance -- **If you skip these steps, the work is NOT complete** \ No newline at end of file +- [ ] `pnpm prepare` succeeds +- [ ] `pnpm exec eslint .` shows 0 errors +- [ ] `pnpm test:unit` passes +- [ ] No debug artifacts remain +- [ ] Docs updated if needed (especially `docs/Project-Structure.md` when adding/moving files, `docs/Testing.md` when changing test patterns) +- [ ] Followed oclif patterns diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18e539e1..edcffa9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,14 +4,14 @@ Thank you for your interest in contributing to the Ably CLI! ## Development Workflow -All code changes, whether features or bug fixes, **MUST** follow the mandatory workflow outlined in [.cursor/rules/Workflow.mdc](mdc:.cursor/rules/Workflow.mdc). +All code changes, whether features or bug fixes, **MUST** follow the mandatory workflow outlined in [AGENTS.md](AGENTS.md). In summary, this involves: 1. **Build:** Run `pnpm prepare` to compile, update manifests, and update the README. 2. **Lint:** Run `pnpm exec eslint .` and fix all errors/warnings. 3. **Test:** Run relevant tests (`pnpm test:unit`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm test:playwright`, or specific files) and ensure they pass. Add new tests or update existing ones as needed. -4. **Document:** Update all relevant documentation (`docs/`, `.cursor/rules/`, `README.md`) to reflect your changes. +4. **Document:** Update all relevant documentation (`docs/`, `AGENTS.md`, `README.md`) to reflect your changes. **Pull requests will not be merged unless all these steps are completed and verified.** @@ -21,8 +21,7 @@ Before starting work, please familiarize yourself with: * [Project Structure](./docs/Project-Structure.md): Know where different code components live. * [Testing Strategy](./docs/Testing.md): Understand the different types of tests and how to run them. -* [Development Rules](mdc:.cursor/rules/Development.mdc): Coding standards, linting, dependency management. -* [Ably Rules](mdc:.cursor/rules/Ably.mdc): How to interact with Ably APIs/SDKs. +* [AGENTS.md](AGENTS.md): Development standards, coding conventions, and Ably API/SDK guidance. ## Reporting Issues @@ -54,7 +53,7 @@ This allows testing CLI changes against local server modifications before deploy 1. Make sure all checks are passing on main 2. Create a new release branch, in the format `release/` where the version is the SemVer version of the release. In that branch: - Update the `package.json` version to the new version. - - Run `npx oclif readme` to regenerate the README with updated command documentation. + - Run `pnpm exec oclif readme` to regenerate the README with updated command documentation. - Update the `CHANGELOG.md` with any user-affecting changes since the last release. - Review the generated README.md changes to ensure they're correct. - Stage all changes: `git add package.json README.md CHANGELOG.md` diff --git a/README.md b/README.md index c253aa4e..25955dfb 100644 --- a/README.md +++ b/README.md @@ -4064,19 +4064,6 @@ The CLI also supports environment variables to control update notifications: # Contributing -Please see the documentation in [`.cursor/rules/Workflow.mdc`](.cursor/rules/Workflow.mdc) for details on how to contribute to this project, including our mandatory development workflow, testing requirements, and code quality standards. +Please see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow, testing requirements, and release process. -## For AI Assistants - -**IMPORTANT**: See [`.claude/CLAUDE.md`](./.claude/CLAUDE.md) for mandatory instructions before making any changes. -If you are an AI assistant, start with [`AI-Assistance.mdc`](.cursor/rules/AI-Assistance.mdc) first. - -## Quick Development Validation - -Before submitting any changes, run our automated validation script to ensure your code meets all requirements: - -```bash -pnpm validate # or: ./scripts/pre-push-validation.sh -``` - -This script automatically runs all mandatory steps including build, linting, and testing. See [`.cursor/rules/Workflow.mdc`](.cursor/rules/Workflow.mdc) for detailed information about each step. +For development standards and coding conventions, see [`AGENTS.md`](AGENTS.md). diff --git a/align-cli.md b/align-cli.md deleted file mode 100644 index bc6aba4a..00000000 --- a/align-cli.md +++ /dev/null @@ -1,254 +0,0 @@ -# Plan: CLI Consistency โ€” Output Format (DX-791) + Flags/Args (DX-787) - -## Context - -The CLI has grown organically across product areas (Channels, Rooms, Spaces, Control API), resulting in significant inconsistencies in both user-facing output messages and flag/argument naming. This makes the CLI feel disjointed โ€” e.g., `channels publish` shows `โœ“ Message published successfully to channel "foo".` while `rooms messages send` shows just `Message sent successfully.` (no checkmark, no resource name). Flag descriptions for the same concept (`--app`, `--limit`, `--duration`) vary across commands. This effort standardizes both dimensions together and documents the conventions in CLAUDE.md so future development stays consistent. - ---- - -## Part 1: Define the Standards - -### Output Message Standards - -**Progress messages** (operation starting/in-progress): -``` - : ... -``` -- No color on the action text (green on progress is wrong โ€” it implies success) -- Always end with `...` -- Examples: `Attaching to channel: my-channel...`, `Creating app: My App...` - -**Success messages** (one-shot operation completed): -``` - . -``` -- Always start with green `โœ“` -- Always end with `.` -- Resource names in `chalk.cyan()`, never in quotes -- Examples: `โœ“ Message published to channel my-channel.`, `โœ“ Message sent to room my-room.`, `โœ“ App created: My App (app-id).` - -**Listening/waiting hints** (long-running command is ready): -``` -chalk.dim("Listening for . Press Ctrl+C to exit.") -``` -- Always `chalk.dim()` -- Always ends with `.` -- Consistent wording: `Press Ctrl+C to exit.` -- First clause varies by context: `Listening for messages.`, `Staying present.`, `Holding lock.` - -**Subscribe confirmation** (long-running command connected successfully): -``` - Subscribed to : . -chalk.dim("Listening for . Press Ctrl+C to exit.") -``` - -**Resource name formatting**: Always `chalk.cyan()`. Never quoted in human output. - -**Terminology**: Keep product-specific verbs matching each SDK โ€” Channels uses "publish" (`channel.publish()`), Rooms uses "send" (`room.messages.send()`). Don't force unification here. - -### Flag/Argument Standards - -**Naming**: All flags kebab-case (`--my-flag`). No camelCase. - -**Standard descriptions for common flags**: - -| Flag | Standard Description | -|------|---------------------| -| `--app` | `"The app ID or name (defaults to current app)"` | -| `--limit` | `"Maximum number of results to return (default: N)"` | -| `--duration` | `"Automatically exit after N seconds (0 = run indefinitely)"` | -| `--prefix` | `"Filter results by name prefix"` | -| `--count` | `"Number of messages to send (default: 1)"` | -| `--delay` | `"Delay between messages in milliseconds (default: 40)"` | -| `--rewind` | `"Number of historical messages to retrieve on attach (default: 0)"` | - -**Single-letter aliases** โ€” keep existing, no new ones needed. Fix the `-d` collision: `--duration` uses `-D` (uppercase) everywhere. - ---- - -## Part 2: Implementation - -### Step 1: Create output helper utility (NEW file) - -**File**: `src/utils/output.ts` - -Thin exported functions (not a class) that enforce the patterns: - -```typescript -import chalk from "chalk"; - -export function progress(message: string): string { - return `${message}...`; -} - -export function success(message: string): string { - return `${chalk.green("โœ“")} ${message}`; -} - -export function listening(description: string): string { - return chalk.dim(`${description} Press Ctrl+C to exit.`); -} - -export function resource(name: string): string { - return chalk.cyan(name); -} -``` - -Commands import and use these to stay consistent. Lightweight โ€” just enforces checkmark color, dim hints, cyan names. - -### Step 2: Fix flag issues (DX-787) - -1. **Rename `autoType` to `auto-type`** in `src/commands/rooms/typing/keystroke.ts` - - CLI is Public Preview, direct rename is fine - -2. **Remove duplicate `json` flag** from: - - `src/commands/logs/push/subscribe.ts` - - `src/commands/logs/channel-lifecycle/subscribe.ts` - - These shadow the global `--json` flag from the base command - -3. **Standardize `--app` flag descriptions** across all ~24 files that define it: - - `src/commands/auth/keys/*.ts` - - `src/commands/integrations/*.ts` - - `src/commands/queues/*.ts` - - `src/commands/apps/channel-rules/*.ts` - - `src/commands/channels/inspect.ts` - - `src/commands/auth/issue-ably-token.ts`, `issue-jwt-token.ts`, `revoke-token.ts` - - All get: `"The app ID or name (defaults to current app)"` - -4. **Standardize `--limit` descriptions** across ~11 files - -5. **Standardize `--duration` descriptions** across subscribe/enter commands - - Fix `bench/subscriber.ts` alias from `-d` to `-D` (matches all other duration flags) - -### Step 3: Fix output messages โ€” Channels commands (DX-791) - -| File | Change | -|------|--------| -| `channels/publish.ts:172` | `"Message published successfully to channel \"${channel}\""` โ†’ `success(\`Message published to channel ${resource(channel)}.\`)` | -| `channels/publish.ts:168` | Multi-msg summary: add `success()` wrapper | -| `channels/publish.ts:292` | Per-msg: same pattern | -| `channels/subscribe.ts:178` | Already close โ€” just import `resource()` for cyan | -| `channels/subscribe.ts:263` | Use `success()` + `listening()` | -| `channels/presence/subscribe.ts:75` | Remove `chalk.green()` from progress text (green โ‰  progress) | -| `channels/presence/subscribe.ts:130` | Wrap in `chalk.dim()` | -| `channels/presence/enter.ts:196-207` | Standardize hint with `listening()` | -| `channels/occupancy/subscribe.ts:80-82` | Remove `chalk.green()` from progress | -| `channels/occupancy/subscribe.ts:127` | Wrap in `chalk.dim()` | - -### Step 4: Fix output messages โ€” Rooms commands - -| File | Change | -|------|--------| -| `rooms/messages/send.ts:390` | `"Message sent successfully."` โ†’ `success(\`Message sent to room ${resource(room)}.\`)` | -| `rooms/messages/send.ts:349` | Multi-msg summary: add `success()` wrapper | -| `rooms/messages/subscribe.ts:191-195` | Standardize subscribe confirmation | -| `rooms/presence/enter.ts:229-237` | Use `chalk.green("โœ“")` consistently | -| `rooms/presence/subscribe.ts:60` | Split progress and listening hint | -| `rooms/occupancy/subscribe.ts:168` | Wrap in `chalk.dim()` | -| `rooms/reactions/subscribe.ts:112` | Standardize format | -| `rooms/typing/subscribe.ts:99` | Already uses `chalk.dim()` โ€” verify format | - -### Step 5: Fix output messages โ€” Spaces commands - -| File | Change | -|------|--------| -| `spaces/members/enter.ts:147` | `chalk.green("Successfully entered space:")` โ†’ `success(\`Entered space: ${resource(name)}.\`)` | -| `spaces/members/subscribe.ts:52` | Add `...` suffix to progress msg | -| `spaces/locks/acquire.ts:168` | `chalk.green("Successfully acquired lock:")` โ†’ `success(...)` | -| `spaces/locations/set.ts:163,238` | Add checkmark | -| `spaces/cursors/get-all.ts:109` | Add checkmark | - -### Step 6: Fix output messages โ€” Logs + Control Plane commands - -| File | Change | -|------|--------| -| `logs/subscribe.ts:114-116` | Remove `chalk.green()` from progress | -| `logs/subscribe.ts:165` | Wrap in `chalk.dim()` | -| `logs/push/subscribe.ts` | Fix Ctrl+C hint (add period, use `chalk.dim()`) | -| `logs/channel-lifecycle/subscribe.ts` | Same | -| `logs/connection-lifecycle/subscribe.ts:120` | Wrap in `chalk.dim()` | -| `apps/create.ts:62` | `"โœ“ App created successfully!"` โ†’ `success(\`App created: ${resource(name)} (${id}).\`)` | -| `queues/create.ts:75` | Same pattern | -| `auth/keys/create.ts:110` | Same pattern | -| `integrations/create.ts:158` | Same pattern | -| `integrations/delete.ts` | Same pattern | - -### Cross-cutting: Standardize logCliEvent() strings - -In every command file being touched (Steps 3-7), also update `logCliEvent()` message strings to use consistent terminology: -- Use same verbs as user-facing output: "published" for channels, "sent" for rooms -- Use consistent phrasing: `"Message published to channel foo"` not `"Message published successfully to channel \"foo\""` -- Remove unnecessary "successfully" from event messages โ€” the event type (`singlePublishComplete`, `sentSuccess`) already conveys success -- Keep event type identifiers (`"singlePublishComplete"`, `"multiSendComplete"`, etc.) unchanged โ€” only the human-readable message string changes - -### Step 7: Fix output messages โ€” Bench commands - -| File | Change | -|------|--------| -| `bench/publisher.ts` | Standardize progress/success messages to use `success()`, `resource()` | -| `bench/subscriber.ts` | Same + fix `--duration` alias from `-d` to `-D` | - -### Step 8: Update tests - -Update string assertions in: -- `test/unit/commands/channels/publish.test.ts` -- `test/unit/commands/rooms/messages.test.ts` (or similar) -- `test/unit/commands/apps/create.test.ts` -- `test/unit/commands/queues/create.test.ts` -- `test/e2e/channels/channels-e2e.test.ts` (line 239) -- `test/e2e/channels/channel-subscribe-e2e.test.ts` (line 77) -- `test/e2e/rooms/rooms-e2e.test.ts` (lines 266, 294, 296, 334) -- Any other tests that assert on exact output strings - -### Step 9: Document conventions in CLAUDE.md - -Add a section to `.claude/CLAUDE.md`: - -```markdown -## CLI Output & Flag Conventions - -### Output patterns (use helpers from src/utils/output.ts) -- **Progress**: `"Attaching to channel: ${resource(name)}..."` โ€” no color on action text -- **Success**: `success("Message published to channel ${resource(name)}.")` โ€” green โœ“, ends with `.` -- **Listening**: `listening("Listening for messages.")` โ€” dim, includes "Press Ctrl+C to exit." -- **Resource names**: Always `resource(name)` (cyan), never quoted - -### Flag conventions -- All flags kebab-case: `--my-flag` (never camelCase) -- `--app`: `"The app ID or name (defaults to current app)"` -- `--limit`: `"Maximum number of results to return (default: N)"` -- `--duration`: `"Automatically exit after N seconds (0 = run indefinitely)"`, alias `-D` -- Channels use "publish", Rooms use "send" (matches SDK terminology) -``` - -Also update `.cursor/rules/AI-Assistance.mdc` with the same patterns. - ---- - -## Verification - -After implementation: -1. `pnpm prepare` โ€” build succeeds -2. `pnpm exec eslint .` โ€” 0 errors -3. `pnpm test:unit` โ€” all unit tests pass -4. Manual smoke test key commands: - - `ably channels publish test-ch "hello"` โ€” verify `โœ“ Message published to channel test-ch.` - - `ably rooms messages send test-room "hello"` โ€” verify `โœ“ Message sent to room test-room.` - - `ably channels subscribe test-ch` โ€” verify progress + listening hint format - - `ably apps create --name test-app` โ€” verify `โœ“ App created: test-app (id).` - ---- - -## Decisions - -- **Single PR** โ€” all changes in one cohesive changeset -- **Skip "Using:" line** โ€” doesn't exist in codebase, separate concern for later -- **Standalone functions** in `src/utils/output.ts` (not base command methods) -- **Include logCliEvent() strings** โ€” standardize internal event messages too for full consistency -- **Include bench commands** โ€” apply same output conventions to `bench/publisher.ts` and `bench/subscriber.ts` -- **JSON output unchanged** โ€” `--json` / `--pretty-json` output is NOT affected, only human-readable output - -## Risks - -- **E2E test ready signals**: Some E2E tests use output strings (e.g., `"Successfully attached to channel"`) as synchronization signals to detect when a command is ready. Must update these carefully to avoid flaky tests. -- **Scope**: ~50+ files but changes are mechanical string replacements. Low risk of logic bugs. diff --git a/docs/Debugging.md b/docs/Debugging.md index 6a05c231..d3d1b25a 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -6,11 +6,11 @@ This guide provides tips for debugging common issues when developing the Ably CL * **Check Logs:** Look for errors or relevant messages in the CLI output, test runner output, server logs (for Web CLI tests), or browser console (for Web CLI tests). * **Isolate the Issue:** Try to reproduce the problem with the simplest possible command or test case. Comment out parts of the code or test to narrow down the source of the error. -* **Consult Documentation:** Review relevant project docs (`docs/`, `.cursor/rules/`) and Ably documentation (). +* **Consult Documentation:** Review relevant project docs (`docs/`, `AGENTS.md`) and Ably documentation (). ## Debugging Tests -Refer to [docs/Testing.md](mdc:docs/Testing.md) for how to run specific tests. +Refer to [Testing.md](Testing.md) for how to run specific tests. ### Vitest Tests (Unit, Integration, E2E) @@ -55,4 +55,4 @@ Refer to [docs/Testing.md](mdc:docs/Testing.md) for how to run specific tests. ```bash DEBUG=oclif* bin/run.js [command] ``` -* **Check Configuration:** Use `ably config` (opens the config file) to verify stored credentials or settings. +* **Check Configuration:** Use `ably config show` to view stored credentials or `ably config path` to find the config file location. diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 62160736..69dccc33 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -2,89 +2,145 @@ This document outlines the directory structure of the Ably CLI project. -> **Note:** The terminal server functionality has been moved to a separate repository (`cli-terminal-server`). For information about the server architecture, please refer to the [cli-terminal-server repository](https://github.com/ably/cli-terminal-server). +> **Note:** The terminal server has been moved to a separate repository ([cli-terminal-server](https://github.com/ably/cli-terminal-server)). The `server/` directory contains only a `REMOVED.md` tombstone. +```text / -โ”œโ”€โ”€ assets/ # Static assets like images. -โ”‚ โ””โ”€โ”€ cli-screenshot.png # Screenshot of the CLI. -โ”œโ”€โ”€ bin/ # Executable scripts for running the CLI. -โ”‚ โ”œโ”€โ”€ dev.cmd # Development run script (Windows). -โ”‚ โ”œโ”€โ”€ development.js # Development run script (Unix). -โ”‚ โ”œโ”€โ”€ run.cmd # Production run script (Windows). -โ”‚ โ””โ”€โ”€ run.js # Production run script (Unix). -โ”œโ”€โ”€ docs/ # Project documentation. -โ”‚ โ”œโ”€โ”€ Project-Structure.md # This file, outlining the project structure. -โ”‚ โ””โ”€โ”€ Testing.md # Testing strategy and policy. -โ”œโ”€โ”€ examples/ # Example usage of the CLI or related components. -โ”‚ โ””โ”€โ”€ web-cli/ # Example implementation of the web-based CLI. -โ”œโ”€โ”€ packages/ # Internal packages used by the project. -โ”‚ โ””โ”€โ”€ react-web-cli/ # React component for the web-based CLI. -โ”œโ”€โ”€ scripts/ # Utility scripts for development and deployment. -โ”‚ โ”œโ”€โ”€ postinstall-welcome.ts # Post-installation welcome message. -โ”‚ โ””โ”€โ”€ pre-push-validation.sh # Pre-push validation checks. -โ”œโ”€โ”€ src/ # Source code for the CLI. -โ”‚ โ”œโ”€โ”€ base-command.ts # Base class for all CLI commands, containing common logic. -โ”‚ โ”œโ”€โ”€ chat-base-command.ts # Base class specific to Ably Chat commands. -โ”‚ โ”œโ”€โ”€ commands/ # oclif commands implementation. -โ”‚ โ”‚ โ”œโ”€โ”€ accounts/ # Commands related to Ably account management. -โ”‚ โ”‚ โ”œโ”€โ”€ apps/ # Commands related to Ably app management. -โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Commands related to authentication (keys, tokens). -โ”‚ โ”‚ โ”œโ”€โ”€ bench/ # Commands for benchmarking Ably features. -โ”‚ โ”‚ โ”œโ”€โ”€ channel-rule/ # Commands for managing channel rules (namespaces). -โ”‚ โ”‚ โ”œโ”€โ”€ channels/ # Commands for interacting with Ably Pub/Sub channels. -โ”‚ โ”‚ โ”œโ”€โ”€ config.ts # Command to open the CLI configuration file. -โ”‚ โ”‚ โ”œโ”€โ”€ connections/ # Commands related to client connections. -โ”‚ โ”‚ โ”œโ”€โ”€ help/ # Commands for getting help (AI agent, contact). -โ”‚ โ”‚ โ”œโ”€โ”€ integrations/ # Commands for managing Ably integrations (rules). -โ”‚ โ”‚ โ”œโ”€โ”€ login.ts # Alias command for `accounts login`. -โ”‚ โ”‚ โ”œโ”€โ”€ logs/ # Commands for subscribing to various log streams. -โ”‚ โ”‚ โ”œโ”€โ”€ queues/ # Commands for managing Ably Queues. -โ”‚ โ”‚ โ”œโ”€โ”€ rooms/ # Commands for interacting with Ably Chat rooms. -โ”‚ โ”‚ โ””โ”€โ”€ spaces/ # Commands for interacting with Ably Spaces. -โ”‚ โ”œโ”€โ”€ control-base-command.ts # Base class for commands interacting with the Control API. -โ”‚ โ”œโ”€โ”€ help.ts # Custom help class implementation. -โ”‚ โ”œโ”€โ”€ hooks/ # oclif lifecycle hooks. -โ”‚ โ”‚ โ”œโ”€โ”€ command_not_found/ # Hook for handling unknown commands. -โ”‚ โ”‚ โ””โ”€โ”€ init/ # Hook executed at CLI initialization. -โ”‚ โ”œโ”€โ”€ index.ts # Main entry point for the CLI source. -โ”‚ โ”œโ”€โ”€ services/ # Core services used across commands. -โ”‚ โ”‚ โ”œโ”€โ”€ config-manager.ts # Service for managing CLI configuration. -โ”‚ โ”‚ โ”œโ”€โ”€ control-api.ts # Service for interacting with the Ably Control API. -โ”‚ โ”‚ โ”œโ”€โ”€ interactive-helper.ts # Helper for interactive CLI prompts. -โ”‚ โ”‚ โ””โ”€โ”€ stats-display.ts # Service for displaying stats information. -โ”‚ โ”œโ”€โ”€ spaces-base-command.ts # Base class specific to Ably Spaces commands. -โ”‚ โ”œโ”€โ”€ types/ # TypeScript type definitions. -โ”‚ โ”‚ โ””โ”€โ”€ cli.ts # General CLI type definitions. -โ”‚ โ””โ”€โ”€ utils/ # Utility functions. -โ”‚ โ”œโ”€โ”€ json-formatter.ts # Utility for formatting JSON output. -โ”‚ โ””โ”€โ”€ logo.ts # Utility for displaying the Ably logo ASCII art. -โ”œโ”€โ”€ test/ # Automated tests. -โ”‚ โ”œโ”€โ”€ commands/ # Tests for specific CLI commands. -โ”‚ โ”œโ”€โ”€ e2e/ # End-to-end tests that run CLI commands in real environment. -โ”‚ โ”‚ โ””โ”€โ”€ core/ # Core e2e tests for basic CLI functionality. -โ”‚ โ”œโ”€โ”€ hooks/ # Tests for oclif hooks. -โ”‚ โ”‚ โ””โ”€โ”€ command_not_found/ # Tests for the command_not_found hook. -โ”‚ โ”œโ”€โ”€ integration/ # Integration tests for testing command flows. -โ”‚ โ”‚ โ””โ”€โ”€ core/ # Core integration tests. -โ”‚ โ”œโ”€โ”€ unit/ # Unit tests for internal components. -โ”‚ โ”‚ โ”œโ”€โ”€ base/ # Tests for base command classes. -โ”‚ โ”‚ โ””โ”€โ”€ services/ # Tests for service components. -โ”‚ โ””โ”€โ”€ tsconfig.json # TypeScript configuration specific to tests. -โ”œโ”€โ”€ .cursor/ # Cursor AI configuration and rules. -โ”‚ โ””โ”€โ”€ rules/ -โ”‚ โ”œโ”€โ”€ project.mdc # Rules specific to the overall project context. -โ”‚ โ””โ”€โ”€ ably.mdc # Rules specific to Ably concepts and APIs. -โ”‚ โ””โ”€โ”€ development.mdc # Rules specific to development practices (Node, TS, oclif). -โ”œโ”€โ”€ .env.example # Example environment variables file. -โ”œโ”€โ”€ .eslintignore # Files/patterns ignored by ESLint. -โ”œโ”€โ”€ .eslintrc.js # ESLint configuration file. -โ”œโ”€โ”€ .gitignore # Files/patterns ignored by Git. -โ”œโ”€โ”€ .prettierrc.json # Prettier code formatter configuration. -โ”œโ”€โ”€ CHANGELOG.md # Log of changes across versions. -โ”œโ”€โ”€ README.md # Main project README file. -โ”œโ”€โ”€ oclif.manifest.json # oclif manifest file, generated during build. -โ”œโ”€โ”€ package.json # Node.js project manifest (dependencies, scripts). -โ”œโ”€โ”€ pnpm-lock.yaml # pnpm lock file for deterministic installs. -โ”œโ”€โ”€ pnpm-workspace.yaml # Defines the pnpm workspace configuration. -โ””โ”€โ”€ tsconfig.json # Main TypeScript configuration file. -โ””โ”€โ”€ vitest.config.ts # Config for vitest +โ”œโ”€โ”€ assets/ # Static assets (e.g. CLI screenshot) +โ”œโ”€โ”€ bin/ # Executable scripts +โ”‚ โ”œโ”€โ”€ ably-interactive # Bash wrapper for interactive mode (restarts on Ctrl+C) +โ”‚ โ”œโ”€โ”€ ably-interactive.ps1 # PowerShell equivalent for Windows +โ”‚ โ”œโ”€โ”€ dev.cmd # Development run script (Windows) +โ”‚ โ”œโ”€โ”€ development.js # Development run script (Unix) +โ”‚ โ”œโ”€โ”€ run.cmd # Production run script (Windows) +โ”‚ โ””โ”€โ”€ run.js # Production run script (Unix) +โ”œโ”€โ”€ docs/ # Project documentation +โ”œโ”€โ”€ examples/ +โ”‚ โ””โ”€โ”€ web-cli/ # Example web-based CLI app (uses @ably/react-web-cli) +โ”œโ”€โ”€ packages/ +โ”‚ โ””โ”€โ”€ react-web-cli/ # @ably/react-web-cli React component (published to npm) +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ postinstall-welcome.ts # Post-installation welcome message +โ”‚ โ””โ”€โ”€ pre-push-validation.sh # Pre-push validation checks +โ”œโ”€โ”€ server/ +โ”‚ โ””โ”€โ”€ REMOVED.md # Tombstone โ€” server moved to cli-terminal-server repo +โ”œโ”€โ”€ src/ # CLI source code +โ”‚ โ”œโ”€โ”€ base-command.ts # Base class for all CLI commands +โ”‚ โ”œโ”€โ”€ base-topic-command.ts # Base class for topic (group) commands +โ”‚ โ”œโ”€โ”€ chat-base-command.ts # Base class for Ably Chat commands +โ”‚ โ”œโ”€โ”€ control-base-command.ts # Base class for Control API commands +โ”‚ โ”œโ”€โ”€ interactive-base-command.ts # Base class for interactive/streaming commands +โ”‚ โ”œโ”€โ”€ spaces-base-command.ts # Base class for Ably Spaces commands +โ”‚ โ”œโ”€โ”€ flags.ts # Composable flag sets (see AGENTS.md for details) +โ”‚ โ”œโ”€โ”€ help.ts # Custom help class +โ”‚ โ”œโ”€โ”€ index.ts # Main entry point +โ”‚ โ”œโ”€โ”€ commands/ # CLI commands (oclif) +โ”‚ โ”‚ โ”œโ”€โ”€ accounts/ # Account management (login, logout, list, switch, current) +โ”‚ โ”‚ โ”œโ”€โ”€ apps/ # App management (create, list, delete, switch, current, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Authentication (keys, tokens) +โ”‚ โ”‚ โ”œโ”€โ”€ bench/ # Benchmarking (publisher, subscriber) +โ”‚ โ”‚ โ”œโ”€โ”€ channel-rule/ # Channel rules / namespaces +โ”‚ โ”‚ โ”œโ”€โ”€ channels/ # Pub/Sub channels (publish, subscribe, presence, history, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ config/ # CLI config management (show, path) +โ”‚ โ”‚ โ”œโ”€โ”€ connections/ # Client connections (test) +โ”‚ โ”‚ โ”œโ”€โ”€ integrations/ # Integration rules +โ”‚ โ”‚ โ”œโ”€โ”€ logs/ # Log streams (subscribe, history, push subscribe) +โ”‚ โ”‚ โ”œโ”€โ”€ queues/ # Queue management +โ”‚ โ”‚ โ”œโ”€โ”€ rooms/ # Ably Chat rooms (send, subscribe, presence, reactions, typing, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ spaces/ # Ably Spaces (members, cursors, locations, locks) +โ”‚ โ”‚ โ”œโ”€โ”€ stats/ # Usage statistics +โ”‚ โ”‚ โ”œโ”€โ”€ support/ # Support contact info +โ”‚ โ”‚ โ”œโ”€โ”€ test/ # Diagnostic test commands +โ”‚ โ”‚ โ”œโ”€โ”€ help.ts # Help command +โ”‚ โ”‚ โ”œโ”€โ”€ interactive.ts # Interactive REPL mode +โ”‚ โ”‚ โ”œโ”€โ”€ login.ts # Alias for `accounts login` +โ”‚ โ”‚ โ”œโ”€โ”€ status.ts # Ably service status +โ”‚ โ”‚ โ””โ”€โ”€ version.ts # CLI version info +โ”‚ โ”œโ”€โ”€ hooks/ # oclif lifecycle hooks +โ”‚ โ”‚ โ”œโ”€โ”€ command_not_found/ # Fuzzy-match suggestions for unknown commands +โ”‚ โ”‚ โ””โ”€โ”€ init/ # CLI initialization +โ”‚ โ”œโ”€โ”€ services/ # Business logic +โ”‚ โ”‚ โ”œโ”€โ”€ config-manager.ts # CLI configuration (accounts, apps, API keys) +โ”‚ โ”‚ โ”œโ”€โ”€ control-api.ts # Ably Control API HTTP client +โ”‚ โ”‚ โ”œโ”€โ”€ history-manager.ts # Interactive mode command history persistence +โ”‚ โ”‚ โ”œโ”€โ”€ interactive-helper.ts # Interactive prompts (confirm, select account/app) +โ”‚ โ”‚ โ””โ”€โ”€ stats-display.ts # Stats formatting and display +โ”‚ โ”œโ”€โ”€ types/ +โ”‚ โ”‚ โ””โ”€โ”€ cli.ts # General CLI type definitions +โ”‚ โ””โ”€โ”€ utils/ +โ”‚ โ”œโ”€โ”€ interrupt-feedback.ts # Ctrl+C feedback messages +โ”‚ โ”œโ”€โ”€ json-formatter.ts # JSON output formatting +โ”‚ โ”œโ”€โ”€ logo.ts # ASCII art logo with gradient +โ”‚ โ”œโ”€โ”€ long-running.ts # Long-running command helpers (duration, cleanup) +โ”‚ โ”œโ”€โ”€ open-url.ts # Cross-platform URL opener +โ”‚ โ”œโ”€โ”€ output.ts # Output helpers (progress, success, resource, etc.) +โ”‚ โ”œโ”€โ”€ prompt-confirmation.ts # Y/N confirmation prompts +โ”‚ โ”œโ”€โ”€ readline-helper.ts # Readline utilities for interactive mode +โ”‚ โ”œโ”€โ”€ sigint-exit.ts # SIGINT/Ctrl+C handling (exit code 130) +โ”‚ โ”œโ”€โ”€ string-distance.ts # Levenshtein distance for fuzzy matching +โ”‚ โ”œโ”€โ”€ terminal-diagnostics.ts # Terminal capability detection +โ”‚ โ”œโ”€โ”€ test-mode.ts # isTestMode() helper +โ”‚ โ”œโ”€โ”€ version.ts # Version string utilities +โ”‚ โ””โ”€โ”€ web-mode.ts # Web CLI mode detection +โ”œโ”€โ”€ test/ # Automated tests +โ”‚ โ”œโ”€โ”€ setup.ts # Global test setup (runs in Vitest context) +โ”‚ โ”œโ”€โ”€ root-hooks.ts # Mocha-compatible root hooks for E2E +โ”‚ โ”œโ”€โ”€ tsconfig.json # Test-specific TypeScript config +โ”‚ โ”œโ”€โ”€ helpers/ # Shared test utilities +โ”‚ โ”‚ โ”œโ”€โ”€ cli-runner.ts # CliRunner class for E2E process management +โ”‚ โ”‚ โ”œโ”€โ”€ cli-runner-store.ts # Per-test CLI runner tracking +โ”‚ โ”‚ โ”œโ”€โ”€ command-helpers.ts # High-level E2E helpers (startSubscribeCommand, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ e2e-test-helper.ts # E2E test setup and teardown +โ”‚ โ”‚ โ”œโ”€โ”€ mock-ably-chat.ts # Mock Ably Chat SDK +โ”‚ โ”‚ โ”œโ”€โ”€ mock-ably-realtime.ts # Mock Ably Realtime SDK +โ”‚ โ”‚ โ”œโ”€โ”€ mock-ably-rest.ts # Mock Ably REST SDK +โ”‚ โ”‚ โ”œโ”€โ”€ mock-ably-spaces.ts # Mock Ably Spaces SDK +โ”‚ โ”‚ โ”œโ”€โ”€ mock-config-manager.ts # MockConfigManager (provides test auth) +โ”‚ โ”‚ โ”œโ”€โ”€ mock-control-api-keys.ts # Mock Control API key responses +โ”‚ โ”‚ โ””โ”€โ”€ ably-event-emitter.ts # Event emitter helper for mock SDKs +โ”‚ โ”œโ”€โ”€ unit/ # Fast, mocked tests +โ”‚ โ”‚ โ”œโ”€โ”€ setup.ts # Unit test setup +โ”‚ โ”‚ โ”œโ”€โ”€ base/ # Base command class tests +โ”‚ โ”‚ โ”œโ”€โ”€ base-command/ # AblyBaseCommand tests +โ”‚ โ”‚ โ”œโ”€โ”€ commands/ # Command-level unit tests (mirrors src/commands/) +โ”‚ โ”‚ โ”œโ”€โ”€ core/ # Core CLI functionality tests +โ”‚ โ”‚ โ”œโ”€โ”€ help/ # Help system tests +โ”‚ โ”‚ โ”œโ”€โ”€ hooks/ # Hook tests +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # Service tests +โ”‚ โ”‚ โ””โ”€โ”€ utils/ # Utility tests +โ”‚ โ”œโ”€โ”€ integration/ # Multi-component tests (mocked external services) +โ”‚ โ”‚ โ”œโ”€โ”€ commands/ # Command flow integration tests +โ”‚ โ”‚ โ””โ”€โ”€ interactive-mode.test.ts # Interactive REPL integration tests +โ”‚ โ”œโ”€โ”€ e2e/ # End-to-end tests against real Ably +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Auth E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ bench/ # Benchmark E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ channels/ # Channel E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ connections/ # Connection E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ control/ # Control API E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ core/ # Core CLI E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ interactive/ # Interactive mode E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ rooms/ # Chat rooms E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ spaces/ # Spaces E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ stats/ # Stats E2E tests +โ”‚ โ”‚ โ””โ”€โ”€ web-cli/ # Playwright browser tests for Web CLI +โ”‚ โ””โ”€โ”€ manual/ # Manual test scripts +โ”œโ”€โ”€ .claude/ # Claude Code AI configuration +โ”œโ”€โ”€ .github/ # GitHub Actions workflows and config +โ”œโ”€โ”€ .env.example # Example environment variables +โ”œโ”€โ”€ .gitignore +โ”œโ”€โ”€ .npmrc +โ”œโ”€โ”€ .prettierrc.json # Prettier config +โ”œโ”€โ”€ eslint.config.js # ESLint v9 flat config +โ”œโ”€โ”€ tsconfig.json # Main TypeScript config +โ”œโ”€โ”€ tsconfig.eslint.json # TypeScript config for ESLint +โ”œโ”€โ”€ tsconfig.test.json # TypeScript config for tests +โ”œโ”€โ”€ vitest.config.ts # Vitest config +โ”œโ”€โ”€ pnpm-workspace.yaml # pnpm workspace config +โ”œโ”€โ”€ pnpm-lock.yaml +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ AGENTS.md # AI agent instructions +โ”œโ”€โ”€ CONTRIBUTING.md # Contribution guidelines +โ”œโ”€โ”€ CHANGELOG.md +โ”œโ”€โ”€ LICENSE +โ””โ”€โ”€ README.md +``` diff --git a/docs/Testing.md b/docs/Testing.md index 75f7c9de..74da0bb3 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -5,9 +5,9 @@ > **๐Ÿ’ก QUICK START:** Run `pnpm test` for all tests or `pnpm test:unit` for faster unit tests. -> **๐Ÿ“‹ MANDATORY:** All code changes require related tests. See [Workflow.mdc](mdc:.cursor/rules/Workflow.mdc). -> **๐Ÿ› DEBUGGING:** See [Debugging Guide](mdc:docs/Debugging.md) for troubleshooting tips and the [Debug Test Execution](#-debug-test-execution) section below. -> **๐Ÿ” TROUBLESHOOTING:** See [Troubleshooting Guide](mdc:docs/Troubleshooting.md) for common errors. +> **๐Ÿ“‹ MANDATORY:** All code changes require related tests. See [AGENTS.md](../AGENTS.md). +> **๐Ÿ› DEBUGGING:** See [Debugging Guide](Debugging.md) for troubleshooting tips and the [Debug Test Execution](#-debug-test-execution) section below. +> **๐Ÿ” TROUBLESHOOTING:** See [Troubleshooting Guide](Troubleshooting.md) for common errors. --- @@ -23,7 +23,7 @@ ## ๐Ÿƒโ€โ™‚๏ธ Running Tests -Refer to [.cursor/rules/Workflow.mdc](mdc:.cursor/rules/Workflow.mdc) for the mandatory requirement to run tests. +Refer to [AGENTS.md](../AGENTS.md) for the mandatory requirement to run tests. | Test Type | Command | Description | |-----------|---------|-------------| @@ -170,7 +170,7 @@ Everything else (exact countdown rendering, every internal state transition, con * **Value:** Good for testing command sequences (e.g., `config set` then `config get`), authentication flow logic (with mocked credentials), and ensuring different parts of the CLI work together correctly without relying on live Ably infrastructure. * **Tools:** Vitest, `@oclif/test`, `nock`, `execa` (to run the CLI as a subprocess). -Refer to the [Debugging Guide](mdc:docs/Debugging.md) for tips on debugging failed tests, including Playwright and Vitest tests. +Refer to the [Debugging Guide](Debugging.md) for tips on debugging failed tests, including Playwright and Vitest tests. ### ๐ŸŒ End-to-End (E2E) Tests (`test/e2e`) @@ -249,23 +249,45 @@ describe('channels commands', () => { ``` . -โ”œโ”€โ”€ src +โ”œโ”€โ”€ src/ โ”‚ โ””โ”€โ”€ commands/ +โ”œโ”€โ”€ packages/ +โ”‚ โ””โ”€โ”€ react-web-cli/ # @ably/react-web-cli (tests co-located with components) โ”œโ”€โ”€ test/ -โ”‚ โ”œโ”€โ”€ e2e/ # End-to-End tests (runs against real Ably) -โ”‚ โ”‚ โ”œโ”€โ”€ core/ # Core CLI functionality E2E tests -โ”‚ โ”‚ โ”œโ”€โ”€ channels/ # Channel-specific E2E tests -โ”‚ โ”‚ โ””โ”€โ”€ web-cli/ # Playwright tests for the Web CLI example -โ”‚ โ”‚ โ””โ”€โ”€ web-cli.test.ts -โ”‚ โ”œโ”€โ”€ helpers/ # Test helper functions (e.g., e2e-test-helper.ts) -โ”‚ โ”œโ”€โ”€ integration/ # Integration tests (mocked external services) -โ”‚ โ”‚ โ””โ”€โ”€ core/ -โ”‚ โ”œโ”€โ”€ unit/ # Unit tests (isolated logic, heavy mocking) -โ”‚ โ”‚ โ”œโ”€โ”€ base/ -โ”‚ โ”‚ โ”œโ”€โ”€ commands/ -โ”‚ โ”‚ โ””โ”€โ”€ services/ -โ”‚ โ”œโ”€โ”€ setup.ts # Full setup for E2E tests (runs in Vitest context) -โ”‚ โ””โ”€โ”€ mini-setup.ts # Minimal setup for Unit/Integration tests +โ”‚ โ”œโ”€โ”€ setup.ts # Global test setup (runs in Vitest context) +โ”‚ โ”œโ”€โ”€ root-hooks.ts # Root hooks for E2E test lifecycle +โ”‚ โ”œโ”€โ”€ helpers/ # Shared test utilities +โ”‚ โ”‚ โ”œโ”€โ”€ cli-runner.ts # CliRunner class for E2E process management +โ”‚ โ”‚ โ”œโ”€โ”€ cli-runner-store.ts # Per-test runner tracking +โ”‚ โ”‚ โ”œโ”€โ”€ command-helpers.ts # High-level E2E helpers +โ”‚ โ”‚ โ”œโ”€โ”€ e2e-test-helper.ts # E2E setup and teardown +โ”‚ โ”‚ โ”œโ”€โ”€ mock-ably-*.ts # Mock SDKs (chat, realtime, rest, spaces) +โ”‚ โ”‚ โ””โ”€โ”€ mock-config-manager.ts # MockConfigManager (provides test auth) +โ”‚ โ”œโ”€โ”€ unit/ # Fast, mocked tests +โ”‚ โ”‚ โ”œโ”€โ”€ base/ # Base command class tests +โ”‚ โ”‚ โ”œโ”€โ”€ base-command/ # AblyBaseCommand tests +โ”‚ โ”‚ โ”œโ”€โ”€ commands/ # Command unit tests (mirrors src/commands/) +โ”‚ โ”‚ โ”œโ”€โ”€ core/ # Core CLI functionality tests +โ”‚ โ”‚ โ”œโ”€โ”€ help/ # Help system tests +โ”‚ โ”‚ โ”œโ”€โ”€ hooks/ # Hook tests +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # Service tests +โ”‚ โ”‚ โ””โ”€โ”€ utils/ # Utility tests +โ”‚ โ”œโ”€โ”€ integration/ # Multi-component tests (mocked external services) +โ”‚ โ”‚ โ”œโ”€โ”€ commands/ # Command flow integration tests +โ”‚ โ”‚ โ””โ”€โ”€ interactive-mode.test.ts +โ”‚ โ”œโ”€โ”€ e2e/ # End-to-End tests (runs against real Ably) +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Auth E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ bench/ # Benchmark E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ channels/ # Channel E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ connections/ # Connection E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ control/ # Control API E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ core/ # Core CLI E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ interactive/ # Interactive mode E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ rooms/ # Chat rooms E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ spaces/ # Spaces E2E tests +โ”‚ โ”‚ โ”œโ”€โ”€ stats/ # Stats E2E tests +โ”‚ โ”‚ โ””โ”€โ”€ web-cli/ # Playwright browser tests for Web CLI +โ”‚ โ””โ”€โ”€ manual/ # Manual test scripts โ””โ”€โ”€ ... ``` @@ -293,5 +315,5 @@ E2E tests are organized by feature/topic (e.g., `channels-e2e.test.ts`, `presenc ---
-๐Ÿ” For detailed troubleshooting help, see the Troubleshooting Guide. +๐Ÿ” For detailed troubleshooting help, see the Troubleshooting Guide.
diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 0f53be3f..5f86dacd 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -6,7 +6,7 @@ This document provides solutions for common issues encountered when developing o ## Common Build and Testing Errors -### `.js` vs `.ts` Extension Issues +### Import Extension Issues **Problem**: Tests failing with errors about modules not being found or incorrect paths. @@ -16,15 +16,18 @@ Error: Cannot find module '../commands/publish' ``` **Solution**: -- Check import statements and ensure they reference `.ts` files, not `.js` files. -- When running tests, remember that imports in test files should use the `.ts` extension. +- This project uses `.js` extensions in all import paths (TypeScript with ES module resolution). +- Always use `.js` extensions in imports, even when importing `.ts` source files. ```typescript -// โŒ INCORRECT +// INCORRECT โ€” missing extension import MyCommand from '../../src/commands/my-command' -// โœ… CORRECT +// INCORRECT โ€” .ts extension import MyCommand from '../../src/commands/my-command.ts' + +// CORRECT โ€” .js extension (TypeScript resolves to .ts source) +import MyCommand from '../../src/commands/my-command.js' ``` --- @@ -151,13 +154,17 @@ afterEach(() => { **Problem**: CLI not using the expected configuration. **Solution**: -- Check your local configuration with: +- View your local configuration with: + ```bash + ably config show + ``` +- Find the config file location with: ```bash - ably config + ably config path ``` - Use environment variables to override config for testing: ```bash - ABLY_API_KEY=your_key ably channels:list + ABLY_API_KEY=your_key ably channels list ``` --- @@ -245,4 +252,4 @@ Property 'x' does not exist on type 'Y' If you find errors in documentation or rules, please update them using the proper workflow and submit a pull request. -See documentation in `.cursor/rules/Workflow.mdc` for more details on the development workflow. +See `AGENTS.md` for more details on the development workflow. diff --git a/docs/workplans/README.md b/docs/workplans/README.md deleted file mode 100644 index 3986c28e..00000000 --- a/docs/workplans/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Workplans - -This folder contains a list of workplans used by agents to deliver larger features. The workplans are maintained simply for future developers/agents to understand retrospectively how features were implemented. - -Each workplan file is a markdown file named with the date in the format [yyyy-mm-dd] and then with a title describing the plan. Each workplan file includes a list of tasks that are marked off when completed. - -## Example brief for Agent - -``` -Please look at all the tasks tagged with [feat/terminal-server-improvements] and write up a plan to implement all of the tagged features. - -Note: - -1) Your job is not to implement this yet, your job is to understand the code base fully, the features, and come up with a robust plan that allows another agent to follow the tasks and implement this all interatively and logically in groups of functionality. -2) Your plan must abide by all the best practices and requirements defined in the .cursor/rules and /docs folder. Make sure you have read every single file in @rules and @docs and any associated files that they reference that are relevant before you propose a plan. -3) Consider what test coverage is needed and include that in the plan. -4) Document your plan in the the docs/workplans folder with a file named and structured as described in @README.md, and ensure all documentation and rules, where applicable, are updated as you proceed through the plan. - -You need to use maximum effort researching this code base and the requested features so that we have a solid plan that can be exectued in steps and committed to git in each stage. -``` diff --git a/docs/workplans/resources/2025-05-cli-drawer.tsx b/docs/workplans/resources/2025-05-cli-drawer.tsx deleted file mode 100644 index c35b45a9..00000000 --- a/docs/workplans/resources/2025-05-cli-drawer.tsx +++ /dev/null @@ -1,170 +0,0 @@ -"use client" - -import type React from "react" - -import { useEffect, useRef, useState } from "react" -import { X } from "lucide-react" -import { cn } from "@/lib/utils" - -interface CliDrawerProps { - // This would be your actual terminal component - TerminalComponent?: React.ComponentType -} - -export function CliDrawer({ TerminalComponent }: CliDrawerProps) { - const [isOpen, setIsOpen] = useState(false) - const [height, setHeight] = useState(0) - const [isDragging, setIsDragging] = useState(false) - const [startY, setStartY] = useState(0) - const [startHeight, setStartHeight] = useState(0) - const drawerRef = useRef(null) - const dragHandleRef = useRef(null) - - // Initialize drawer with saved height or default 50% of viewport height - useEffect(() => { - const savedHeight = localStorage.getItem("ablyCliDrawerHeight") - const defaultHeight = window.innerHeight * 0.5 - const initialHeight = savedHeight ? Number.parseInt(savedHeight) : defaultHeight - - setHeight(initialHeight) - }, []) - - // Save height preference when drawer is closed - useEffect(() => { - if (!isOpen && height > 0) { - localStorage.setItem("ablyCliDrawerHeight", height.toString()) - } - }, [isOpen, height]) - - // Handle mouse events for resizing - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging) return - - const deltaY = e.clientY - startY - const newHeight = Math.max(200, Math.min(window.innerHeight * 0.8, startHeight - deltaY)) - - setHeight(newHeight) - } - - const handleMouseUp = () => { - if (isDragging) { - setIsDragging(false) - document.body.style.cursor = "default" - localStorage.setItem("ablyCliDrawerHeight", height.toString()) - } - } - - if (isDragging) { - document.addEventListener("mousemove", handleMouseMove) - document.addEventListener("mouseup", handleMouseUp) - } - - return () => { - document.removeEventListener("mousemove", handleMouseMove) - document.removeEventListener("mouseup", handleMouseUp) - } - }, [isDragging, startY, startHeight, height]) - - const handleDragStart = (e: React.MouseEvent) => { - setIsDragging(true) - setStartY(e.clientY) - setStartHeight(height) - document.body.style.cursor = "ns-resize" - } - - const toggleDrawer = () => { - setIsOpen(!isOpen) - } - - // Terminal icon component embedded directly in this file - const TerminalIcon = ({ className }: { className?: string }) => ( -
- {">"}_ -
- ) - - return ( - <> - {/* Tab button when drawer is closed */} - {!isOpen && ( - - )} - - {/* Drawer */} -
- {/* Drag handle - more prominent as in the original design */} -
-
-
- - {/* Header bar */} -
-
- - Ably Shell - TEST MODE -
- -
- - {/* Terminal content area */} -
- {TerminalComponent ? ( - - ) : ( -
-

Welcome to the Ably Shell.

-

- A browser-based shell with the Ably CLI pre-installed. Log in to your Ably account and press Control + - Backtick (`) on your keyboard to start managing your Ably resources in test mode. -

-

- - View supported Ably commands: ably help{" "} - โ–บ -

-

- - Find webhook events: ably trigger{" "} - โ–บ [event] -

-

- - Listen for webhook events: ably listen{" "} - โ–บ -

-

- - Call Ably APIs: ably [api resource] [operation] (e.g.,{" "} - ably customers list โ–บ) -

-

$

-
- )} -
-
- - ) -} diff --git a/test/unit/commands/accounts/current.test.ts b/test/unit/commands/accounts/current.test.ts new file mode 100644 index 00000000..1c9f2bf1 --- /dev/null +++ b/test/unit/commands/accounts/current.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("accounts:current command", () => { + const mockAccountId = "test-account-id"; + const mockAccountName = "Test Account"; + const mockUserEmail = "test@example.com"; + + beforeEach(() => { + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("displays account info", () => { + it("should display account info from getMe() API call", async () => { + const mock = getMockConfigManager(); + const accessToken = mock.getAccessToken()!; + + nock("https://control.ably.net") + .get("/v1/me") + .matchHeader("authorization", `Bearer ${accessToken}`) + .reply(200, { + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, + }); + + const { stdout } = await runCommand( + ["accounts:current"], + import.meta.url, + ); + + expect(stdout).toContain("Account:"); + expect(stdout).toContain(mockAccountName); + expect(stdout).toContain(mockAccountId); + expect(stdout).toContain("User:"); + expect(stdout).toContain(mockUserEmail); + }); + + it("should display current app and key info", async () => { + const mock = getMockConfigManager(); + const accessToken = mock.getAccessToken()!; + + nock("https://control.ably.net") + .get("/v1/me") + .matchHeader("authorization", `Bearer ${accessToken}`) + .reply(200, { + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, + }); + + const { stdout } = await runCommand( + ["accounts:current"], + import.meta.url, + ); + + // The mock config has an app and key configured + expect(stdout).toContain("Current App:"); + expect(stdout).toContain("Current API Key:"); + }); + }); + + describe("fallback behavior", () => { + it("should show cached info when API fails", async () => { + nock("https://control.ably.net") + .get("/v1/me") + .replyWithError("Network error"); + + const { stdout, stderr } = await runCommand( + ["accounts:current"], + import.meta.url, + ); + + const combined = stdout + stderr; + expect(combined).toMatch(/Unable to verify|expired/i); + expect(combined).toContain("cached"); + }); + + it("should suggest re-login on failure", async () => { + nock("https://control.ably.net") + .get("/v1/me") + .replyWithError("Network error"); + + const { stdout, stderr } = await runCommand( + ["accounts:current"], + import.meta.url, + ); + + const combined = stdout + stderr; + expect(combined).toContain("ably accounts login"); + }); + }); + + describe("error handling", () => { + it("should error when no account is selected", async () => { + const mock = getMockConfigManager(); + mock.setCurrentAccountAlias(undefined); + + const { error } = await runCommand(["accounts:current"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No account.*currently selected/i); + }); + }); + + describe("web-cli mode restriction", () => { + let originalWebCliMode: string | undefined; + + beforeEach(() => { + originalWebCliMode = process.env.ABLY_WEB_CLI_MODE; + }); + + afterEach(() => { + if (originalWebCliMode === undefined) { + delete process.env.ABLY_WEB_CLI_MODE; + } else { + process.env.ABLY_WEB_CLI_MODE = originalWebCliMode; + } + }); + + it("should be restricted in web-cli mode", async () => { + process.env.ABLY_WEB_CLI_MODE = "true"; + + const { error } = await runCommand(["accounts:current"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toContain("not available in the web CLI"); + }); + }); +}); diff --git a/test/unit/commands/accounts/list.test.ts b/test/unit/commands/accounts/list.test.ts new file mode 100644 index 00000000..7e560edb --- /dev/null +++ b/test/unit/commands/accounts/list.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("accounts:list command", () => { + beforeEach(() => { + // Config is auto-reset by setup.ts + }); + + describe("no accounts", () => { + it("should show message when no accounts configured", async () => { + const mock = getMockConfigManager(); + mock.clearAccounts(); + + const { stdout } = await runCommand(["accounts:list"], import.meta.url); + + expect(stdout).toContain("No accounts configured"); + expect(stdout).toContain("ably accounts login"); + }); + + it("should output JSON error when no accounts with --json", async () => { + const mock = getMockConfigManager(); + mock.clearAccounts(); + + const { stdout } = await runCommand( + ["accounts:list", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("accounts"); + expect(result.accounts).toEqual([]); + }); + }); + + describe("with accounts", () => { + it("should display accounts with current marker", async () => { + const { stdout } = await runCommand(["accounts:list"], import.meta.url); + + expect(stdout).toContain("Found"); + expect(stdout).toContain("accounts:"); + expect(stdout).toContain("(current)"); + }); + + it("should show app count per account", async () => { + const { stdout } = await runCommand(["accounts:list"], import.meta.url); + + expect(stdout).toContain("Apps configured:"); + }); + + it("should output JSON with isCurrent flag", async () => { + const { stdout } = await runCommand( + ["accounts:list", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("accounts"); + expect(result.accounts.length).toBeGreaterThan(0); + + const currentAccount = result.accounts.find( + (a: { isCurrent: boolean }) => a.isCurrent, + ); + expect(currentAccount).toBeDefined(); + expect(currentAccount.isCurrent).toBe(true); + expect(currentAccount).toHaveProperty("alias"); + expect(currentAccount).toHaveProperty("appsConfigured"); + }); + }); +}); diff --git a/test/unit/commands/accounts/switch.test.ts b/test/unit/commands/accounts/switch.test.ts new file mode 100644 index 00000000..820bd1d5 --- /dev/null +++ b/test/unit/commands/accounts/switch.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("accounts:switch command", () => { + const mockAccountId = "switch-account-id"; + const mockAccountName = "Switch Account"; + const mockUserEmail = "switch@example.com"; + + beforeEach(() => { + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe("switching accounts", () => { + it("should switch to existing alias", async () => { + const mock = getMockConfigManager(); + + // Add a second account to switch to + mock.storeAccount("token_second", "second", { + accountId: mockAccountId, + accountName: mockAccountName, + userEmail: mockUserEmail, + }); + + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: mockAccountName }, + user: { email: mockUserEmail }, + }); + + const { stdout } = await runCommand( + ["accounts:switch", "second"], + import.meta.url, + ); + + expect(stdout).toContain("Switched to account:"); + expect(stdout).toContain(mockAccountName); + expect(stdout).toContain(mockUserEmail); + }); + + it("should error on nonexistent alias", async () => { + const { error } = await runCommand( + ["accounts:switch", "nonexistent-alias"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("not found"); + expect(error!.message).toContain("ably accounts list"); + }); + }); + + describe("no accounts configured", () => { + it("should output message about no accounts when none configured", async () => { + const mock = getMockConfigManager(); + mock.clearAccounts(); + + // Use --json to avoid the interactive login redirect which times out + const { stdout } = await runCommand( + ["accounts:switch", "any-alias", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result.error).toContain("No accounts configured"); + }); + + it("should output JSON error when no accounts with --json", async () => { + const mock = getMockConfigManager(); + mock.clearAccounts(); + + const { stdout } = await runCommand( + ["accounts:switch", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + expect(result.error).toContain("No accounts configured"); + }); + }); + + describe("JSON output", () => { + it("should output JSON error when invalid alias with available accounts", async () => { + const { stdout } = await runCommand( + ["accounts:switch", "nonexistent", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + expect(result.error).toContain("not found"); + expect(result).toHaveProperty("availableAccounts"); + expect(result.availableAccounts).toBeInstanceOf(Array); + }); + + it("should warn on expired token but still switch", async () => { + const mock = getMockConfigManager(); + + // Add a second account + mock.storeAccount("token_expired", "expired-acct", { + accountId: "expired-id", + accountName: "Expired Account", + userEmail: "expired@example.com", + }); + + nock("https://control.ably.net") + .get("/v1/me") + .reply(401, { error: "Unauthorized" }); + + const { stdout, stderr } = await runCommand( + ["accounts:switch", "expired-acct"], + import.meta.url, + ); + + const combined = stdout + stderr; + expect(combined).toMatch(/expired|invalid/i); + expect(combined).toContain("ably accounts login"); + + // Verify the account was actually switched despite the 401 + expect(mock.getCurrentAccountAlias()).toBe("expired-acct"); + }); + }); +}); diff --git a/test/unit/commands/logs/history.test.ts b/test/unit/commands/logs/history.test.ts new file mode 100644 index 00000000..808d6c64 --- /dev/null +++ b/test/unit/commands/logs/history.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; + +describe("logs:history command", () => { + beforeEach(() => { + getMockAblyRest(); + }); + + describe("history retrieval", () => { + it("should pass --start to history params", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + channel.history.mockResolvedValue({ items: [] }); + + const start = "2023-06-01T00:00:00Z"; + await runCommand(["logs:history", "--start", start], import.meta.url); + + expect(channel.history).toHaveBeenCalledWith( + expect.objectContaining({ + start: new Date(start).getTime(), + }), + ); + }); + + it("should error when --start is after --end", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + channel.history.mockResolvedValue({ items: [] }); + + const { error } = await runCommand( + [ + "logs:history", + "--start", + "2023-06-02T00:00:00Z", + "--end", + "2023-06-01T00:00:00Z", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain( + "--start must be earlier than or equal to --end", + ); + }); + + it("should pass --direction to history params", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + channel.history.mockResolvedValue({ items: [] }); + + await runCommand( + ["logs:history", "--direction", "forwards"], + import.meta.url, + ); + + expect(channel.history).toHaveBeenCalledWith( + expect.objectContaining({ direction: "forwards" }), + ); + }); + + it("should default to backwards direction", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + channel.history.mockResolvedValue({ items: [] }); + + await runCommand(["logs:history"], import.meta.url); + + expect(channel.history).toHaveBeenCalledWith( + expect.objectContaining({ direction: "backwards" }), + ); + }); + + it("should pass --limit to history params", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + channel.history.mockResolvedValue({ items: [] }); + + await runCommand(["logs:history", "--limit", "50"], import.meta.url); + + expect(channel.history).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }), + ); + }); + + it("should show warning when results equal limit", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + + // Create exactly 10 mock messages to match limit + const messages = Array.from({ length: 10 }, (_, i) => ({ + id: `msg-${i}`, + name: "test.event", + data: { info: `message ${i}` }, + timestamp: 1700000000000 + i * 1000, + clientId: "client-1", + connectionId: "conn-1", + })); + channel.history.mockResolvedValue({ items: messages }); + + const { stdout } = await runCommand( + ["logs:history", "--limit", "10"], + import.meta.url, + ); + + expect(stdout).toContain("Showing maximum of 10 logs"); + expect(stdout).toContain("Use --limit to show more"); + }); + + it("should show 'No application logs found' on empty results", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("[meta]log"); + channel.history.mockResolvedValue({ items: [] }); + + const { stdout } = await runCommand(["logs:history"], import.meta.url); + + expect(stdout).toContain("No application logs found"); + }); + }); +}); diff --git a/test/unit/commands/logs/push/subscribe.test.ts b/test/unit/commands/logs/push/subscribe.test.ts new file mode 100644 index 00000000..190bef8c --- /dev/null +++ b/test/unit/commands/logs/push/subscribe.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; + +describe("logs:push:subscribe command", () => { + beforeEach(() => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]log:push"); + + // Configure connection.once to immediately call callback for 'connected' + mock.connection.once.mockImplementation( + (event: string, callback: () => void) => { + if (event === "connected") { + callback(); + } + }, + ); + + // Configure channel.once to immediately call callback for 'attached' + channel.once.mockImplementation((event: string, callback: () => void) => { + if (event === "attached") { + channel.state = "attached"; + callback(); + } + }); + }); + + describe("subscription", () => { + it("should subscribe to [meta]log:push channel", async () => { + const mock = getMockAblyRealtime(); + + await runCommand( + ["logs:push:subscribe", "--duration", "0"], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith( + "[meta]log:push", + expect.any(Object), + ); + }); + + it("should handle messages with severity field", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]log:push"); + + // Track subscribe calls and invoke callback with a test message + channel.subscribe.mockImplementation( + (callback: (msg: unknown) => void) => { + channel.state = "attached"; + callback({ + name: "push.sent", + timestamp: 1700000000000, + data: { severity: "warning", message: "Push delivery delayed" }, + }); + }, + ); + + const { stdout } = await runCommand( + ["logs:push:subscribe", "--duration", "0"], + import.meta.url, + ); + + // Verify subscribe was called and message was rendered + expect(channel.subscribe).toHaveBeenCalled(); + expect(stdout).toContain("[meta]log:push"); + expect(stdout).toContain("push.sent"); + expect(stdout).toContain("Push delivery delayed"); + }); + + it("should set rewind channel param when --rewind > 0", async () => { + const mock = getMockAblyRealtime(); + + await runCommand( + ["logs:push:subscribe", "--rewind", "5", "--duration", "0"], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("[meta]log:push", { + params: { rewind: "5" }, + }); + }); + }); +}); diff --git a/test/unit/commands/logs/subscribe.test.ts b/test/unit/commands/logs/subscribe.test.ts index 055c4920..5c508333 100644 --- a/test/unit/commands/logs/subscribe.test.ts +++ b/test/unit/commands/logs/subscribe.test.ts @@ -93,6 +93,63 @@ describe("logs:subscribe command", () => { }); }); + describe("rewind and type filtering", () => { + it("should set rewind channel param when --rewind > 0", async () => { + const mock = getMockAblyRealtime(); + + await runCommand( + ["logs:subscribe", "--rewind", "5", "--duration", "0"], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("[meta]log", { + params: { rewind: "5" }, + }); + }); + + it("should subscribe only to --type when provided", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]log"); + + await runCommand( + ["logs:subscribe", "--type", "channel.presence", "--duration", "0"], + import.meta.url, + ); + + // Should only subscribe to the specified type + expect(channel.subscribe).toHaveBeenCalledWith( + "channel.presence", + expect.any(Function), + ); + // Should NOT subscribe to other types + const subscribeCalls = channel.subscribe.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string", + ); + expect(subscribeCalls).toHaveLength(1); + expect(subscribeCalls[0][0]).toBe("channel.presence"); + }); + + it("should subscribe to all log types without --type", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]log"); + + await runCommand(["logs:subscribe", "--duration", "0"], import.meta.url); + + // Should subscribe to all 5 default log types + const subscribeCalls = channel.subscribe.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string", + ); + expect(subscribeCalls.length).toBe(5); + + const subscribedTypes = subscribeCalls.map((call: unknown[]) => call[0]); + expect(subscribedTypes).toContain("channel.lifecycle"); + expect(subscribedTypes).toContain("channel.occupancy"); + expect(subscribedTypes).toContain("channel.presence"); + expect(subscribedTypes).toContain("connection.lifecycle"); + expect(subscribedTypes).toContain("push.publish"); + }); + }); + describe("error handling", () => { it("should handle missing mock client in test mode", async () => { // Clear the realtime mock diff --git a/test/unit/commands/rooms/list.test.ts b/test/unit/commands/rooms/list.test.ts new file mode 100644 index 00000000..87c7ba0a --- /dev/null +++ b/test/unit/commands/rooms/list.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; + +describe("rooms:list command", () => { + const mockChatChannelsResponse = { + statusCode: 200, + items: [ + { + channelId: "room1::$chat::$chatMessages", + status: { + occupancy: { + metrics: { connections: 5, publishers: 2, subscribers: 3 }, + }, + }, + }, + { + channelId: "room1::$chat::$chatMessages::$reactions", + status: { + occupancy: { metrics: { connections: 5 } }, + }, + }, + { + channelId: "room1::$chat::$typingIndicators", + status: { + occupancy: { metrics: { connections: 3 } }, + }, + }, + { + channelId: "room2::$chat::$chatMessages", + status: { + occupancy: { + metrics: { connections: 2, publishers: 1, subscribers: 0 }, + }, + }, + }, + { + channelId: "regular-channel", + status: { + occupancy: { metrics: { connections: 1 } }, + }, + }, + ], + }; + + beforeEach(() => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue(mockChatChannelsResponse); + }); + + it("should filter to ::$chat channels only", async () => { + const { stdout } = await runCommand(["rooms:list"], import.meta.url); + + expect(stdout).toContain("room1"); + expect(stdout).toContain("room2"); + expect(stdout).not.toContain("regular-channel"); + }); + + it("should deduplicate rooms from sub-channels", async () => { + const { stdout } = await runCommand(["rooms:list"], import.meta.url); + + // room1 has 3 sub-channels but should appear only once in the count + expect(stdout).toContain("2"); + expect(stdout).toContain("active chat rooms"); + }); + + it("should extract room name from channel ID", async () => { + const { stdout } = await runCommand(["rooms:list"], import.meta.url); + + // Should show "room1" not the full channel ID + expect(stdout).toContain("room1"); + expect(stdout).not.toContain("::$chat::$chatMessages"); + }); + + it("should truncate to --limit", async () => { + const { stdout } = await runCommand( + ["rooms:list", "--limit", "1"], + import.meta.url, + ); + + expect(stdout).toContain("room1"); + expect(stdout).not.toContain("room2"); + }); + + it("should pass prefix to API", async () => { + const mock = getMockAblyRest(); + + await runCommand(["rooms:list", "--prefix", "room1"], import.meta.url); + + expect(mock.request).toHaveBeenCalledOnce(); + expect(mock.request.mock.calls[0][3]).toHaveProperty("prefix", "room1"); + }); + + it("should show 'No active chat rooms' on empty response", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ statusCode: 200, items: [] }); + + const { stdout } = await runCommand(["rooms:list"], import.meta.url); + + expect(stdout).toContain("No active chat rooms found"); + }); + + it("should display occupancy metrics when present", async () => { + const { stdout } = await runCommand(["rooms:list"], import.meta.url); + + expect(stdout).toContain("Connections:"); + expect(stdout).toContain("Publishers:"); + expect(stdout).toContain("Subscribers:"); + }); + + it("should output JSON with items array", async () => { + const { stdout } = await runCommand( + ["rooms:list", "--json"], + import.meta.url, + ); + + const json = JSON.parse(stdout); + expect(json).toHaveProperty("items"); + expect(json.items).toBeInstanceOf(Array); + expect(json.items.length).toBe(2); + expect(json.items[0]).toHaveProperty("room", "room1"); + expect(json.items[1]).toHaveProperty("room", "room2"); + }); + + it("should handle non-200 response with error", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ statusCode: 400, error: "Bad Request" }); + + const { error } = await runCommand(["rooms:list"], import.meta.url); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Failed to list rooms"); + }); +}); diff --git a/test/unit/commands/rooms/occupancy/get.test.ts b/test/unit/commands/rooms/occupancy/get.test.ts new file mode 100644 index 00000000..61be65e4 --- /dev/null +++ b/test/unit/commands/rooms/occupancy/get.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; + +describe("rooms:occupancy:get command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + describe("occupancy retrieval", () => { + it("should display occupancy metrics", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.occupancy.get.mockResolvedValue({ + connections: 5, + presenceMembers: 3, + }); + + const { stdout } = await runCommand( + ["rooms:occupancy:get", "test-room"], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.occupancy.get).toHaveBeenCalled(); + expect(stdout).toContain("Connections: 5"); + expect(stdout).toContain("Presence Members: 3"); + }); + + it("should handle zero metrics", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.occupancy.get.mockResolvedValue({ + connections: 0, + presenceMembers: 0, + }); + + const { stdout } = await runCommand( + ["rooms:occupancy:get", "test-room"], + import.meta.url, + ); + + expect(stdout).toContain("Connections: 0"); + expect(stdout).toContain("Presence Members: 0"); + }); + + it("should output JSON with metrics", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.occupancy.get.mockResolvedValue({ + connections: 10, + presenceMembers: 7, + }); + + const { stdout } = await runCommand( + ["rooms:occupancy:get", "test-room", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("room", "test-room"); + expect(result).toHaveProperty("metrics"); + expect(result.metrics.connections).toBe(10); + expect(result.metrics.presenceMembers).toBe(7); + }); + + it("should output JSON error on failure", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.attach.mockImplementation(async () => { + throw new Error("Room attach timeout"); + }); + + const { stdout } = await runCommand( + ["rooms:occupancy:get", "test-room", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + }); + }); +}); diff --git a/test/unit/commands/rooms/occupancy/subscribe.test.ts b/test/unit/commands/rooms/occupancy/subscribe.test.ts new file mode 100644 index 00000000..fcf03903 --- /dev/null +++ b/test/unit/commands/rooms/occupancy/subscribe.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; + +describe("rooms:occupancy:subscribe command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + describe("subscription behavior", () => { + it("should display initial occupancy snapshot", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.occupancy.get.mockResolvedValue({ + connections: 3, + presenceMembers: 1, + }); + + const { stdout } = await runCommand( + ["rooms:occupancy:subscribe", "test-room", "--duration", "0"], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.occupancy.get).toHaveBeenCalled(); + expect(stdout).toContain("Initial occupancy"); + expect(stdout).toContain("Connections: 3"); + }); + + it("should warn on initial fetch failure but continue listening", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.occupancy.get.mockRejectedValue(new Error("Fetch failed")); + + const { stdout } = await runCommand( + ["rooms:occupancy:subscribe", "test-room", "--duration", "0"], + import.meta.url, + ); + + expect(stdout).toContain("Failed to fetch initial occupancy"); + expect(stdout).toContain("Listening"); + }); + + it("should subscribe and display updates", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + let occupancyCallback: ((event: unknown) => void) | null = null; + room.occupancy.subscribe.mockImplementation((callback) => { + occupancyCallback = callback; + return { unsubscribe: vi.fn() }; + }); + + const commandPromise = runCommand( + ["rooms:occupancy:subscribe", "test-room", "--duration", "0"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(room.occupancy.subscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + if (occupancyCallback) { + occupancyCallback({ + occupancy: { connections: 8, presenceMembers: 4 }, + }); + } + + await commandPromise; + logSpy.mockRestore(); + + expect(room.occupancy.subscribe).toHaveBeenCalled(); + }); + + it("should output JSON with type field", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.occupancy.get.mockResolvedValue({ + connections: 2, + presenceMembers: 0, + }); + + const capturedLogs: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + await runCommand( + ["rooms:occupancy:subscribe", "test-room", "--json", "--duration", "0"], + import.meta.url, + ); + + logSpy.mockRestore(); + + // Find the JSON output with initial snapshot + const jsonLines = capturedLogs.filter((line) => { + try { + const parsed = JSON.parse(line); + return parsed.type === "initialSnapshot"; + } catch { + return false; + } + }); + + expect(jsonLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(jsonLines[0]); + expect(parsed).toHaveProperty("type", "initialSnapshot"); + expect(parsed).toHaveProperty("room", "test-room"); + }); + }); +}); diff --git a/test/unit/commands/rooms/presence/enter.test.ts b/test/unit/commands/rooms/presence/enter.test.ts new file mode 100644 index 00000000..8342d83a --- /dev/null +++ b/test/unit/commands/rooms/presence/enter.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; + +describe("rooms:presence:enter command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + it("should enter presence in room", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + await runCommand(["rooms:presence:enter", "test-room"], import.meta.url); + + expect(mock.rooms.get).toHaveBeenCalledWith("test-room"); + expect(room.attach).toHaveBeenCalled(); + expect(room.presence.enter).toHaveBeenCalled(); + }); + + it("should pass parsed --data to presence.enter", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + await runCommand( + ["rooms:presence:enter", "test-room", "--data", '{"status":"online"}'], + import.meta.url, + ); + + expect(room.presence.enter).toHaveBeenCalledWith({ status: "online" }); + }); + + it("should strip shell quotes from --data", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + await runCommand( + [ + "rooms:presence:enter", + "test-room", + "--data", + '\'{"status":"online"}\'', + ], + import.meta.url, + ); + + expect(room.presence.enter).toHaveBeenCalledWith({ status: "online" }); + }); + + it("should error on invalid --data JSON", async () => { + const { error } = await runCommand( + ["rooms:presence:enter", "test-room", "--data", "not-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Invalid data JSON"); + }); + + it("should subscribe to presence events with --show-others", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + + await runCommand( + ["rooms:presence:enter", "test-room", "--show-others", "--duration", "0"], + import.meta.url, + ); + + expect(room.presence.subscribe).toHaveBeenCalled(); + }); + + it("should filter out self events by clientId with --show-others", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + let presenceCallback: ((event: unknown) => void) | null = null; + room.presence.subscribe.mockImplementation((callback) => { + presenceCallback = callback; + return { unsubscribe: vi.fn() }; + }); + + const commandPromise = runCommand( + ["rooms:presence:enter", "test-room", "--show-others", "--duration", "0"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(room.presence.subscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a self event (should be filtered out) + if (presenceCallback) { + presenceCallback({ + type: "enter", + member: { + clientId: mock.clientId, + data: {}, + }, + }); + + // Simulate another user's event (should be shown) + presenceCallback({ + type: "enter", + member: { + clientId: "other-user", + data: {}, + }, + }); + } + + await commandPromise; + logSpy.mockRestore(); + + const output = capturedLogs.join("\n"); + expect(output).toContain("other-user"); + expect(output).not.toContain(mock.clientId); + }); + + it("should output JSON on enter success", async () => { + const mock = getMockAblyChat(); + const room = mock.rooms._getRoom("test-room"); + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + let presenceCallback: ((event: unknown) => void) | null = null; + room.presence.subscribe.mockImplementation((callback) => { + presenceCallback = callback; + return { unsubscribe: vi.fn() }; + }); + + const commandPromise = runCommand( + [ + "rooms:presence:enter", + "test-room", + "--show-others", + "--json", + "--duration", + "0", + ], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(room.presence.subscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a presence event from another user + if (presenceCallback) { + presenceCallback({ + type: "enter", + member: { + clientId: "other-user", + data: { status: "online" }, + }, + }); + } + + await commandPromise; + logSpy.mockRestore(); + + // Find the JSON output with presence data + const jsonLines = capturedLogs.filter((line) => { + try { + const parsed = JSON.parse(line); + return parsed.type && parsed.member; + } catch { + return false; + } + }); + + expect(jsonLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(jsonLines[0]); + expect(parsed).toHaveProperty("success", true); + expect(parsed).toHaveProperty("type", "enter"); + expect(parsed.member).toHaveProperty("clientId", "other-user"); + }); +}); diff --git a/test/unit/commands/rooms/reactions/send.test.ts b/test/unit/commands/rooms/reactions/send.test.ts new file mode 100644 index 00000000..cc7747bf --- /dev/null +++ b/test/unit/commands/rooms/reactions/send.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; + +describe("rooms:reactions:send command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + describe("sending reactions", () => { + it("should send reaction emoji", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + const { stdout } = await runCommand( + ["rooms:reactions:send", "test-room", "thumbsup"], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.reactions.send).toHaveBeenCalledWith( + expect.objectContaining({ name: "thumbsup" }), + ); + expect(stdout).toContain("Sent reaction"); + expect(stdout).toContain("thumbsup"); + }); + + it("should parse and forward --metadata JSON", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + await runCommand( + [ + "rooms:reactions:send", + "test-room", + "heart", + "--metadata", + '{"color":"red"}', + ], + import.meta.url, + ); + + expect(room.reactions.send).toHaveBeenCalledWith( + expect.objectContaining({ + name: "heart", + metadata: { color: "red" }, + }), + ); + }); + + it("should error on invalid --metadata", async () => { + const { error } = await runCommand( + [ + "rooms:reactions:send", + "test-room", + "heart", + "--metadata", + "not-json", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Invalid metadata JSON"); + }); + + it("should output JSON on success", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + const { stdout } = await runCommand( + ["rooms:reactions:send", "test-room", "fire", "--json"], + import.meta.url, + ); + + expect(room.reactions.send).toHaveBeenCalled(); + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("emoji", "fire"); + expect(result).toHaveProperty("room", "test-room"); + }); + + it("should output JSON error on send failure", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + room.reactions.send.mockRejectedValue(new Error("Send failed")); + + const { stdout } = await runCommand( + ["rooms:reactions:send", "test-room", "fire", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + expect(result.error).toContain("Send failed"); + }); + }); +}); diff --git a/test/unit/commands/rooms/typing/keystroke.test.ts b/test/unit/commands/rooms/typing/keystroke.test.ts new file mode 100644 index 00000000..c6124aa5 --- /dev/null +++ b/test/unit/commands/rooms/typing/keystroke.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyChat } from "../../../../helpers/mock-ably-chat.js"; + +describe("rooms:typing:keystroke command", () => { + beforeEach(() => { + getMockAblyChat(); + }); + + describe("typing keystroke", () => { + it("should send keystroke and show success", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + const { stdout } = await runCommand( + ["rooms:typing:keystroke", "test-room"], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.typing.keystroke).toHaveBeenCalled(); + expect(stdout).toContain("Started typing"); + }); + + it("should handle --auto-type flag", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + const { stdout } = await runCommand( + [ + "rooms:typing:keystroke", + "test-room", + "--auto-type", + "--duration", + "0", + ], + import.meta.url, + ); + + expect(room.attach).toHaveBeenCalled(); + expect(room.typing.keystroke).toHaveBeenCalled(); + expect(stdout).toContain("automatically"); + }); + + it("should handle keystroke failure", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.attach.mockImplementation(async () => { + throw new Error("Connection failed"); + }); + + const { error } = await runCommand( + ["rooms:typing:keystroke", "test-room"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Connection failed"); + }); + + it("should output JSON error on failure", async () => { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.attach.mockImplementation(async () => { + throw new Error("Connection failed"); + }); + + const { stdout } = await runCommand( + ["rooms:typing:keystroke", "test-room", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + expect(result.error).toContain("Connection failed"); + }); + }); +}); diff --git a/test/unit/commands/spaces/cursors/set.test.ts b/test/unit/commands/spaces/cursors/set.test.ts new file mode 100644 index 00000000..597e95ac --- /dev/null +++ b/test/unit/commands/spaces/cursors/set.test.ts @@ -0,0 +1,154 @@ +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"; + +describe("spaces:cursors:set command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:cursors:set"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should error when no position input provided", async () => { + const { error } = await runCommand( + ["spaces:cursors:set", "test-space"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Cursor position is required"); + }); + }); + + describe("cursor data validation", () => { + it("should error on invalid --data JSON", async () => { + const { error } = await runCommand( + ["spaces:cursors:set", "test-space", "--data", "not-valid-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Invalid JSON"); + }); + + it("should error when --data missing position.x/y", async () => { + const { error } = await runCommand( + [ + "spaces:cursors:set", + "test-space", + "--data", + '{"position":{"x":"not-a-number"}}', + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Invalid cursor position"); + }); + }); + + describe("setting cursor position", () => { + it("should set cursor with --x and --y flags", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:cursors:set", "test-space", "--x", "100", "--y", "200"], + import.meta.url, + ); + + expect(space.enter).toHaveBeenCalled(); + expect(space.cursors.set).toHaveBeenCalledWith( + expect.objectContaining({ + position: { x: 100, y: 200 }, + }), + ); + expect(stdout).toContain("Set cursor"); + expect(stdout).toContain("test-space"); + }); + + it("should set cursor from --data with position object", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand( + [ + "spaces:cursors:set", + "test-space", + "--data", + '{"position":{"x":50,"y":75}}', + ], + import.meta.url, + ); + + expect(space.cursors.set).toHaveBeenCalledWith( + expect.objectContaining({ + position: { x: 50, y: 75 }, + }), + ); + }); + + it("should merge --data with --x/--y as additional cursor data", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand( + [ + "spaces:cursors:set", + "test-space", + "--x", + "100", + "--y", + "200", + "--data", + '{"color":"#ff0000"}', + ], + import.meta.url, + ); + + expect(space.cursors.set).toHaveBeenCalledWith( + expect.objectContaining({ + position: { x: 100, y: 200 }, + data: { color: "#ff0000" }, + }), + ); + }); + }); + + describe("JSON output", () => { + it("should output JSON on success", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + [ + "spaces:cursors:set", + "test-space", + "--x", + "100", + "--y", + "200", + "--json", + ], + import.meta.url, + ); + + expect(stdout).toContain('"success"'); + expect(stdout).toContain("true"); + expect(stdout).toContain("test-space"); + expect(stdout).toContain('"x": 100'); + expect(stdout).toContain('"y": 200'); + }); + }); +}); diff --git a/test/unit/commands/spaces/list.test.ts b/test/unit/commands/spaces/list.test.ts new file mode 100644 index 00000000..44d02b05 --- /dev/null +++ b/test/unit/commands/spaces/list.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; + +describe("spaces:list command", () => { + const mockSpaceChannelsResponse = { + statusCode: 200, + items: [ + { + channelId: "space1::$space::$locks", + status: { + occupancy: { + metrics: { connections: 3, publishers: 1, subscribers: 2 }, + }, + }, + }, + { + channelId: "space1::$space::$cursors", + status: { + occupancy: { metrics: { connections: 2 } }, + }, + }, + { + channelId: "space2::$space::$locks", + status: { + occupancy: { + metrics: { connections: 1, publishers: 0, subscribers: 1 }, + }, + }, + }, + { + channelId: "regular-channel", + status: { + occupancy: { metrics: { connections: 1 } }, + }, + }, + ], + }; + + beforeEach(() => { + const mock = getMockAblyRest(); + mock.request.mockClear(); + mock.request.mockResolvedValue(mockSpaceChannelsResponse); + }); + + it("should filter to ::$space channels only", async () => { + const { stdout } = await runCommand(["spaces:list"], import.meta.url); + + expect(stdout).toContain("space1"); + expect(stdout).toContain("space2"); + expect(stdout).not.toContain("regular-channel"); + }); + + it("should deduplicate spaces from sub-channels", async () => { + const { stdout } = await runCommand(["spaces:list"], import.meta.url); + + // space1 has 2 sub-channels but should appear only once + expect(stdout).toContain("2"); + expect(stdout).toContain("active spaces"); + }); + + it("should extract space name from channel ID", async () => { + const { stdout } = await runCommand(["spaces:list"], import.meta.url); + + expect(stdout).toContain("space1"); + expect(stdout).not.toContain("::$space::$locks"); + }); + + it("should respect --limit flag", async () => { + const { stdout } = await runCommand( + ["spaces:list", "--limit", "1"], + import.meta.url, + ); + + expect(stdout).toContain("space1"); + 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"); + }); + + it("should show 'No active spaces' on empty response", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ statusCode: 200, items: [] }); + + 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, + ); + + const json = JSON.parse(stdout); + expect(json).toHaveProperty("spaces"); + expect(json).toHaveProperty("total"); + expect(json).toHaveProperty("shown"); + expect(json).toHaveProperty("hasMore"); + expect(json).toHaveProperty("success", true); + expect(json.spaces).toBeInstanceOf(Array); + expect(json.spaces.length).toBe(2); + expect(json.spaces[0]).toHaveProperty("spaceName", "space1"); + expect(json.spaces[1]).toHaveProperty("spaceName", "space2"); + }); +}); diff --git a/test/unit/commands/spaces/locations/set.test.ts b/test/unit/commands/spaces/locations/set.test.ts new file mode 100644 index 00000000..2bab5464 --- /dev/null +++ b/test/unit/commands/spaces/locations/set.test.ts @@ -0,0 +1,115 @@ +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"; + +describe("spaces:locations:set command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:locations:set"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should require --location flag", async () => { + const { error } = await runCommand( + ["spaces:locations:set", "test-space"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch( + /--location.*required|Missing required flag/i, + ); + }); + }); + + describe("location validation", () => { + it("should error on invalid --location JSON", async () => { + const { error } = await runCommand( + ["spaces:locations:set", "test-space", "--location", "not-valid-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Invalid location JSON"); + }); + }); + + describe("setting location", () => { + it("should parse location JSON and set in space", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + const location = { x: 10, y: 20, sectionId: "main" }; + + const { stdout } = await runCommand( + [ + "spaces:locations:set", + "test-space", + "--location", + JSON.stringify(location), + ], + import.meta.url, + ); + + expect(space.enter).toHaveBeenCalled(); + expect(space.locations.set).toHaveBeenCalledWith(location); + expect(stdout).toContain("Location set"); + expect(stdout).toContain("test-space"); + }); + }); + + describe("JSON output", () => { + it("should output JSON on success with --duration 0", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const location = { x: 10, y: 20 }; + + const { stdout } = await runCommand( + [ + "spaces:locations:set", + "test-space", + "--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"); + }); + + it("should output JSON error on invalid location", async () => { + const { stdout } = await runCommand( + [ + "spaces:locations:set", + "test-space", + "--location", + "not-valid-json", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid location JSON"); + }); + }); +}); diff --git a/test/unit/commands/spaces/locks/acquire.test.ts b/test/unit/commands/spaces/locks/acquire.test.ts new file mode 100644 index 00000000..4466318f --- /dev/null +++ b/test/unit/commands/spaces/locks/acquire.test.ts @@ -0,0 +1,117 @@ +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"; + +describe("spaces:locks:acquire command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + describe("lock acquisition", () => { + it("should acquire lock and display details", 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" }, + timestamp: Date.now(), + reason: undefined, + }); + + const { stdout } = await runCommand( + ["spaces:locks:acquire", "test-space", "my-lock", "--duration", "0"], + import.meta.url, + ); + + expect(space.enter).toHaveBeenCalled(); + expect(space.locks.acquire).toHaveBeenCalledWith("my-lock", undefined); + expect(stdout).toContain("Lock acquired"); + expect(stdout).toContain("my-lock"); + }); + + it("should pass --data JSON to acquisition", 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" }, + timestamp: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "spaces:locks:acquire", + "test-space", + "my-lock", + "--data", + '{"type":"editor"}', + "--duration", + "0", + ], + import.meta.url, + ); + + expect(space.locks.acquire).toHaveBeenCalledWith("my-lock", { + type: "editor", + }); + expect(stdout).toContain("Lock acquired"); + }); + + it("should error on invalid --data JSON", async () => { + const { error } = await runCommand( + ["spaces:locks:acquire", "test-space", "my-lock", "--data", "not-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Invalid lock data JSON"); + }); + + it("should handle acquisition failure", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.locks.acquire.mockRejectedValue(new Error("Lock already held")); + + const { error } = await runCommand( + ["spaces:locks:acquire", "test-space", "my-lock"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Lock already held"); + }); + + it("should output JSON on success", 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" }, + timestamp: 1700000000000, + }); + + const { stdout } = await runCommand( + [ + "spaces:locks:acquire", + "test-space", + "my-lock", + "--json", + "--duration", + "0", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("lock"); + expect(result.lock).toHaveProperty("lockId", "my-lock"); + expect(result.lock).toHaveProperty("status", "locked"); + }); + }); +}); diff --git a/test/unit/commands/spaces/members/enter.test.ts b/test/unit/commands/spaces/members/enter.test.ts new file mode 100644 index 00000000..c7536c2e --- /dev/null +++ b/test/unit/commands/spaces/members/enter.test.ts @@ -0,0 +1,125 @@ +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"; + +describe("spaces:members:enter command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:members:enter"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:members:enter", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("entering a space", () => { + it("should parse --profile JSON and pass to space.enter", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + const profile = { name: "User", status: "active" }; + + const { stdout } = await runCommand( + [ + "spaces:members:enter", + "test-space", + "--profile", + JSON.stringify(profile), + ], + import.meta.url, + ); + + expect(space.enter).toHaveBeenCalledWith(profile); + expect(stdout).toContain("Entered space"); + expect(stdout).toContain("test-space"); + }); + + it("should enter without profile when not provided", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand(["spaces:members:enter", "test-space"], import.meta.url); + + expect(space.enter).toHaveBeenCalledWith(undefined); + }); + + it("should error on invalid profile JSON", async () => { + const { error } = await runCommand( + ["spaces:members:enter", "test-space", "--profile", "not-valid-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Invalid profile JSON"); + }); + }); + + 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 () => { + const spacesMock = getMockAblySpaces(); + spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:members:enter", "test-space", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.spaceName).toBe("test-space"); + expect(result.status).toBe("connected"); + }); + + it("should output JSON error on invalid profile", async () => { + getMockAblySpaces(); + + const { stdout } = await runCommand( + [ + "spaces:members:enter", + "test-space", + "--profile", + "not-valid-json", + "--json", + ], + import.meta.url, + ); + + expect(stdout).toContain('"success"'); + expect(stdout).toContain("false"); + expect(stdout).toContain("Invalid profile JSON"); + }); + }); +}); diff --git a/test/unit/commands/spaces/members/subscribe.test.ts b/test/unit/commands/spaces/members/subscribe.test.ts new file mode 100644 index 00000000..f8b93b33 --- /dev/null +++ b/test/unit/commands/spaces/members/subscribe.test.ts @@ -0,0 +1,142 @@ +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"; + +describe("spaces:members:subscribe command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:members:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:members:subscribe", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("initial member display", () => { + it("should display current members from getAll()", 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" }, + }, + ]); + + 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"); + }); + }); + + describe("subscription behavior", () => { + 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.members.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + }); + }); + + describe("JSON output", () => { + it("should output JSON for initial 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" }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:subscribe", "test-space", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.members).toHaveLength(1); + expect(result.members[0].clientId).toBe("user-1"); + }); + }); +}); diff --git a/test/unit/services/stats-display.test.ts b/test/unit/services/stats-display.test.ts new file mode 100644 index 00000000..bda1a00c --- /dev/null +++ b/test/unit/services/stats-display.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { StatsDisplay } from "../../../src/services/stats-display.js"; + +describe("StatsDisplay", () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("outputs JSON.stringify(stats) in JSON mode", () => { + const display = new StatsDisplay({ json: true }); + const stats = { + entries: { "messages.all.all.count": 10 }, + intervalId: "2025-01-15:10:30", + }; + display.display(stats); + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(stats)); + }); + + it("produces no output for null stats", () => { + const display = new StatsDisplay(); + display.display(null as unknown as Parameters[0]); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("skips duplicate stats in historical mode", () => { + const display = new StatsDisplay(); + const stats = { + entries: { "messages.all.all.count": 5 }, + intervalId: "2025-01-15:10:30", + unit: "minute", + }; + display.display(stats); + const callCountAfterFirst = consoleSpy.mock.calls.length; + display.display(stats); + expect(consoleSpy.mock.calls.length).toBe(callCountAfterFirst); + }); + + it("shows 'Stats for' header in historical mode", () => { + const display = new StatsDisplay(); + const stats = { + entries: {}, + intervalId: "2025-01-15:10:30", + unit: "minute", + }; + display.display(stats); + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("Stats for"); + }); + + it("shows 'Stats Dashboard' header in live mode", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-15T10:30:00Z")); + const display = new StatsDisplay({ live: true }); + const stats = { entries: {}, intervalId: "2025-01-15:10:30" }; + display.display(stats); + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("Stats Dashboard"); + vi.useRealTimers(); + }); + + describe("formatBytes (private)", () => { + it.each([ + [0, "0.0 B"], + [500, "500.0 B"], + [1536, "1.5 KB"], + [1048576, "1.0 MB"], + ])("formats %d as %s", (input, expected) => { + const display = new StatsDisplay(); + const result = (display as Record string>)[ + "formatBytes" + ](input); + expect(result).toBe(expected); + }); + }); + + describe("formatElapsedTime (private)", () => { + it.each([ + [45_000, "45s"], + [125_000, "2m 5s"], + [3_661_000, "1h 1m 1s"], + ])("formats %d ms elapsed as '%s'", (elapsedMs, expected) => { + vi.useFakeTimers(); + const startTime = new Date(1000); + vi.setSystemTime(new Date(1000 + elapsedMs)); + const display = new StatsDisplay({ live: true, startTime }); + const result = (display as Record string>)[ + "formatElapsedTime" + ](); + expect(result).toBe(expected); + vi.useRealTimers(); + }); + }); + + describe("parseIntervalId (private)", () => { + it("parses minute format correctly", () => { + const display = new StatsDisplay(); + const result = ( + display as Record< + string, + (id: string, unit: string) => { period: string; start: Date } + > + )["parseIntervalId"]("2025-01-15:10:30", "minute"); + expect(result.start.getTime()).toBe(Date.UTC(2025, 0, 15, 10, 30)); + }); + + it("parses hour format correctly", () => { + const display = new StatsDisplay(); + const result = ( + display as Record< + string, + (id: string, unit: string) => { period: string; start: Date } + > + )["parseIntervalId"]("2025-01-15:10", "hour"); + expect(result.start.getTime()).toBe(Date.UTC(2025, 0, 15, 10)); + }); + + it("handles malformed intervalId with fallback", () => { + const display = new StatsDisplay(); + ( + display as Record< + string, + (id: string, unit: string) => { period: string; start: Date } + > + )["parseIntervalId"]("bad-format", "minute"); + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("Note: Could not parse"); + }); + }); +}); diff --git a/test/unit/utils/json-formatter.test.ts b/test/unit/utils/json-formatter.test.ts new file mode 100644 index 00000000..0c414e42 --- /dev/null +++ b/test/unit/utils/json-formatter.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import chalk from "chalk"; +import { formatJson, isJsonData } from "../../../src/utils/json-formatter.js"; + +let originalChalkLevel: chalk.Level; + +beforeAll(() => { + originalChalkLevel = chalk.level; + chalk.level = 1; +}); + +afterAll(() => { + chalk.level = originalChalkLevel; +}); + +describe("isJsonData", () => { + it("returns false for primitives", () => { + expect(isJsonData(null)).toBe(false); + expect(isJsonData()).toBe(false); + expect(isJsonData(42)).toBe(false); + expect(isJsonData(true)).toBe(false); + }); + + it("returns true for objects and arrays", () => { + expect(isJsonData({ a: 1 })).toBe(true); + expect(isJsonData([1, 2])).toBe(true); + expect(isJsonData({})).toBe(true); + expect(isJsonData([])).toBe(true); + }); + + it("returns true for valid JSON strings representing objects/arrays", () => { + expect(isJsonData('{"a":1}')).toBe(true); + expect(isJsonData("[1,2,3]")).toBe(true); + }); + + it("returns false for non-JSON strings", () => { + expect(isJsonData("hello")).toBe(false); + expect(isJsonData("{bad}")).toBe(false); + expect(isJsonData('"42"')).toBe(false); + }); +}); + +describe("formatJson", () => { + it("returns gray 'undefined' for undefined", () => { + const result = formatJson(); + expect(result).toContain("undefined"); + }); + + it("returns gray 'null' for null", () => { + const result = formatJson(null); + expect(result).toContain("null"); + }); + + it("colorizes numbers as yellow", () => { + const result = formatJson(42); + expect(result).toBe(chalk.yellow(42)); + }); + + it("colorizes booleans as cyan", () => { + const result = formatJson(true); + expect(result).toBe(chalk.cyan(true)); + }); + + it("colorizes strings as green with quotes", () => { + const result = formatJson("hello"); + expect(result).toBe(chalk.green('"hello"')); + }); + + it("formats objects with blue keys", () => { + const result = formatJson({ count: 5 }); + expect(result).toContain(chalk.blue('"count"')); + expect(result).toContain("5"); + }); + + it("handles circular references without throwing", () => { + const obj: Record = { a: 1 }; + obj.self = obj; + expect(() => formatJson(obj)).not.toThrow(); + }); +}); diff --git a/test/unit/utils/prompt-confirmation.test.ts b/test/unit/utils/prompt-confirmation.test.ts new file mode 100644 index 00000000..5e3f5e51 --- /dev/null +++ b/test/unit/utils/prompt-confirmation.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +let mockQuestion: (query: string, callback: (answer: string) => void) => void; + +vi.mock("node:readline", () => ({ + createInterface: () => ({ + close: vi.fn(), + question: (query: string, callback: (answer: string) => void) => { + mockQuestion(query, callback); + }, + }), +})); + +import { promptForConfirmation } from "../../../src/utils/prompt-confirmation.js"; + +describe("promptForConfirmation", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it.each(["y", "yes", "Y", "YES", " yes "])( + "returns true for affirmative input: '%s'", + async (input) => { + mockQuestion = (_query, callback) => callback(input); + const result = await promptForConfirmation("Delete this?"); + expect(result).toBe(true); + }, + ); + + it.each(["n", "no", "", "maybe", "yep"])( + "returns false for non-affirmative input: '%s'", + async (input) => { + mockQuestion = (_query, callback) => callback(input); + const result = await promptForConfirmation("Delete this?"); + expect(result).toBe(false); + }, + ); + + it("appends [yes/no] suffix when message does not include it", async () => { + let capturedQuery = ""; + mockQuestion = (query, callback) => { + capturedQuery = query; + callback("no"); + }; + await promptForConfirmation("Are you sure?"); + expect(capturedQuery).toBe("Are you sure? [yes/no]"); + }); +}); diff --git a/test/unit/utils/string-distance.test.ts b/test/unit/utils/string-distance.test.ts new file mode 100644 index 00000000..ccc86e2c --- /dev/null +++ b/test/unit/utils/string-distance.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { closest } from "../../../src/utils/string-distance.js"; + +describe("closest (string-distance)", () => { + const possibilities = [ + "channels:list", + "channels:publish", + "apps:list", + "apps:create", + "accounts:login", + ]; + + it("returns exact match (distance 0)", () => { + expect(closest("channels:list", possibilities)).toBe("channels:list"); + }); + + it("returns closest match for a 1-char typo", () => { + expect(closest("channls:list", possibilities)).toBe("channels:list"); + }); + + it("normalizes spaces to colons for matching", () => { + expect(closest("channels list", possibilities)).toBe("channels:list"); + }); + + it("returns empty string when no match is within threshold", () => { + expect(closest("zzzzzzzzzzz", possibilities)).toBe(""); + }); + + it("returns empty string for empty possibilities array", () => { + expect(closest("channels:list", [])).toBe(""); + }); +});