From aa89f481905fe04fcb03eb3c413e8bf4f1343371 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:41:05 +0100 Subject: [PATCH 1/9] Add logging helpers and JSON lifecycle events to base command Introduce five logging helpers on AblyBaseCommand that route status messages to stderr (human) or structured JSON (agents/scripts): - logProgress/logSuccessMessage: silent in JSON mode - logListening/logHolding/logWarning: emit structured JSON status events Also adds: - JSON completed signal in finally() so agents know when a command ends - Connection and channel state change events emitted in JSON mode - Cleanup timeout/error messages moved to stderr unconditionally - chat-base-command and stats-base-command updated to use new helpers --- src/base-command.ts | 201 +++++++++++++++++++++++++++++++------- src/chat-base-command.ts | 21 ++-- src/stats-base-command.ts | 13 +-- 3 files changed, 174 insertions(+), 61 deletions(-) diff --git a/src/base-command.ts b/src/base-command.ts index aceb0618..af4ee72e 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -17,8 +17,13 @@ import { BaseFlags, CommandConfig } from "./types/cli.js"; import { JsonRecordType, buildJsonRecord, + formatListening, + formatProgress, + formatResource, + formatSuccess, formatWarning, } from "./utils/output.js"; +import stripAnsi from "strip-ansi"; import { getCliVersion } from "./utils/version.js"; import Spaces from "@ably/spaces"; import { ChatClient } from "@ably/chat"; @@ -842,6 +847,23 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { this.debug(`Realtime client cleanup error: ${String(error)}`); } + // Emit a terminal "completed" line so JSON consumers know the command is done. + const isJsonMode = + this.argv.includes("--json") || this.argv.includes("--pretty-json"); + if (isJsonMode) { + const flags: BaseFlags = this.argv.includes("--pretty-json") + ? ({ "pretty-json": true } as BaseFlags) + : ({ json: true } as BaseFlags); + const exitCode = err ? 1 : 0; + this.log( + this.formatJsonRecord( + JsonRecordType.Status, + { status: "completed", exitCode }, + flags, + ), + ); + } + // Call super to maintain the parent class functionality await super.finally(err); } @@ -897,19 +919,81 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { this.log(this.formatJsonRecord(JsonRecordType.Event, data, flags)); } - protected logJsonStatus( - status: string, - message: string, - flags: BaseFlags, - ): void { + /** + * Log a progress message. Silent in JSON mode (structured events convey + * the same information). Non-JSON mode: emits formatted text on stderr. + */ + protected logProgress(message: string, flags: BaseFlags): void { + if (!this.shouldOutputJson(flags)) { + this.logToStderr(formatProgress(message)); + } + } + + /** + * Log a success message. Silent in JSON mode (the result record's + * success:true already conveys this). Non-JSON mode: emits formatted + * text on stderr. + */ + protected logSuccessMessage(message: string, flags: BaseFlags): void { + if (!this.shouldOutputJson(flags)) { + this.logToStderr(formatSuccess(message)); + } + } + + /** + * Log a listening message for passive subscribe/stream commands. + * JSON mode: emits a status event on stdout (agents need the signal). + * Non-JSON mode: emits formatted text on stderr. + */ + protected logListening(message: string, flags: BaseFlags): void { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonRecord( + JsonRecordType.Status, + { status: "listening", message: stripAnsi(message) }, + flags, + ), + ); + } else { + this.logToStderr(formatListening(message)); + } + } + + /** + * Log a holding message for commands that hold state (enter, set, acquire). + * JSON mode: emits a status event on stdout (agents need the signal). + * Non-JSON mode: emits formatted text on stderr. + */ + protected logHolding(message: string, flags: BaseFlags): void { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonRecord( + JsonRecordType.Status, + { status: "holding", message: stripAnsi(message) }, + flags, + ), + ); + } else { + this.logToStderr(formatListening(message)); + } + } + + /** + * Log a warning message. JSON mode: emits a status event on stdout + * (agents need actionable warnings). Non-JSON mode: emits formatted + * text on stderr. + */ + protected logWarning(message: string, flags: BaseFlags): void { if (this.shouldOutputJson(flags)) { this.log( this.formatJsonRecord( JsonRecordType.Status, - { status, message }, + { status: "warning", message: stripAnsi(message) }, flags, ), ); + } else { + this.logToStderr(formatWarning(message)); } } @@ -924,7 +1008,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // When using token auth, we don't set the clientId as it may conflict // with any clientId embedded in the token if (flags["client-id"] && !this.shouldSuppressOutput(flags)) { - this.log( + this.logToStderr( chalk.yellow( "Warning: clientId is ignored when using token authentication as the clientId is embedded in the token", ), @@ -1325,11 +1409,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - // Log timeout only if not in JSON mode - if (!this.shouldOutputJson({})) { - // TODO: Pass actual flags here - this.log(formatWarning("Cleanup operation timed out.")); - } + this.logToStderr(formatWarning("Cleanup operation timed out.")); reject(new Error("Cleanup timed out")); // Reject promise on timeout }, effectiveTimeout); @@ -1338,13 +1418,9 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { try { await cleanupFunction(); } catch (error) { - // Log cleanup error only if not in JSON mode - if (!this.shouldOutputJson({})) { - // TODO: Pass actual flags here - this.log( - chalk.red(`Error during cleanup: ${(error as Error).message}`), - ); - } + this.logToStderr( + chalk.red(`Error during cleanup: ${(error as Error).message}`), + ); // Don't necessarily reject the main promise here, depends on desired behavior // For now, we just log it } finally { @@ -1396,6 +1472,29 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { const connectionStateHandler = ( stateChange: Ably.ConnectionStateChange, ) => { + // Always emit in JSON mode so agents can observe connection lifecycle + if (this.shouldOutputJson(flags)) { + const eventData: Record = { + current: stateChange.current, + previous: stateChange.previous, + }; + if (stateChange.reason) { + eventData.reason = { + message: stateChange.reason.message, + code: stateChange.reason.code, + statusCode: stateChange.reason.statusCode, + }; + } + this.log( + this.formatJsonRecord( + JsonRecordType.Event, + { component, event: "connectionStateChange", ...eventData }, + flags, + ), + ); + } + + // Verbose log (for non-JSON verbose, or JSON verbose with extra detail) this.logCliEvent( flags, component, @@ -1404,27 +1503,26 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { { reason: stateChange.reason }, ); - // Optional user-friendly messages for non-JSON output - if (showUserMessages && !this.shouldOutputJson(flags)) { + // User-friendly messages on stderr (visible even when piping JSON) + if (showUserMessages) { switch (stateChange.current) { case "connected": { // Don't show connected message - it's implied by successful channel/space operations break; } case "disconnected": { - this.log(formatWarning("Disconnected from Ably")); + this.logWarning("Disconnected from Ably.", flags); break; } case "failed": { - this.log( - chalk.red( - `✗ Connection failed: ${stateChange.reason?.message || "Unknown error"}`, - ), + this.logWarning( + `Connection failed: ${stateChange.reason?.message || "Unknown error"}.`, + flags, ); break; } case "suspended": { - this.log(formatWarning("Connection suspended")); + this.logWarning("Connection suspended.", flags); break; } case "connecting": { @@ -1459,6 +1557,30 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { const showUserMessages = options?.includeUserFriendlyMessages || false; const stateChangeHandler = (stateChange: Ably.ChannelStateChange) => { + // Always emit in JSON mode so agents can observe channel lifecycle + if (this.shouldOutputJson(flags)) { + const eventData: Record = { + channel: channel.name, + current: stateChange.current, + previous: stateChange.previous, + }; + if (stateChange.reason) { + eventData.reason = { + message: stateChange.reason.message, + code: stateChange.reason.code, + statusCode: stateChange.reason.statusCode, + }; + } + this.log( + this.formatJsonRecord( + JsonRecordType.Event, + { component, event: "channelStateChange", ...eventData }, + flags, + ), + ); + } + + // Verbose log this.logCliEvent( flags, component, @@ -1467,25 +1589,24 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { { channel: channel.name, reason: stateChange.reason }, ); - if (showUserMessages && !this.shouldOutputJson(flags)) { + // Channel state messages on stderr (visible even when piping JSON) + if (showUserMessages) { switch (stateChange.current) { case "attached": { // Success will be shown by the command itself in context break; } case "failed": { - this.log( - chalk.red( - `✗ Failed to attach to channel ${chalk.cyan(channel.name)}: ${stateChange.reason?.message || "Unknown error"}`, - ), + this.logWarning( + `Failed to attach to channel ${formatResource(channel.name)}: ${stateChange.reason?.message || "Unknown error"}.`, + flags, ); break; } case "detached": { - this.log( - chalk.yellow( - `! Detached from channel: ${chalk.cyan(channel.name)} ${stateChange.reason ? `(Reason: ${stateChange.reason.message})` : ""}`, - ), + this.logWarning( + `Detached from channel ${formatResource(channel.name)}${stateChange.reason ? ` (Reason: ${stateChange.reason.message})` : ""}.`, + flags, ); break; } @@ -1648,7 +1769,13 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { if (exitReason === "timeout" && !isTestMode()) { const message = "Duration elapsed – command finished cleanly."; if (this.shouldOutputJson(flags)) { - this.logJsonStatus("complete", message, flags); + this.log( + this.formatJsonRecord( + JsonRecordType.Status, + { status: "complete", message }, + flags, + ), + ); } else { this.log(message); } diff --git a/src/chat-base-command.ts b/src/chat-base-command.ts index 46b063a4..eda40b9b 100644 --- a/src/chat-base-command.ts +++ b/src/chat-base-command.ts @@ -4,11 +4,6 @@ import { AblyBaseCommand } from "./base-command.js"; import { productApiFlags } from "./flags.js"; import { BaseFlags } from "./types/cli.js"; -import { - formatSuccess, - formatListening, - formatWarning, -} from "./utils/output.js"; import isTestMode from "./utils/test-mode.js"; export abstract class ChatBaseCommand extends AblyBaseCommand { @@ -134,20 +129,16 @@ export abstract class ChatBaseCommand extends AblyBaseCommand { ); switch (statusChange.current) { case RoomStatus.Attached: { - if (!this.shouldOutputJson(flags)) { - if (options.successMessage) { - this.log(formatSuccess(options.successMessage)); - } - if (options.listeningMessage) { - this.log(formatListening(options.listeningMessage)); - } + if (options.successMessage) { + this.logSuccessMessage(options.successMessage, flags as BaseFlags); + } + if (options.listeningMessage) { + this.logListening(options.listeningMessage, flags as BaseFlags); } break; } case RoomStatus.Detached: { - if (!this.shouldOutputJson(flags)) { - this.log(formatWarning("Disconnected from Ably")); - } + this.logWarning("Disconnected from Ably", flags as BaseFlags); break; } case RoomStatus.Failed: { diff --git a/src/stats-base-command.ts b/src/stats-base-command.ts index 1a1b5965..af17f2e1 100644 --- a/src/stats-base-command.ts +++ b/src/stats-base-command.ts @@ -7,7 +7,6 @@ import { StatsDisplay, StatsDisplayData } from "./services/stats-display.js"; import type { BaseFlags } from "./types/cli.js"; import type { ControlApi } from "./services/control-api.js"; import { errorMessage } from "./utils/errors.js"; -import { formatProgress } from "./utils/output.js"; import { parseTimestamp } from "./utils/time.js"; export abstract class StatsBaseCommand extends ControlBaseCommand { @@ -138,10 +137,8 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { controlApi: ControlApi, ): Promise { try { - if (!this.shouldOutputJson(flags)) { - const label = await this.getStatsLabel(flags, controlApi); - this.log(formatProgress(`Subscribing to live stats for ${label}`)); - } + const label = await this.getStatsLabel(flags, controlApi); + this.logProgress(`Subscribing to live stats for ${label}`, flags); const isJson = this.shouldOutputJson(flags); const cleanup = () => { @@ -192,10 +189,8 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { controlApi: ControlApi, ): Promise { try { - if (!this.shouldOutputJson(flags)) { - const label = await this.getStatsLabel(flags, controlApi); - this.log(formatProgress(`Fetching stats for ${label}`)); - } + const label = await this.getStatsLabel(flags, controlApi); + this.logProgress(`Fetching stats for ${label}`, flags); let startMs: number | undefined; let endMs: number | undefined; From 2c0b9fea4e449197f034f3f4d0b022db5eb868a7 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:41:11 +0100 Subject: [PATCH 2/9] Add NDJSON test helpers for multi-record JSON output Add parseJsonOutput() and parseAllJsonRecords() to test/helpers/ndjson.ts. These handle stdout that now contains multiple JSON records (e.g. result + completed signal) in both compact NDJSON and pretty-printed formats. --- test/helpers/ndjson.ts | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/helpers/ndjson.ts b/test/helpers/ndjson.ts index 60192a3b..088af08b 100644 --- a/test/helpers/ndjson.ts +++ b/test/helpers/ndjson.ts @@ -40,6 +40,74 @@ export function parseLogLines(lines: string[]): Record[] { return results; } +/** + * Split stdout containing one or more JSON objects (compact or pretty-printed) + * into an array of parsed records. Handles: + * - Compact NDJSON (one JSON object per line) + * - Pretty-printed JSON (multi-line indented objects) + * - Mixed (e.g. pretty result followed by pretty completed signal) + */ +export function parseAllJsonRecords(stdout: string): Record[] { + const records: Record[] = []; + const trimmed = stdout.trim(); + if (!trimmed) return records; + + // Use incremental JSON.parse: find each top-level object by trying to parse + // from the current position, extending until we get a valid parse. + let remaining = trimmed; + while (remaining.length > 0) { + remaining = remaining.trimStart(); + if (!remaining) break; + + // Try compact NDJSON first: if the first line is valid JSON, take it + const newlineIdx = remaining.indexOf("\n"); + if (newlineIdx !== -1) { + const firstLine = remaining.slice(0, newlineIdx); + try { + records.push(JSON.parse(firstLine)); + remaining = remaining.slice(newlineIdx + 1); + continue; + } catch { + // Not a single-line JSON object — try multi-line parse + } + } + + // Try parsing from the start, extending character by character. + // For pretty-printed JSON, the closing `}` is on its own line. + let parsed = false; + for (let i = 1; i <= remaining.length; i++) { + try { + const obj = JSON.parse(remaining.slice(0, i)); + records.push(obj); + remaining = remaining.slice(i); + parsed = true; + break; + } catch { + // Not enough characters yet + } + } + if (!parsed) break; // Unparseable — stop + } + return records; +} + +/** + * Parse NDJSON or pretty-JSON stdout and return the primary result or error record + * (i.e. the record with `type: "result"` or `type: "error"`). + * Handles both compact NDJSON and pretty-printed JSON with multiple objects. + * Use this instead of `JSON.parse(stdout)` when stdout may contain + * multiple JSON records (e.g. result + completed signal). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseJsonOutput(stdout: string): any { + const records = parseAllJsonRecords(stdout); + const primary = records.find( + (r) => r.type === "result" || r.type === "error", + ); + if (primary) return primary; + return records.find((r) => r.type !== "status") ?? records[0] ?? {}; +} + /** * Capture all console.log output from an async function and parse as JSON records. * Spy is always restored via `finally`, even on error. From 1e36dc243d11f24491f64f54f485547bb4b33f0d Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:41:17 +0100 Subject: [PATCH 3/9] Update --force flag description to indicate JSON mode requirement Flag description now reads "(required with --json)" since JSON mode cannot show interactive confirmation prompts. --- src/flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flags.ts b/src/flags.ts index 7bfbe2ff..21bfed1a 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -117,7 +117,7 @@ export const forceFlag = { force: Flags.boolean({ char: "f", default: false, - description: "Skip confirmation prompt", + description: "Skip confirmation prompt (required with --json)", }), }; From 297dead7e84b03b4a1be73ca1de6a6d81a505ab9 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:46:08 +0100 Subject: [PATCH 4/9] Migrate commands to use logging helpers and stderr for status output Mechanical migration across all ~99 command files: - Replace this.log(formatProgress/Success/Listening/Warning(...)) with this.logProgress/logSuccessMessage/logListening/logWarning(msg, flags) - Remove manual shouldOutputJson guards around status messages - Status text now routes to stderr (human) or structured JSON (agents) - stdout reserved for data output only --- src/commands/accounts/login.ts | 58 ++++----- src/commands/accounts/logout.ts | 29 +++-- src/commands/accounts/switch.ts | 9 +- src/commands/apps/create.ts | 34 +++-- src/commands/apps/delete.ts | 48 +++---- src/commands/apps/rules/create.ts | 28 ++--- src/commands/apps/rules/delete.ts | 36 +++--- src/commands/apps/rules/update.ts | 26 ++-- src/commands/apps/switch.ts | 14 ++- src/commands/apps/update.ts | 13 +- src/commands/auth/keys/create.ts | 25 ++-- src/commands/auth/keys/get.ts | 15 +-- src/commands/auth/keys/revoke.ts | 47 +++---- src/commands/auth/keys/switch.ts | 22 ++-- src/commands/auth/revoke-token.ts | 2 +- src/commands/bench/publisher.ts | 25 ++-- src/commands/bench/subscriber.ts | 94 ++++++-------- src/commands/channels/annotations/delete.ts | 33 ++--- src/commands/channels/annotations/get.ts | 12 +- src/commands/channels/annotations/publish.ts | 53 ++------ .../channels/annotations/subscribe.ts | 25 ++-- src/commands/channels/append.ts | 38 +++--- src/commands/channels/batch-publish.ts | 69 +++++----- src/commands/channels/delete.ts | 38 +++--- src/commands/channels/history.ts | 10 +- src/commands/channels/list.ts | 2 +- src/commands/channels/occupancy/subscribe.ts | 27 ++-- src/commands/channels/presence/enter.ts | 40 +++--- src/commands/channels/presence/get.ts | 19 +-- src/commands/channels/presence/subscribe.ts | 25 ++-- src/commands/channels/publish.ts | 73 +++++------ src/commands/channels/subscribe.ts | 44 +++---- src/commands/channels/update.ts | 38 +++--- src/commands/connections/test.ts | 119 ++++++++---------- src/commands/integrations/create.ts | 21 ++-- src/commands/integrations/delete.ts | 30 ++--- src/commands/integrations/update.ts | 5 +- .../logs/channel-lifecycle/subscribe.ts | 12 +- .../logs/connection-lifecycle/history.ts | 2 +- .../logs/connection-lifecycle/subscribe.ts | 15 +-- src/commands/logs/history.ts | 2 +- src/commands/logs/push/history.ts | 2 +- src/commands/logs/push/subscribe.ts | 12 +- src/commands/logs/subscribe.ts | 17 +-- src/commands/push/batch-publish.ts | 52 ++++---- src/commands/push/channels/list-channels.ts | 13 +- src/commands/push/channels/list.ts | 25 ++-- src/commands/push/channels/remove-where.ts | 30 ++--- src/commands/push/channels/remove.ts | 35 +++--- src/commands/push/channels/save.ts | 31 ++--- src/commands/push/config/clear-apns.ts | 58 ++++----- src/commands/push/config/clear-fcm.ts | 58 ++++----- src/commands/push/config/set-apns.ts | 42 +++---- src/commands/push/config/set-fcm.ts | 22 ++-- src/commands/push/config/show.ts | 17 +-- src/commands/push/devices/get.ts | 11 +- src/commands/push/devices/list.ts | 15 +-- src/commands/push/devices/remove-where.ts | 18 ++- src/commands/push/devices/remove.ts | 26 ++-- src/commands/push/devices/save.ts | 24 ++-- src/commands/push/publish.ts | 37 ++---- src/commands/queues/create.ts | 14 +-- src/commands/queues/delete.ts | 27 ++-- src/commands/rooms/list.ts | 2 +- src/commands/rooms/messages/delete.ts | 33 ++--- src/commands/rooms/messages/history.ts | 19 ++- .../rooms/messages/reactions/remove.ts | 13 +- src/commands/rooms/messages/reactions/send.ts | 13 +- .../rooms/messages/reactions/subscribe.ts | 27 ++-- src/commands/rooms/messages/send.ts | 55 ++++---- src/commands/rooms/messages/subscribe.ts | 12 +- src/commands/rooms/messages/update.ts | 33 ++--- src/commands/rooms/occupancy/subscribe.ts | 20 +-- src/commands/rooms/presence/enter.ts | 72 +++++++---- src/commands/rooms/presence/get.ts | 17 +-- src/commands/rooms/presence/subscribe.ts | 48 ++++--- src/commands/rooms/reactions/send.ts | 13 +- src/commands/rooms/reactions/subscribe.ts | 27 ++-- src/commands/rooms/typing/keystroke.ts | 35 +++--- src/commands/spaces/create.ts | 25 ++-- src/commands/spaces/cursors/get.ts | 15 +-- src/commands/spaces/cursors/set.ts | 41 ++---- src/commands/spaces/cursors/subscribe.ts | 14 +-- src/commands/spaces/get.ts | 12 +- src/commands/spaces/list.ts | 2 +- src/commands/spaces/locations/get.ts | 17 +-- src/commands/spaces/locations/set.ts | 20 +-- src/commands/spaces/locations/subscribe.ts | 14 +-- src/commands/spaces/locks/acquire.ts | 24 ++-- src/commands/spaces/locks/get.ts | 33 ++--- src/commands/spaces/locks/subscribe.ts | 10 +- src/commands/spaces/members/enter.ts | 20 +-- src/commands/spaces/members/get.ts | 15 +-- src/commands/spaces/members/subscribe.ts | 10 +- src/commands/spaces/occupancy/subscribe.ts | 27 ++-- src/commands/spaces/subscribe.ts | 15 ++- src/commands/status.ts | 15 ++- src/commands/support/ask.ts | 9 +- src/commands/test/wait.ts | 7 +- test/e2e/channels/channels-e2e.test.ts | 6 +- test/unit/commands/rooms/messages.test.ts | 3 +- .../commands/rooms/messages/subscribe.test.ts | 3 +- 102 files changed, 1105 insertions(+), 1554 deletions(-) diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index c6523596..f89c53a6 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -8,11 +8,7 @@ import { endpointFlag } from "../../flags.js"; import { ControlApi } from "../../services/control-api.js"; import { errorMessage } from "../../utils/errors.js"; import { displayLogo } from "../../utils/logo.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; // Moved function definition outside the class @@ -100,9 +96,7 @@ export default class AccountsLogin extends ControlBaseCommand { // Prompt the user to get a token if (!flags["no-browser"]) { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Opening browser to get an access token")); - } + this.logProgress("Opening browser to get an access token", flags); await this.openBrowser(obtainTokenPath); } else if (!this.shouldOutputJson(flags)) { @@ -232,8 +226,10 @@ export default class AccountsLogin extends ControlBaseCommand { const appName = await this.promptForAppName(); try { - this.log( - `\n${formatProgress(`Creating app ${formatResource(appName)}`)}`, + this.log(""); // blank line before progress + this.logProgress( + `Creating app ${formatResource(appName)}`, + flags, ); const app = await controlApi.createApp({ @@ -248,7 +244,7 @@ export default class AccountsLogin extends ControlBaseCommand { this.configManager.setCurrentApp(app.id); this.configManager.storeAppInfo(app.id, { appName: app.name }); - this.log(formatSuccess("App created successfully.")); + this.logSuccessMessage("App created successfully.", flags); } catch (createError) { this.warn( `Failed to create app: ${createError instanceof Error ? createError.message : String(createError)}`, @@ -348,36 +344,30 @@ export default class AccountsLogin extends ControlBaseCommand { this.logJsonResult({ account: accountData }, flags); } else { - this.log( - `Successfully logged in to ${formatResource(account.name)} (account ID: ${chalk.greenBright(account.id)})`, - ); if (alias !== "default") { this.log(`Account stored with alias: ${alias}`); } this.log(`Account ${formatResource(alias)} is now the current account`); + } - if (selectedApp) { - const message = isAutoSelected - ? formatSuccess( - `Automatically selected app: ${formatResource(selectedApp.name)} (${selectedApp.id}).`, - ) - : formatSuccess( - `Selected app: ${formatResource(selectedApp.name)} (${selectedApp.id}).`, - ); - this.log(message); - } + this.logSuccessMessage( + `Successfully logged in to ${formatResource(account.name)} (account ID: ${chalk.greenBright(account.id)}).`, + flags, + ); - if (selectedKey) { - const keyMessage = isKeyAutoSelected - ? formatSuccess( - `Automatically selected API key: ${formatResource(selectedKey.name || "Unnamed key")} (${selectedKey.id}).`, - ) - : formatSuccess( - `Selected API key: ${formatResource(selectedKey.name || "Unnamed key")} (${selectedKey.id}).`, - ); - this.log(keyMessage); - } + if (selectedApp) { + const message = isAutoSelected + ? `Automatically selected app: ${formatResource(selectedApp.name)} (${selectedApp.id}).` + : `Selected app: ${formatResource(selectedApp.name)} (${selectedApp.id}).`; + this.logSuccessMessage(message, flags); + } + + if (selectedKey) { + const keyMessage = isKeyAutoSelected + ? `Automatically selected API key: ${formatResource(selectedKey.name || "Unnamed key")} (${selectedKey.id}).` + : `Selected API key: ${formatResource(selectedKey.name || "Unnamed key")} (${selectedKey.id}).`; + this.logSuccessMessage(keyMessage, flags); } } catch (error) { this.fail(error, flags, "accountLogin"); diff --git a/src/commands/accounts/logout.ts b/src/commands/accounts/logout.ts index b3d9e55d..b6241de2 100644 --- a/src/commands/accounts/logout.ts +++ b/src/commands/accounts/logout.ts @@ -1,6 +1,7 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { forceFlag } from "../../flags.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class AccountsLogout extends ControlBaseCommand { @@ -23,11 +24,7 @@ export default class AccountsLogout extends ControlBaseCommand { static override flags = { ...ControlBaseCommand.globalFlags, - force: Flags.boolean({ - char: "f", - default: false, - description: "Force logout without confirmation", - }), + ...forceFlag, }; public async run(): Promise { @@ -58,11 +55,20 @@ export default class AccountsLogout extends ControlBaseCommand { ); } - // Get confirmation unless force flag is used or in JSON mode + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm logout", + flags, + "accountLogout", + ); + } + + // Get confirmation unless force flag is used if (!flags.force && !this.shouldOutputJson(flags)) { const confirmed = await this.confirmLogout(targetAlias); if (!confirmed) { - this.log("Logout canceled."); + this.logWarning("Logout canceled.", flags); return; } } @@ -87,8 +93,6 @@ export default class AccountsLogout extends ControlBaseCommand { flags, ); } else { - this.log(`Successfully logged out from account ${targetAlias}.`); - // Suggest switching to another account if there are any left if (remainingAccounts.length > 0) { this.log( @@ -100,6 +104,11 @@ export default class AccountsLogout extends ControlBaseCommand { ); } } + + this.logSuccessMessage( + `Successfully logged out from account ${targetAlias}.`, + flags, + ); } else { this.fail( `Failed to log out from account ${targetAlias}.`, diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index e79f2c49..026524c9 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -43,7 +43,7 @@ export default class AccountsSwitch extends ControlBaseCommand { } // In interactive mode, proxy to login - this.log("No accounts configured. Redirecting to login..."); + this.logProgress("No accounts configured. Redirecting to login", flags); await this.config.runCommand("accounts:login"); return; } @@ -76,7 +76,7 @@ export default class AccountsSwitch extends ControlBaseCommand { if (selectedAccount) { await this.switchToAccount(selectedAccount.alias, accounts, flags); } else { - this.log("Account switch cancelled."); + this.logWarning("Account switch cancelled.", flags); } } @@ -147,8 +147,9 @@ export default class AccountsSwitch extends ControlBaseCommand { flags, ); } else { - this.log( - `Switched to account: ${formatResource(account.name)} (${account.id})`, + this.logSuccessMessage( + `Switched to account ${formatResource(account.name)} (${account.id}).`, + flags, ); this.log(`User: ${user.email}`); } diff --git a/src/commands/apps/create.ts b/src/commands/apps/create.ts index 265fe264..17c9d851 100644 --- a/src/commands/apps/create.ts +++ b/src/commands/apps/create.ts @@ -1,12 +1,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatProgress, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; export default class AppsCreateCommand extends ControlBaseCommand { static description = "Create a new app"; @@ -35,9 +30,7 @@ export default class AppsCreateCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress(`Creating app ${formatResource(flags.name)}`)); - } + this.logProgress(`Creating app ${formatResource(flags.name)}`, flags); const app = await controlApi.createApp({ name: flags.name, @@ -60,12 +53,14 @@ export default class AppsCreateCommand extends ControlBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `App created: ${formatResource(app.name)} (${app.id}).`, - ), - ); + } + + this.logSuccessMessage( + `App created: ${formatResource(app.name)} (${app.id}).`, + flags, + ); + + if (!this.shouldOutputJson(flags)) { this.log(`${formatLabel("App ID")} ${app.id}`); this.log(`${formatLabel("Name")} ${app.name}`); this.log(`${formatLabel("Status")} ${app.status}`); @@ -79,11 +74,10 @@ export default class AppsCreateCommand extends ControlBaseCommand { this.configManager.setCurrentApp(app.id); this.configManager.storeAppInfo(app.id, { appName: app.name }); - if (!this.shouldOutputJson(flags)) { - this.log( - `\nAutomatically switched to app: ${formatResource(app.name)} (${app.id})`, - ); - } + this.logSuccessMessage( + `Automatically switched to app ${formatResource(app.name)} (${app.id}).`, + flags, + ); } catch (error) { this.fail(error, flags, "appCreate"); } diff --git a/src/commands/apps/delete.ts b/src/commands/apps/delete.ts index dac7cf24..0cf89ad3 100644 --- a/src/commands/apps/delete.ts +++ b/src/commands/apps/delete.ts @@ -2,11 +2,8 @@ import { Args, Flags } from "@oclif/core"; import * as readline from "node:readline"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatProgress, - formatResource, -} from "../../utils/output.js"; +import { forceFlag } from "../../flags.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import AppsSwitch from "./switch.js"; @@ -32,11 +29,7 @@ export default class AppsDeleteCommand extends ControlBaseCommand { static flags = { ...ControlBaseCommand.globalFlags, - force: Flags.boolean({ - char: "f", - default: false, - description: "Skip confirmation prompt", - }), + ...forceFlag, app: Flags.string({ description: "The app ID or name (defaults to current app)", env: "ABLY_APP_ID", @@ -68,7 +61,16 @@ export default class AppsDeleteCommand extends ControlBaseCommand { // Get app details const app = await controlApi.getApp(appIdToDelete); - // If not using force flag or JSON mode, get app details and prompt for confirmation + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm deletion", + flags, + "appDelete", + ); + } + + // If not using force flag, prompt for confirmation if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following app:`); this.log(`${formatLabel("App ID")} ${app.id}`); @@ -82,7 +84,10 @@ export default class AppsDeleteCommand extends ControlBaseCommand { if (!nameConfirmed) { // This branch is only reachable when !shouldOutputJson (see outer condition), // so only human-readable output is needed here. - this.log("Deletion cancelled - app name did not match"); + this.logWarning( + "Deletion cancelled - app name did not match.", + flags, + ); return; } @@ -93,16 +98,12 @@ export default class AppsDeleteCommand extends ControlBaseCommand { if (!confirmed) { // This branch is only reachable when !shouldOutputJson (see outer condition), // so only human-readable output is needed here. - this.log("Deletion cancelled"); + this.logWarning("Deletion cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Deleting app ${formatResource(appIdToDelete)}`), - ); - } + this.logProgress(`Deleting app ${formatResource(appIdToDelete)}`, flags); await controlApi.deleteApp(appIdToDelete); @@ -117,13 +118,16 @@ export default class AppsDeleteCommand extends ControlBaseCommand { }, flags, ); - } else { - this.log("App deleted successfully"); } + this.logSuccessMessage("App deleted successfully.", flags); + // If we deleted the current app, run switch command to select a new one - if (isDeletingCurrentApp && !this.shouldOutputJson(flags)) { - this.log("\nThe current app was deleted. Switching to another app..."); + if (isDeletingCurrentApp) { + this.logProgress( + "The current app was deleted. Switching to another app", + flags, + ); // Create a new instance of AppsSwitch and run it const switchCommand = new AppsSwitch(this.argv, this.config); diff --git a/src/commands/apps/rules/create.ts b/src/commands/apps/rules/create.ts index 277390d5..b8a0cb67 100644 --- a/src/commands/apps/rules/create.ts +++ b/src/commands/apps/rules/create.ts @@ -2,12 +2,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { - formatLabel, - formatResource, - formatSuccess, - formatWarning, -} from "../../../utils/output.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; export default class RulesCreateCommand extends ControlBaseCommand { static description = "Create a rule"; @@ -107,13 +102,10 @@ export default class RulesCreateCommand extends ControlBaseCommand { if (mutableMessages) { persisted = true; - if (!this.shouldOutputJson(flags)) { - this.logToStderr( - formatWarning( - "Message persistence is automatically enabled when mutable messages is enabled.", - ), - ); - } + this.logWarning( + "Message persistence is automatically enabled when mutable messages is enabled.", + flags, + ); } const namespaceData = { @@ -164,11 +156,6 @@ export default class RulesCreateCommand extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess( - "Rule " + formatResource(createdNamespace.id) + " created.", - ), - ); this.log(`${formatLabel("ID")} ${formatResource(createdNamespace.id)}`); for (const line of formatChannelRuleDetails(createdNamespace, { bold: true, @@ -177,6 +164,11 @@ export default class RulesCreateCommand extends ControlBaseCommand { this.log(line); } } + + this.logSuccessMessage( + "Channel rule " + formatResource(createdNamespace.id) + " created.", + flags, + ); } catch (error) { this.fail(error, flags, "ruleCreate", { appId }); } diff --git a/src/commands/apps/rules/delete.ts b/src/commands/apps/rules/delete.ts index 2378b4fe..2a0ca93c 100644 --- a/src/commands/apps/rules/delete.ts +++ b/src/commands/apps/rules/delete.ts @@ -1,12 +1,9 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; +import { forceFlag } from "../../../flags.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { - formatLabel, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class RulesDeleteCommand extends ControlBaseCommand { @@ -33,12 +30,7 @@ export default class RulesDeleteCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", required: false, }), - force: Flags.boolean({ - char: "f", - default: false, - description: "Force deletion without confirmation", - required: false, - }), + ...forceFlag, }; async run(): Promise { @@ -58,7 +50,16 @@ export default class RulesDeleteCommand extends ControlBaseCommand { }); } - // If not using force flag or JSON mode, prompt for confirmation + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm deletion", + flags, + "ruleDelete", + ); + } + + // If not using force flag, prompt for confirmation if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following rule:`); this.log(`${formatLabel("ID")} ${formatResource(namespace.id)}`); @@ -77,7 +78,7 @@ export default class RulesDeleteCommand extends ControlBaseCommand { if (!confirmed) { // This branch is only reachable when !shouldOutputJson (see outer condition), // so only human-readable output is needed here. - this.log("Deletion cancelled"); + this.logWarning("Deletion cancelled.", flags); return; } } @@ -95,11 +96,12 @@ export default class RulesDeleteCommand extends ControlBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess(`Rule ${formatResource(namespace.id)} deleted.`), - ); } + + this.logSuccessMessage( + `Channel rule ${formatResource(namespace.id)} deleted.`, + flags, + ); } catch (error) { this.fail(error, flags, "ruleDelete", { appId }); } diff --git a/src/commands/apps/rules/update.ts b/src/commands/apps/rules/update.ts index 7d51b169..a02e9c48 100644 --- a/src/commands/apps/rules/update.ts +++ b/src/commands/apps/rules/update.ts @@ -2,12 +2,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { - formatLabel, - formatResource, - formatSuccess, - formatWarning, -} from "../../../utils/output.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; export default class RulesUpdateCommand extends ControlBaseCommand { static args = { @@ -161,13 +156,10 @@ export default class RulesUpdateCommand extends ControlBaseCommand { updateData.mutableMessages = flags["mutable-messages"]; if (flags["mutable-messages"]) { updateData.persisted = true; - if (!this.shouldOutputJson(flags)) { - this.logToStderr( - formatWarning( - "Message persistence is automatically enabled when mutable messages is enabled.", - ), - ); - } + this.logWarning( + "Message persistence is automatically enabled when mutable messages is enabled.", + flags, + ); } } @@ -254,9 +246,6 @@ export default class RulesUpdateCommand extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess(`Rule ${formatResource(updatedNamespace.id)} updated.`), - ); this.log(`${formatLabel("ID")} ${formatResource(updatedNamespace.id)}`); for (const line of formatChannelRuleDetails(updatedNamespace, { bold: true, @@ -266,6 +255,11 @@ export default class RulesUpdateCommand extends ControlBaseCommand { this.log(line); } } + + this.logSuccessMessage( + `Channel rule ${formatResource(updatedNamespace.id)} updated.`, + flags, + ); } catch (error) { this.fail(error, flags, "ruleUpdate", { appId }); } diff --git a/src/commands/apps/switch.ts b/src/commands/apps/switch.ts index afbfe1eb..a87c1ae0 100644 --- a/src/commands/apps/switch.ts +++ b/src/commands/apps/switch.ts @@ -54,14 +54,13 @@ export default class AppsSwitch extends ControlBaseCommand { flags, ); } else { - this.log( - `Switched to app: ${formatResource(selectedApp.name)} (${selectedApp.id})`, + this.logSuccessMessage( + `Switched to app ${formatResource(selectedApp.name)} (${selectedApp.id}).`, + flags, ); } } else { - if (!this.shouldOutputJson(flags)) { - this.log("App switch cancelled."); - } + this.logWarning("App switch cancelled.", flags); } } catch (error) { this.fail(error, flags, "appSwitch"); @@ -84,7 +83,10 @@ export default class AppsSwitch extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ app: { id: app.id, name: app.name } }, flags); } else { - this.log(`Switched to app: ${formatResource(app.name)} (${app.id})`); + this.logSuccessMessage( + `Switched to app ${formatResource(app.name)} (${app.id}).`, + flags, + ); } } catch (error) { this.fail(error, flags, "appSwitch", { diff --git a/src/commands/apps/update.ts b/src/commands/apps/update.ts index c80b7d84..a0275ea7 100644 --- a/src/commands/apps/update.ts +++ b/src/commands/apps/update.ts @@ -1,11 +1,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatProgress, - formatResource, -} from "../../utils/output.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; export default class AppsUpdateCommand extends ControlBaseCommand { static args = { @@ -54,9 +50,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress(`Updating app ${formatResource(args.id)}`)); - } + this.logProgress(`Updating app ${formatResource(args.id)}`, flags); const updateData: { name?: string; tlsOnly?: boolean } = {}; @@ -90,7 +84,6 @@ export default class AppsUpdateCommand extends ControlBaseCommand { flags, ); } else { - this.log(`\nApp updated successfully!`); this.log(`${formatLabel("App ID")} ${app.id}`); this.log(`${formatLabel("Name")} ${app.name}`); this.log(`${formatLabel("Status")} ${app.status}`); @@ -104,6 +97,8 @@ export default class AppsUpdateCommand extends ControlBaseCommand { ); } } + + this.logSuccessMessage("App updated successfully.", flags); } catch (error) { this.fail(error, flags, "appUpdate", { appId: args.id, diff --git a/src/commands/auth/keys/create.ts b/src/commands/auth/keys/create.ts index 1427bea9..ad972778 100644 --- a/src/commands/auth/keys/create.ts +++ b/src/commands/auth/keys/create.ts @@ -2,12 +2,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatCapabilities } from "../../../utils/key-display.js"; -import { - formatLabel, - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; export default class KeysCreateCommand extends ControlBaseCommand { static description = "Create a new API key for an app"; @@ -58,13 +53,10 @@ export default class KeysCreateCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Creating key ${formatResource(flags.name)} for app ${formatResource(appId)}`, - ), - ); - } + this.logProgress( + `Creating key ${formatResource(flags.name)} for app ${formatResource(appId)}`, + flags, + ); const key = await controlApi.createKey(appId, { capability: capabilities, @@ -85,7 +77,6 @@ export default class KeysCreateCommand extends ControlBaseCommand { ); } else { const keyName = `${key.appId}.${key.id}`; - this.log(formatSuccess(`Key created: ${formatResource(keyName)}.`)); this.log(`${formatLabel("Key Name")} ${keyName}`); this.log(`${formatLabel("Key Label")} ${key.name || "Unnamed key"}`); @@ -104,6 +95,12 @@ export default class KeysCreateCommand extends ControlBaseCommand { `\nTo switch to this key, run: ably auth keys switch ${keyName}`, ); } + + const displayKeyName = `${key.appId}.${key.id}`; + this.logSuccessMessage( + `Key created: ${formatResource(displayKeyName)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "keyCreate", { appId }); } diff --git a/src/commands/auth/keys/get.ts b/src/commands/auth/keys/get.ts index 949d6a78..84e1f9a9 100644 --- a/src/commands/auth/keys/get.ts +++ b/src/commands/auth/keys/get.ts @@ -5,9 +5,7 @@ import { formatCapabilities } from "../../../utils/key-display.js"; import { formatHeading, formatLabel, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; export default class KeysGetCommand extends ControlBaseCommand { @@ -67,9 +65,7 @@ export default class KeysGetCommand extends ControlBaseCommand { await this.showAuthInfoIfNeeded(flags); try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Fetching key details")); - } + this.logProgress("Fetching key details", flags); const controlApi = this.createControlApi(flags); const key = await controlApi.getKey(appId, keyIdentifier); @@ -125,11 +121,10 @@ export default class KeysGetCommand extends ControlBaseCommand { if (hasEnvOverride) { this.logToStderr(""); - this.logToStderr( - formatWarning( - `ABLY_API_KEY environment variable is set to a different key (${envKeyPrefix}). ` + - `The env var overrides this key for product API commands.`, - ), + this.logWarning( + `ABLY_API_KEY environment variable is set to a different key (${envKeyPrefix}). ` + + `The env var overrides this key for product API commands.`, + flags, ); } } diff --git a/src/commands/auth/keys/revoke.ts b/src/commands/auth/keys/revoke.ts index eca0a615..791f10d8 100644 --- a/src/commands/auth/keys/revoke.ts +++ b/src/commands/auth/keys/revoke.ts @@ -1,13 +1,10 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; +import { forceFlag } from "../../../flags.js"; import { formatCapabilities } from "../../../utils/key-display.js"; import { parseKeyIdentifier } from "../../../utils/key-parsing.js"; -import { - formatLabel, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; export default class KeysRevokeCommand extends ControlBaseCommand { static args = { @@ -33,10 +30,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", env: "ABLY_APP_ID", }), - force: Flags.boolean({ - default: false, - description: "Skip confirmation prompt", - }), + ...forceFlag, }; async run(): Promise { @@ -71,24 +65,24 @@ export default class KeysRevokeCommand extends ControlBaseCommand { this.log(""); } - let confirmed = flags.force; + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm revocation", + flags, + "keyRevoke", + ); + } - if (!confirmed) { - confirmed = await this.interactiveHelper.confirm( + if (!flags.force && !this.shouldOutputJson(flags)) { + const confirmed = await this.interactiveHelper.confirm( "This will permanently revoke this key and any applications using it will stop working. Continue?", ); - } - if (!confirmed) { - if (this.shouldOutputJson(flags)) { - this.fail("Revocation cancelled by user", flags, "keyRevoke", { - keyName, - }); - } else { - this.log("Revocation cancelled."); + if (!confirmed) { + this.logWarning("Revocation cancelled.", flags); + return; } - - return; } await controlApi.revokeKey(appId, keyId); @@ -104,8 +98,9 @@ export default class KeysRevokeCommand extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess(`Key ${formatResource(keyName)} has been revoked.`), + this.logSuccessMessage( + `Key ${formatResource(keyName)} has been revoked.`, + flags, ); } @@ -119,9 +114,7 @@ export default class KeysRevokeCommand extends ControlBaseCommand { if (shouldRemove) { this.configManager.removeApiKey(appId); - if (!this.shouldOutputJson(flags)) { - this.log("Key removed from configuration."); - } + this.logSuccessMessage("Key removed from configuration.", flags); } } } catch (error) { diff --git a/src/commands/auth/keys/switch.ts b/src/commands/auth/keys/switch.ts index b2e0f166..31694ee4 100644 --- a/src/commands/auth/keys/switch.ts +++ b/src/commands/auth/keys/switch.ts @@ -3,7 +3,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { ControlApi } from "../../../services/control-api.js"; import { parseKeyIdentifier } from "../../../utils/key-parsing.js"; -import { formatResource, formatSuccess } from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class KeysSwitchCommand extends ControlBaseCommand { static args = { @@ -105,15 +105,14 @@ export default class KeysSwitchCommand extends ControlBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess(`Switched to key ${formatResource(keyName)}.`), - ); } + + this.logSuccessMessage( + `Switched to key ${formatResource(keyName)}.`, + flags, + ); } else { - if (!this.shouldOutputJson(flags)) { - this.log("Key switch cancelled."); - } + this.logWarning("Key switch cancelled.", flags); } } catch (error) { this.fail(error, flags, "keySwitch"); @@ -165,9 +164,12 @@ export default class KeysSwitchCommand extends ControlBaseCommand { }, flags, ); - } else { - this.log(formatSuccess(`Switched to key ${formatResource(keyName)}.`)); } + + this.logSuccessMessage( + `Switched to key ${formatResource(keyName)}.`, + flags, + ); } catch { this.fail( `Key "${keyIdOrValue}" not found or access denied.`, diff --git a/src/commands/auth/revoke-token.ts b/src/commands/auth/revoke-token.ts index 5a4a871d..982d3538 100644 --- a/src/commands/auth/revoke-token.ts +++ b/src/commands/auth/revoke-token.ts @@ -108,7 +108,7 @@ export default class RevokeTokenCommand extends AblyBaseCommand { flags, ); } else { - this.log("Token successfully revoked"); + this.logSuccessMessage("Token successfully revoked.", flags); } } catch (requestError: unknown) { // Handle specific API errors diff --git a/src/commands/bench/publisher.ts b/src/commands/bench/publisher.ts index ebea5cc5..de20d700 100644 --- a/src/commands/bench/publisher.ts +++ b/src/commands/bench/publisher.ts @@ -6,7 +6,6 @@ import Table from "cli-table3"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatSuccess } from "../../utils/output.js"; import type { BenchPresenceData } from "../../types/bench.js"; interface TestMetrics { @@ -206,13 +205,10 @@ export default class BenchPublisher extends AblyBaseCommand { `Starting benchmark test with ID: ${testId}`, { messageCount, messageRate, messageSize, transport: flags.transport }, ); - if (!this.shouldOutputJson(flags)) { - this.log(`\nStarting benchmark test with ID: ${testId}`); - this.log( - `Publishing ${messageCount} messages at ${messageRate} msg/sec using ${flags.transport} transport`, - ); - this.log(`Message size: ${messageSize} bytes\n`); - } + this.logProgress( + `Starting benchmark test with ID: ${testId}. Publishing ${messageCount} messages at ${messageRate} msg/sec using ${flags.transport} transport. Message size: ${messageSize} bytes`, + flags, + ); const { intervalId: progressIntervalId, progressDisplay } = this.setupProgressDisplay(flags, metrics, messageCount); @@ -249,9 +245,10 @@ export default class BenchPublisher extends AblyBaseCommand { "waitingForEchoes", "Waiting for remaining messages to be echoed back...", ); - if (!this.shouldOutputJson(flags)) { - this.log("\nWaiting for remaining messages to be echoed back..."); - } + this.logProgress( + "Waiting for remaining messages to be echoed back", + flags, + ); await this.delay(3000); @@ -530,7 +527,7 @@ export default class BenchPublisher extends AblyBaseCommand { process.stdout.write("\u001B[2J\u001B[0f"); } - this.log("\n\n" + formatSuccess("Benchmark complete.") + "\n"); + this.log(""); const summaryTable = new Table({ head: [chalk.white("Metric"), chalk.white("Value")], style: { border: [], head: [] }, @@ -565,8 +562,10 @@ export default class BenchPublisher extends AblyBaseCommand { "• Echo Latency: Round trip time (Publisher → Ably → Publisher)", ); this.log(latencyTable.toString()); - this.log("\nTest complete. Disconnecting..."); + this.logProgress("Test complete. Disconnecting", flags); } + + this.logSuccessMessage("Benchmark complete.", flags); } private async enterPresence( diff --git a/src/commands/bench/subscriber.ts b/src/commands/bench/subscriber.ts index e6c39c5e..2e9d5b03 100644 --- a/src/commands/bench/subscriber.ts +++ b/src/commands/bench/subscriber.ts @@ -5,12 +5,7 @@ import Table from "cli-table3"; import { AblyBaseCommand, type BaseFlags } from "../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../flags.js"; -import { - formatHeading, - formatProgress, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatHeading, formatResource } from "../../utils/output.js"; import type { BenchMessageData, BenchPresenceData } from "../../types/bench.js"; interface TestMetrics { @@ -96,13 +91,10 @@ export default class BenchSubscriber extends AblyBaseCommand { channel = this.handleChannel(client, args.channel, flags); // Show initial status - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Attaching to channel: ${formatResource(args.channel)}`, - ), - ); - } + this.logProgress( + `Attaching to channel: ${formatResource(args.channel)}`, + flags, + ); await this.handlePresence(channel, metrics, flags); @@ -120,13 +112,10 @@ export default class BenchSubscriber extends AblyBaseCommand { ); // Show success message - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to channel: ${formatResource(args.channel)}. Waiting for benchmark messages.`, - ), - ); - } + this.logSuccessMessage( + `Subscribed to channel: ${formatResource(args.channel)}. Waiting for benchmark messages.`, + flags, + ); await this.waitForTermination(flags); } catch (error) { @@ -171,9 +160,10 @@ export default class BenchSubscriber extends AblyBaseCommand { "initialPublishersFound", `Found ${publishers.length} publisher(s) already present`, ); - if (!this.shouldOutputJson(flags)) { - this.log(`Found ${publishers.length} publisher(s) already present`); - } + this.logProgress( + `Found ${publishers.length} publisher(s) already present`, + flags, + ); for (const publisher of publishers) { const data = publisher.data as BenchPresenceData | undefined; @@ -208,13 +198,12 @@ export default class BenchSubscriber extends AblyBaseCommand { } } - if (!this.shouldOutputJson(flags)) { - this.log(`Active test ID: ${metrics.testId}`); - if (metrics.testDetails) { - this.log( - `Test will send ${String(metrics.testDetails.messageCount)} messages at ${String(metrics.testDetails.messageRate)} msg/sec using ${String(metrics.testDetails.transport)} transport`, - ); - } + this.logProgress(`Active test ID: ${metrics.testId}`, flags); + if (metrics.testDetails) { + this.logProgress( + `Test will send ${String(metrics.testDetails.messageCount)} messages at ${String(metrics.testDetails.messageRate)} msg/sec using ${String(metrics.testDetails.transport)} transport`, + flags, + ); } } } @@ -438,12 +427,11 @@ export default class BenchSubscriber extends AblyBaseCommand { metrics.publisherActive = true; metrics.lastMessageTime = Date.now(); // Do not start a new test here, wait for the first message - if (!this.shouldOutputJson(flags)) { - this.log(`\nPublisher detected with test ID: ${testId}`); - this.log( - `Test will send ${String(testDetails.messageCount)} messages at ${String(testDetails.messageRate)} msg/sec using ${String(testDetails.transport)} transport`, - ); - } + this.logProgress(`Publisher detected with test ID: ${testId}`, flags); + this.logProgress( + `Test will send ${String(testDetails.messageCount)} messages at ${String(testDetails.messageRate)} msg/sec using ${String(testDetails.transport)} transport`, + flags, + ); } }, ); @@ -495,15 +483,14 @@ export default class BenchSubscriber extends AblyBaseCommand { this.checkPublisherIntervalId = null; } - if (this.shouldOutputJson(flags)) { - this.logCliEvent( - flags, - "benchmark", - "waitingForTest", - "Waiting for a new benchmark test to start...", - ); - } else { - this.log("\nWaiting for a new benchmark test to start..."); + this.logCliEvent( + flags, + "benchmark", + "waitingForTest", + "Waiting for a new benchmark test to start...", + ); + this.logProgress("Waiting for a new benchmark test to start", flags); + if (!this.shouldOutputJson(flags)) { this.displayTable = this.createStatusDisplay(null); this.log(this.displayTable.toString()); } @@ -643,15 +630,14 @@ export default class BenchSubscriber extends AblyBaseCommand { this.checkPublisherIntervalId = null; } - if (this.shouldOutputJson(flags)) { - this.logCliEvent( - flags, - "benchmark", - "waitingForTest", - "Waiting for a new benchmark test to start...", - ); - } else { - this.log("\nWaiting for a new benchmark test to start..."); + this.logCliEvent( + flags, + "benchmark", + "waitingForTest", + "Waiting for a new benchmark test to start...", + ); + this.logProgress("Waiting for a new benchmark test to start", flags); + if (!this.shouldOutputJson(flags)) { this.displayTable = this.createStatusDisplay(null); this.log(this.displayTable.toString()); } diff --git a/src/commands/channels/annotations/delete.ts b/src/commands/channels/annotations/delete.ts index 470b64cb..ddecea70 100644 --- a/src/commands/channels/annotations/delete.ts +++ b/src/commands/channels/annotations/delete.ts @@ -7,12 +7,7 @@ import { extractSummarizationType, validateAnnotationParams, } from "../../../utils/annotations.js"; -import { - formatLabel, - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class ChannelsAnnotationsDelete extends AblyBaseCommand { static override args = { @@ -73,13 +68,10 @@ export default class ChannelsAnnotationsDelete extends AblyBaseCommand { const channel = client.channels.get(channelName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Deleting annotation on message ${formatResource(serial)} in channel ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Deleting annotation on message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + flags, + ); const annotation: Ably.OutboundAnnotation = { type }; if (flags.name !== undefined) annotation.name = flags.name; @@ -111,17 +103,12 @@ export default class ChannelsAnnotationsDelete extends AblyBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `Annotation deleted on message ${formatResource(serial)} in channel ${formatResource(channelName)}.`, - ), - ); - this.log(` ${formatLabel("Type")} ${formatResource(type)}`); - if (flags.name !== undefined) { - this.log(` ${formatLabel("Name")} ${formatResource(flags.name)}`); - } } + + this.logSuccessMessage( + `Annotation deleted on message ${formatResource(serial)} in channel ${formatResource(channelName)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "annotationDelete", { channel: channelName, diff --git a/src/commands/channels/annotations/get.ts b/src/commands/channels/annotations/get.ts index a4e681d4..1d60340a 100644 --- a/src/commands/channels/annotations/get.ts +++ b/src/commands/channels/annotations/get.ts @@ -9,7 +9,6 @@ import { formatIndex, formatLimitWarning, formatMessageTimestamp, - formatProgress, formatResource, formatTimestamp, } from "../../../utils/output.js"; @@ -56,13 +55,10 @@ export default class ChannelsAnnotationsGet extends AblyBaseCommand { const channel = rest.channels.get(channelName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Getting annotations for message ${formatResource(serial)} in channel ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Getting annotations for message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + flags, + ); const params: Ably.GetAnnotationsParams = { limit: flags.limit }; diff --git a/src/commands/channels/annotations/publish.ts b/src/commands/channels/annotations/publish.ts index 49295a62..b729e83d 100644 --- a/src/commands/channels/annotations/publish.ts +++ b/src/commands/channels/annotations/publish.ts @@ -7,12 +7,7 @@ import { extractSummarizationType, validateAnnotationParams, } from "../../../utils/annotations.js"; -import { - formatLabel, - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class ChannelsAnnotationsPublish extends AblyBaseCommand { static override args = { @@ -81,13 +76,10 @@ export default class ChannelsAnnotationsPublish extends AblyBaseCommand { const channel = rest.channels.get(channelName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Publishing annotation on message ${formatResource(serial)} in channel ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Publishing annotation on message ${formatResource(serial)} in channel ${formatResource(channelName)}`, + flags, + ); const annotation: Ably.OutboundAnnotation = { type }; if (flags.name !== undefined) annotation.name = flags.name; @@ -136,37 +128,12 @@ export default class ChannelsAnnotationsPublish extends AblyBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `Annotation published on message ${formatResource(serial)} in channel ${formatResource(channelName)}.`, - ), - ); - this.log(` ${formatLabel("Type")} ${formatResource(type)}`); - if (flags.name !== undefined) { - this.log(` ${formatLabel("Name")} ${formatResource(flags.name)}`); - } - - if (flags.count !== undefined) { - this.log( - ` ${formatLabel("Count")} ${formatResource(String(flags.count))}`, - ); - } - - if (annotation.data !== undefined) { - const displayData = - typeof annotation.data === "string" - ? annotation.data - : JSON.stringify(annotation.data, null, 2); - this.log(` ${formatLabel("Data")} ${formatResource(displayData)}`); - } - - if (flags.encoding !== undefined) { - this.log( - ` ${formatLabel("Encoding")} ${formatResource(flags.encoding)}`, - ); - } } + + this.logSuccessMessage( + `Annotation published on message ${formatResource(serial)} in channel ${formatResource(channelName)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "annotationPublish", { channel: channelName, diff --git a/src/commands/channels/annotations/subscribe.ts b/src/commands/channels/annotations/subscribe.ts index 959da8ab..6133e010 100644 --- a/src/commands/channels/annotations/subscribe.ts +++ b/src/commands/channels/annotations/subscribe.ts @@ -10,11 +10,8 @@ import { } from "../../../flags.js"; import { formatAnnotationsOutput, - formatListening, formatMessageTimestamp, - formatProgress, formatResource, - formatSuccess, } from "../../../utils/output.js"; import type { AnnotationDisplayFields } from "../../../utils/output.js"; @@ -84,13 +81,10 @@ export default class ChannelsAnnotationsSubscribe extends AblyBaseCommand { { channel: channelName }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Attaching to channel: ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Attaching to channel: ${formatResource(channelName)}`, + flags, + ); this.setupChannelStateLogging(channel, flags, { includeUserFriendlyMessages: true, @@ -173,13 +167,12 @@ export default class ChannelsAnnotationsSubscribe extends AblyBaseCommand { await attachPromise; + this.logSuccessMessage( + `Subscribed to annotations on channel: ${formatResource(channelName)}.`, + flags, + ); + this.logListening("Listening for annotations.", flags); if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to annotations on channel: ${formatResource(channelName)}.`, - ), - ); - this.log(formatListening("Listening for annotations.")); this.log(""); } diff --git a/src/commands/channels/append.ts b/src/commands/channels/append.ts index a5d3ce23..16538048 100644 --- a/src/commands/channels/append.ts +++ b/src/commands/channels/append.ts @@ -4,12 +4,7 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; import { prepareMessageFromInput } from "../../utils/message.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class ChannelsAppend extends AblyBaseCommand { static override args = { @@ -65,13 +60,10 @@ export default class ChannelsAppend extends AblyBaseCommand { const channel = rest.channels.get(channelName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Appending to message ${formatResource(serial)} on channel ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Appending to message ${formatResource(serial)} on channel ${formatResource(channelName)}`, + flags, + ); const message = prepareMessageFromInput(args.message, flags, { serial }); const operation: Ably.MessageOperation | undefined = flags.description @@ -96,19 +88,21 @@ export default class ChannelsAppend extends AblyBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Appended to message ${formatResource(serial)} on channel ${formatResource(channelName)}.`, - ), - ); if (versionSerial) { this.log(` Version serial: ${formatResource(versionSerial)}`); - } else if (versionSerial === null) { - this.log( - formatWarning("Message was superseded by a subsequent operation."), - ); } } + + this.logSuccessMessage( + `Appended to message ${formatResource(serial)} on channel ${formatResource(channelName)}.`, + flags, + ); + if (versionSerial === null) { + this.logWarning( + "Message was superseded by a subsequent operation.", + flags, + ); + } } catch (error) { this.fail(error, flags, "channelAppend", { channel: channelName, diff --git a/src/commands/channels/batch-publish.ts b/src/commands/channels/batch-publish.ts index 37ba54e6..0b3b6cea 100644 --- a/src/commands/channels/batch-publish.ts +++ b/src/commands/channels/batch-publish.ts @@ -4,11 +4,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { CommandError } from "../../errors/command-error.js"; import { productApiFlags } from "../../flags.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; // Define interfaces for the batch-publish command interface BatchMessage { @@ -202,8 +198,8 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { } as BatchContent; } - if (!this.shouldOutputJson(flags) && !this.shouldSuppressOutput(flags)) { - this.log(formatProgress("Sending batch publish request")); + if (!this.shouldSuppressOutput(flags)) { + this.logProgress("Sending batch publish request", flags); } // Make the batch publish request using the REST client's request method @@ -234,7 +230,7 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { }; this.logJsonResult({ publish: publishData }, flags); } else { - this.log(formatSuccess("Batch publish successful.")); + this.logSuccessMessage("Batch publish successful.", flags); this.log( `Response: ${JSON.stringify({ responses: responseItems }, null, 2)}`, ); @@ -254,36 +250,35 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { errorInfo.batchResponse ) { // This is a partial success with batchResponse field + if (this.shouldOutputJson(flags)) { + this.fail(errorInfo.error.message, flags, "batchPublish", { + channels: Array.isArray(batchContentObj.channels) + ? batchContentObj.channels + : [batchContentObj.channels], + message: batchContentObj.messages, + partial: true, + response: errorInfo.batchResponse, + }); + } + if (!this.shouldSuppressOutput(flags)) { - if (this.shouldOutputJson(flags)) { - this.fail(errorInfo.error, flags, "batchPublish", { - channels: Array.isArray(batchContentObj.channels) - ? batchContentObj.channels - : [batchContentObj.channels], - message: batchContentObj.messages, - partial: true, - response: errorInfo.batchResponse, - }); - } else { - this.log( - "Batch publish partially successful (some messages failed).", - ); - // Format batch response in a friendly way - const batchResponses = errorInfo.batchResponse; - batchResponses.forEach((item: BatchResponseItem) => { - if (item.error) { - this.log( - `${chalk.red("✗")} Failed to publish to channel ${formatResource(item.channel)}: ${item.error.message} (${item.error.code})`, - ); - } else { - this.log( - formatSuccess( - `Published to channel ${formatResource(item.channel)} with messageId: ${item.messageId}.`, - ), - ); - } - }); - } + this.log( + "Batch publish partially successful (some messages failed).", + ); + // Format batch response in a friendly way + const batchResponses = errorInfo.batchResponse; + batchResponses.forEach((item: BatchResponseItem) => { + if (item.error) { + this.log( + `${chalk.red("✗")} Failed to publish to channel ${formatResource(item.channel)}: ${item.error.message} (${item.error.code})`, + ); + } else { + this.logSuccessMessage( + `Published to channel ${formatResource(item.channel)} with messageId: ${item.messageId}.`, + flags, + ); + } + }); } } else { // Complete failure diff --git a/src/commands/channels/delete.ts b/src/commands/channels/delete.ts index bdcc0ede..fff9412d 100644 --- a/src/commands/channels/delete.ts +++ b/src/commands/channels/delete.ts @@ -3,12 +3,7 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class ChannelsDelete extends AblyBaseCommand { static override args = { @@ -50,13 +45,10 @@ export default class ChannelsDelete extends AblyBaseCommand { const channel = rest.channels.get(channelName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Deleting message ${formatResource(serial)} on channel ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Deleting message ${formatResource(serial)} on channel ${formatResource(channelName)}`, + flags, + ); const message: Partial = { serial }; const operation: Ably.MessageOperation | undefined = flags.description @@ -84,19 +76,21 @@ export default class ChannelsDelete extends AblyBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Message ${formatResource(serial)} deleted on channel ${formatResource(channelName)}.`, - ), - ); if (versionSerial) { this.log(` Version serial: ${formatResource(versionSerial)}`); - } else if (versionSerial === null) { - this.log( - formatWarning("Message was superseded by a subsequent operation."), - ); } } + + this.logSuccessMessage( + `Message ${formatResource(serial)} deleted on channel ${formatResource(channelName)}.`, + flags, + ); + if (versionSerial === null) { + this.logWarning( + "Message was superseded by a subsequent operation.", + flags, + ); + } } catch (error) { this.fail(error, flags, "channelDelete", { channel: channelName, diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 0f614546..2d3b8426 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -9,7 +9,6 @@ import { formatMessageTimestamp, formatIndex, formatCountLabel, - formatProgress, formatResource, formatLimitWarning, formatMessagesOutput, @@ -100,10 +99,9 @@ export default class ChannelsHistory extends AblyBaseCommand { flags, ); } else { - this.log( - formatProgress( - `Fetching ${flags.limit} most recent messages from channel ${formatResource(channelName)}`, - ), + this.logProgress( + `Fetching ${flags.limit} most recent messages from channel ${formatResource(channelName)}`, + flags, ); } @@ -121,7 +119,7 @@ export default class ChannelsHistory extends AblyBaseCommand { true, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } // Display results based on format diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index eca0fdbf..61e94afd 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -102,7 +102,7 @@ export default class ChannelsList extends AblyBaseCommand { channels.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } // Output channels based on format diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index 6e9a2f21..ef044ce7 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -3,10 +3,7 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { - formatListening, - formatProgress, formatResource, - formatSuccess, formatTimestamp, formatMessageTimestamp, formatLabel, @@ -78,13 +75,10 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { { channel: channelName }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Subscribing to occupancy events on channel: ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Subscribing to occupancy events on channel: ${formatResource(channelName)}`, + flags, + ); await channel.subscribe(occupancyEventName, (message: Ably.Message) => { const timestamp = formatMessageTimestamp(message.timestamp); @@ -138,14 +132,11 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { } }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to occupancy on channel: ${formatResource(channelName)}.`, - ), - ); - this.log(formatListening("Listening for occupancy events.")); - } + this.logSuccessMessage( + `Subscribed to occupancy on channel: ${formatResource(channelName)}.`, + flags, + ); + this.logListening("Listening for occupancy events.", flags); this.logCliEvent( flags, diff --git a/src/commands/channels/presence/enter.ts b/src/commands/channels/presence/enter.ts index b928d3dc..7b2f1433 100644 --- a/src/commands/channels/presence/enter.ts +++ b/src/commands/channels/presence/enter.ts @@ -8,11 +8,8 @@ import { formatEventType, formatIndex, formatLabel, - formatListening, formatMessageTimestamp, - formatProgress, formatResource, - formatSuccess, formatTimestamp, } from "../../../utils/output.js"; @@ -144,13 +141,10 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { }); } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Entering presence on channel: ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Entering presence on channel: ${formatResource(channelName)}`, + flags, + ); // Enter presence this.logCliEvent( @@ -186,10 +180,9 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Entered presence on channel: ${formatResource(channelName)}.`, - ), + this.logSuccessMessage( + `Entered presence on channel: ${formatResource(channelName)}.`, + flags, ); this.log( `${formatLabel("Client ID")} ${formatClientId(client.auth.clientId)}`, @@ -198,21 +191,16 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { if (data !== undefined) { this.log(`${formatLabel("Data")} ${JSON.stringify(data)}`); } - this.log( - formatListening( - flags["show-others"] - ? "Listening for presence events." - : "Holding presence.", - ), + } + if (flags["show-others"]) { + this.logListening( + "Listening for presence events. Press Ctrl+C to exit.", + flags, ); + } else { + this.logHolding("Holding presence. Press Ctrl+C to exit.", flags); } - this.logJsonStatus( - "holding", - "Holding presence. Press Ctrl+C to exit.", - flags, - ); - // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "presence", flags.duration); } catch (error) { diff --git a/src/commands/channels/presence/get.ts b/src/commands/channels/presence/get.ts index 3ba5dd54..477d459e 100644 --- a/src/commands/channels/presence/get.ts +++ b/src/commands/channels/presence/get.ts @@ -10,9 +10,7 @@ import { formatLabel, formatLimitWarning, formatMessageTimestamp, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; import { buildPaginationNext, @@ -55,13 +53,10 @@ export default class ChannelsPresenceGet extends AblyBaseCommand { const channelName = args.channel; - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching presence members for channel: ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Fetching presence members for channel: ${formatResource(channelName)}`, + flags, + ); this.logCliEvent( flags, @@ -94,7 +89,7 @@ export default class ChannelsPresenceGet extends AblyBaseCommand { items.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -118,9 +113,7 @@ export default class ChannelsPresenceGet extends AblyBaseCommand { flags, ); } else if (items.length === 0) { - this.logToStderr( - formatWarning("No members currently present on this channel."), - ); + this.logWarning("No members currently present on this channel.", flags); } else { this.log( `\n${formatHeading(`Presence members on channel: ${channelName}`)} (${formatCountLabel(items.length, "member")}):\n`, diff --git a/src/commands/channels/presence/subscribe.ts b/src/commands/channels/presence/subscribe.ts index 152c82c1..321543a6 100644 --- a/src/commands/channels/presence/subscribe.ts +++ b/src/commands/channels/presence/subscribe.ts @@ -3,10 +3,7 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { - formatListening, - formatProgress, formatResource, - formatSuccess, formatMessageTimestamp, formatPresenceOutput, } from "../../../utils/output.js"; @@ -71,13 +68,10 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { { channel: channelName }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Subscribing to presence events on channel: ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Subscribing to presence events on channel: ${formatResource(channelName)}`, + flags, + ); await channel.presence.subscribe( (presenceMessage: Ably.PresenceMessage) => { @@ -117,13 +111,12 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { }, ); + this.logSuccessMessage( + `Subscribed to presence on channel: ${formatResource(channelName)}.`, + flags, + ); + this.logListening("Listening for presence events.", flags); if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to presence on channel: ${formatResource(channelName)}.`, - ), - ); - this.log(formatListening("Listening for presence events.")); this.log(""); } diff --git a/src/commands/channels/publish.ts b/src/commands/channels/publish.ts index 2327ec57..e05a4d70 100644 --- a/src/commands/channels/publish.ts +++ b/src/commands/channels/publish.ts @@ -7,11 +7,7 @@ import { clientIdFlag, productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; import { errorMessage, extractErrorInfo } from "../../utils/errors.js"; import { prepareMessageFromInput } from "../../utils/message.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class ChannelsPublish extends AblyBaseCommand { static override args = { @@ -138,28 +134,20 @@ export default class ChannelsPublish extends AblyBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult(finalResult, flags); } else if (total > 1) { - this.log( - formatSuccess( - `${published}/${total} messages published to channel: ${formatResource(args.channel as string)}${errors > 0 ? ` (${chalk.red(errors)} errors)` : ""}.`, - ), + this.logSuccessMessage( + `${published}/${total} messages published to channel: ${formatResource(args.channel as string)}${errors > 0 ? ` (${chalk.red(errors)} errors)` : ""}.`, + flags, ); } else if (errors === 0) { - const serial = - results[0]?.serial == null - ? undefined - : typeof results[0].serial === "string" - ? results[0].serial - : JSON.stringify(results[0].serial); - this.log( - formatSuccess( - `Message published to channel: ${formatResource(args.channel as string)}.`, - ), + this.logSuccessMessage( + `Message published to channel: ${formatResource(args.channel as string)}.`, + flags, ); - if (serial) { + const rawSerial = results[0]?.serial; + const serial = typeof rawSerial === "string" ? rawSerial : undefined; + if (serial && total === 1) { this.log(` Serial: ${formatResource(serial)}`); } - } else { - // Error message already logged by publishMessages loop or prepareMessage } } } @@ -179,9 +167,10 @@ export default class ChannelsPublish extends AblyBaseCommand { `Publishing ${count} messages with ${delay}ms delay...`, { count, delay }, ); - if (count > 1 && !this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Publishing ${count} messages with ${delay}ms delay`), + if (count > 1) { + this.logProgress( + `Publishing ${count} messages with ${delay}ms delay`, + flags, ); } @@ -244,17 +233,20 @@ export default class ChannelsPublish extends AblyBaseCommand { ); if ( !this.shouldSuppressOutput(flags) && - !this.shouldOutputJson(flags) && count > 1 // Only show individual success messages when publishing multiple messages ) { - this.log( - formatSuccess( - `Message ${messageIndex} published to channel: ${formatResource(args.channel as string)}.`, - ), + this.logSuccessMessage( + `Message ${messageIndex} published to channel: ${formatResource(args.channel as string)}.`, + flags, ); - if (serial) { - this.log(` Serial: ${formatResource(serial)}`); - } + } + if ( + !this.shouldSuppressOutput(flags) && + !this.shouldOutputJson(flags) && + count > 1 && // Only show individual serial when publishing multiple messages + serial + ) { + this.log(` Serial: ${formatResource(serial)}`); } } catch (error) { errorCount++; @@ -272,12 +264,10 @@ export default class ChannelsPublish extends AblyBaseCommand { `Error publishing message ${messageIndex}: ${errorMsg}`, { error: errorMsg, index: messageIndex }, ); - if ( - !this.shouldSuppressOutput(flags) && - !this.shouldOutputJson(flags) - ) { - this.log( - `${chalk.red("✗")} Error publishing message ${messageIndex}: ${errorMsg}`, + if (!this.shouldSuppressOutput(flags)) { + this.logWarning( + `Error publishing message ${messageIndex}: ${errorMsg}`, + flags, ); } } @@ -399,8 +389,9 @@ export default class ChannelsPublish extends AblyBaseCommand { ); }, 2000) : setInterval(() => { - this.log( - `Progress: ${getPublishedCount()}/${total} messages published (${getErrorCount()} errors)`, + this.logProgress( + `${getPublishedCount()}/${total} messages published (${getErrorCount()} errors)`, + flags, ); }, 1000); } diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index cc503c27..a2f742e7 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -8,10 +8,7 @@ import { rewindFlag, } from "../../flags.js"; import { - formatListening, - formatProgress, formatResource, - formatSuccess, formatMessageTimestamp, formatIndex, formatMessagesOutput, @@ -151,13 +148,10 @@ export default class ChannelsSubscribe extends AblyBaseCommand { `Subscribing to channel: ${channel.name}`, { channel: channel.name }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Attaching to channel: ${formatResource(channel.name)}`, - ), - ); - } + this.logProgress( + `Attaching to channel: ${formatResource(channel.name)}`, + flags, + ); // Set up channel state logging this.setupChannelStateLogging(channel, flags, { @@ -233,26 +227,28 @@ export default class ChannelsSubscribe extends AblyBaseCommand { // Wait for all channels to attach via subscribe await Promise.all(subscribePromises); + const firstChannelName = channelNames[0] ?? ""; + // Log the ready signal for E2E tests if (channelNames.length === 1 && !this.shouldOutputJson(flags)) { - this.log(`Successfully attached to channel: ${channelNames[0]}`); + this.log(`Successfully attached to channel: ${firstChannelName}`); } // Show success message once all channels are attached - if (!this.shouldOutputJson(flags)) { - if (channelNames.length === 1) { - this.log( - formatSuccess( - `Subscribed to channel: ${formatResource(channelNames[0]!)}.`, - ), - ); - } else { - this.log( - formatSuccess(`Subscribed to ${channelNames.length} channels.`), - ); - } + if (channelNames.length === 1) { + this.logSuccessMessage( + `Subscribed to channel: ${formatResource(firstChannelName)}.`, + flags, + ); + } else { + this.logSuccessMessage( + `Subscribed to ${channelNames.length} channels.`, + flags, + ); + } - this.log(formatListening("Listening for messages.")); + this.logListening("Listening for messages.", flags); + if (!this.shouldOutputJson(flags)) { this.log(""); } diff --git a/src/commands/channels/update.ts b/src/commands/channels/update.ts index 1f6ff724..f9e87027 100644 --- a/src/commands/channels/update.ts +++ b/src/commands/channels/update.ts @@ -4,12 +4,7 @@ import * as Ably from "ably"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; import { prepareMessageFromInput } from "../../utils/message.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class ChannelsUpdate extends AblyBaseCommand { static override args = { @@ -65,13 +60,10 @@ export default class ChannelsUpdate extends AblyBaseCommand { const channel = rest.channels.get(channelName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Updating message ${formatResource(serial)} on channel ${formatResource(channelName)}`, - ), - ); - } + this.logProgress( + `Updating message ${formatResource(serial)} on channel ${formatResource(channelName)}`, + flags, + ); const message = prepareMessageFromInput(args.message, flags, { serial }); const operation: Ably.MessageOperation | undefined = flags.description @@ -96,19 +88,21 @@ export default class ChannelsUpdate extends AblyBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Message ${formatResource(serial)} updated on channel ${formatResource(channelName)}.`, - ), - ); if (versionSerial) { this.log(` Version serial: ${formatResource(versionSerial)}`); - } else if (versionSerial === null) { - this.log( - formatWarning("Message was superseded by a subsequent operation."), - ); } } + + this.logSuccessMessage( + `Message ${formatResource(serial)} updated on channel ${formatResource(channelName)}.`, + flags, + ); + if (versionSerial === null) { + this.logWarning( + "Message was superseded by a subsequent operation.", + flags, + ); + } } catch (error) { this.fail(error, flags, "channelUpdate", { channel: channelName, diff --git a/src/commands/connections/test.ts b/src/commands/connections/test.ts index 4dfb0c22..dc81e977 100644 --- a/src/commands/connections/test.ts +++ b/src/commands/connections/test.ts @@ -1,16 +1,10 @@ import { Flags } from "@oclif/core"; import * as Ably from "ably"; -import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { extractErrorInfo } from "../../utils/errors.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class ConnectionsTest extends AblyBaseCommand { static override description = "Test connection to Ably"; @@ -167,55 +161,57 @@ export default class ConnectionsTest extends AblyBaseCommand { } else { this.log(""); this.log("Connection Test Summary:"); + } - switch (flags.transport) { - case "all": { - // If both were tested - const allSuccess = wsSuccess && xhrSuccess; - const partialSuccess = wsSuccess || xhrSuccess; - - if (allSuccess) { - this.log( - formatSuccess("All connection tests passed successfully."), - ); - } else if (partialSuccess) { - this.log( - formatWarning( - "Some connection tests succeeded, but others failed", - ), - ); - } else { - this.log(`${chalk.red("✗")} All connection tests failed`); - } + switch (flags.transport) { + case "all": { + // If both were tested + const allSuccess = wsSuccess && xhrSuccess; + const partialSuccess = wsSuccess || xhrSuccess; - break; + if (allSuccess) { + this.logSuccessMessage( + "All connection tests passed successfully.", + flags, + ); + } else if (partialSuccess) { + this.logWarning( + "Some connection tests succeeded, but others failed", + flags, + ); + } else { + this.logWarning("All connection tests failed.", flags); } - case "ws": { - if (wsSuccess) { - this.log( - formatSuccess("WebSocket connection test passed successfully."), - ); - } else { - this.log(`${chalk.red("✗")} WebSocket connection test failed`); - } + break; + } - break; + case "ws": { + if (wsSuccess) { + this.logSuccessMessage( + "WebSocket connection test passed successfully.", + flags, + ); + } else { + this.logWarning("WebSocket connection test failed.", flags); } - case "xhr": { - if (xhrSuccess) { - this.log( - formatSuccess("HTTP connection test passed successfully."), - ); - } else { - this.log(`${chalk.red("✗")} HTTP connection test failed`); - } + break; + } - break; + case "xhr": { + if (xhrSuccess) { + this.logSuccessMessage( + "HTTP connection test passed successfully.", + flags, + ); + } else { + this.logWarning("HTTP connection test failed.", flags); } - // No default + + break; } + // No default } } @@ -267,11 +263,7 @@ export default class ConnectionsTest extends AblyBaseCommand { `${config.prefix}TestStarting`, `Testing ${config.displayName} connection...`, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Testing ${config.displayName} connection to Ably`), - ); - } + this.logProgress(`Testing ${config.displayName} connection to Ably`, flags); let client: Ably.Realtime | null = null; @@ -315,10 +307,11 @@ export default class ConnectionsTest extends AblyBaseCommand { `${config.displayName} connection successful`, { connectionId: client!.connection.id }, ); + this.logSuccessMessage( + `${config.displayName} connection successful.`, + flags, + ); if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`${config.displayName} connection successful.`), - ); this.log( ` Connection ID: ${formatResource(client!.connection.id || "unknown")}`, ); @@ -336,11 +329,10 @@ export default class ConnectionsTest extends AblyBaseCommand { `${config.displayName} connection failed: ${errorResult.message}`, { error: errorResult.message, reason: stateChange.reason }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - `${chalk.red("✗")} ${config.displayName} connection failed: ${errorResult.message}`, - ); - } + this.logWarning( + `${config.displayName} connection failed: ${errorResult.message}`, + flags, + ); resolve(); }); }); @@ -353,11 +345,10 @@ export default class ConnectionsTest extends AblyBaseCommand { `${config.displayName} connection test caught error: ${errorResult.message}`, { error: errorResult.message }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - `${chalk.red("✗")} ${config.displayName} connection failed: ${errorResult.message}`, - ); - } + this.logWarning( + `${config.displayName} connection failed: ${errorResult.message}`, + flags, + ); } return { client, error: errorResult, success }; diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 3eaea61c..09dfb4ad 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -1,11 +1,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; // Interface for basic integration data structure interface IntegrationData { @@ -137,8 +133,9 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { } default: { - this.log( - `Note: Using default target for ${flags["rule-type"]}. In a real implementation, more target options would be required.`, + this.logWarning( + `Using default target for ${flags["rule-type"]}. In a real implementation, more target options would be required.`, + flags, ); integrationData.target = { enveloped: true, format: "json" }; } @@ -161,11 +158,6 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Integration rule created: ${formatResource(createdIntegration.id)}.`, - ), - ); this.log(`${formatLabel("ID")} ${createdIntegration.id}`); this.log(`${formatLabel("App ID")} ${createdIntegration.appId}`); this.log(`${formatLabel("Type")} ${createdIntegration.ruleType}`); @@ -184,6 +176,11 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { `${formatLabel("Target")} ${this.formatJsonOutput(createdIntegration.target as Record, flags)}`, ); } + + this.logSuccessMessage( + `Integration rule created: ${formatResource(createdIntegration.id)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "integrationCreate"); } diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index 83d1754c..0fdbb219 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -1,11 +1,8 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { forceFlag } from "../../flags.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class IntegrationsDeleteCommand extends ControlBaseCommand { @@ -31,12 +28,7 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", required: false, }), - force: Flags.boolean({ - char: "f", - default: false, - description: "Force deletion without confirmation", - required: false, - }), + ...forceFlag, }; async run(): Promise { @@ -52,9 +44,7 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { // In JSON mode, require --force to prevent accidental destructive actions if (!flags.force && this.shouldOutputJson(flags)) { this.fail( - new Error( - "The --force flag is required when using --json to confirm deletion", - ), + "The --force flag is required when using --json to confirm deletion", flags, "integrationDelete", ); @@ -78,7 +68,7 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { ); if (!confirmed) { - this.log("Deletion cancelled"); + this.logWarning("Deletion cancelled.", flags); return; } } @@ -99,16 +89,16 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Integration rule deleted: ${formatResource(integration.id)}.`, - ), - ); this.log(`${formatLabel("ID")} ${integration.id}`); this.log(`${formatLabel("App ID")} ${integration.appId}`); this.log(`${formatLabel("Type")} ${integration.ruleType}`); this.log(`${formatLabel("Source Type")} ${integration.source.type}`); } + + this.logSuccessMessage( + `Integration rule deleted: ${formatResource(integration.id)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "integrationDelete"); } diff --git a/src/commands/integrations/update.ts b/src/commands/integrations/update.ts index d4661075..04f231a7 100644 --- a/src/commands/integrations/update.ts +++ b/src/commands/integrations/update.ts @@ -1,7 +1,5 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { formatSuccess } from "../../utils/output.js"; - // Interface for rule update data structure (most fields optional) interface PartialRuleData { requestMode?: string; @@ -120,7 +118,6 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { flags, ); } else { - this.log(formatSuccess("Integration rule updated.")); this.log(`ID: ${updatedRule.id}`); this.log(`App ID: ${updatedRule.appId}`); this.log(`Rule Type: ${updatedRule.ruleType}`); @@ -142,6 +139,8 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { } this.log(`Target: ${JSON.stringify(updatedRule.target, null, 2)}`); } + + this.logSuccessMessage("Integration rule updated.", flags); } catch (error) { this.fail(error, flags, "integrationUpdate"); } diff --git a/src/commands/logs/channel-lifecycle/subscribe.ts b/src/commands/logs/channel-lifecycle/subscribe.ts index 25599dc3..c40c21ab 100644 --- a/src/commands/logs/channel-lifecycle/subscribe.ts +++ b/src/commands/logs/channel-lifecycle/subscribe.ts @@ -11,9 +11,7 @@ import { import { formatMessageData } from "../../../utils/json-formatter.js"; import { formatLabel, - formatListening, formatResource, - formatSuccess, formatTimestamp, formatMessageTimestamp, } from "../../../utils/output.js"; @@ -130,13 +128,15 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { }); if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`Subscribed to ${formatResource(channelName)}.`), - ); - this.log(formatListening("Listening for channel lifecycle logs.")); this.log(""); } + this.logSuccessMessage( + `Subscribed to ${formatResource(channelName)}.`, + flags, + ); + this.logListening("Listening for channel lifecycle logs.", flags); + this.logCliEvent( flags, "logs", diff --git a/src/commands/logs/connection-lifecycle/history.ts b/src/commands/logs/connection-lifecycle/history.ts index 494141da..cc7ca481 100644 --- a/src/commands/logs/connection-lifecycle/history.ts +++ b/src/commands/logs/connection-lifecycle/history.ts @@ -76,7 +76,7 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { true, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } // Output results based on format diff --git a/src/commands/logs/connection-lifecycle/subscribe.ts b/src/commands/logs/connection-lifecycle/subscribe.ts index 245f99a8..14e853ac 100644 --- a/src/commands/logs/connection-lifecycle/subscribe.ts +++ b/src/commands/logs/connection-lifecycle/subscribe.ts @@ -9,9 +9,7 @@ import { } from "../../../flags.js"; import { formatEventType, - formatListening, formatMessageTimestamp, - formatSuccess, formatTimestamp, formatLabel, } from "../../../utils/output.js"; @@ -114,9 +112,7 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { } }); - if (!this.shouldOutputJson(flags)) { - this.log(formatSuccess("Subscribed to connection lifecycle logs.")); - } + this.logSuccessMessage("Subscribed to connection lifecycle logs.", flags); this.logCliEvent( flags, @@ -124,11 +120,10 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { "listening", "Listening for connection lifecycle log events. Press Ctrl+C to exit.", ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatListening("Listening for connection lifecycle log events."), - ); - } + this.logListening( + "Listening for connection lifecycle log events.", + flags, + ); // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "logs", flags.duration); diff --git a/src/commands/logs/history.ts b/src/commands/logs/history.ts index 2fc575d9..8104bb95 100644 --- a/src/commands/logs/history.ts +++ b/src/commands/logs/history.ts @@ -76,7 +76,7 @@ export default class LogsHistory extends AblyBaseCommand { true, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } // Output results based on format diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index 5fbff477..54975919 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -77,7 +77,7 @@ export default class LogsPushHistory extends AblyBaseCommand { true, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } // Output results based on format diff --git a/src/commands/logs/push/subscribe.ts b/src/commands/logs/push/subscribe.ts index bd077550..6db7b45f 100644 --- a/src/commands/logs/push/subscribe.ts +++ b/src/commands/logs/push/subscribe.ts @@ -10,9 +10,7 @@ import { } from "../../../flags.js"; import { formatMessageData } from "../../../utils/json-formatter.js"; import { - formatListening, formatResource, - formatSuccess, formatTimestamp, formatMessageTimestamp, } from "../../../utils/output.js"; @@ -149,13 +147,15 @@ export default class LogsPushSubscribe extends AblyBaseCommand { }); if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`Subscribed to ${formatResource(channelName)}.`), - ); - this.log(formatListening("Listening for push logs.")); this.log(""); } + this.logSuccessMessage( + `Subscribed to ${formatResource(channelName)}.`, + flags, + ); + this.logListening("Listening for push logs.", flags); + this.logCliEvent( flags, "logs", diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index 44d7c25d..e4008225 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -10,10 +10,8 @@ import { } from "../../flags.js"; import { formatEventType, - formatListening, formatMessageTimestamp, formatResource, - formatSuccess, formatTimestamp, formatLabel, } from "../../utils/output.js"; @@ -152,13 +150,10 @@ export default class LogsSubscribe extends AblyBaseCommand { await Promise.all(subscribePromises); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to app logs: ${formatResource(logTypes.join(", "))}.`, - ), - ); - } + this.logSuccessMessage( + `Subscribed to app logs: ${formatResource(logTypes.join(", "))}.`, + flags, + ); this.logCliEvent( flags, @@ -166,9 +161,7 @@ export default class LogsSubscribe extends AblyBaseCommand { "listening", "Listening for log events. Press Ctrl+C to exit.", ); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Listening for log events.")); - } + this.logListening("Listening for log events.", flags); // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "logs", flags.duration); diff --git a/src/commands/push/batch-publish.ts b/src/commands/push/batch-publish.ts index d8e96de6..e119e5a9 100644 --- a/src/commands/push/batch-publish.ts +++ b/src/commands/push/batch-publish.ts @@ -7,12 +7,7 @@ import { CommandError } from "../../errors/command-error.js"; import { productApiFlags } from "../../flags.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import { BaseFlags } from "../../types/cli.js"; -import { - formatCountLabel, - formatProgress, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatCountLabel, formatResource } from "../../utils/output.js"; interface BatchResponseItem { channel: string; @@ -237,13 +232,10 @@ export default class PushBatchPublish extends AblyBaseCommand { } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Publishing batch of ${formatCountLabel(recipientItems.length, "notification")} to recipients`, - ), - ); - } + this.logProgress( + `Publishing batch of ${formatCountLabel(recipientItems.length, "notification")} to recipients`, + flags, + ); const response = await rest.request( "post", @@ -322,13 +314,10 @@ export default class PushBatchPublish extends AblyBaseCommand { } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Publishing batch of ${formatCountLabel(channelItems.length, "notification")} to channels`, - ), - ); - } + this.logProgress( + `Publishing batch of ${formatCountLabel(channelItems.length, "notification")} to channels`, + flags, + ); const response = await rest.request( "post", @@ -387,22 +376,23 @@ export default class PushBatchPublish extends AblyBaseCommand { ); } else { if (totalFailed > 0) { - this.log( - formatSuccess( - `Batch published: ${totalSucceeded} succeeded, ${totalFailed} failed out of ${formatCountLabel(total, "notification")}.`, - ), - ); for (const detail of failedDetails) { this.logToStderr(detail); } - } else { - this.log( - formatSuccess( - `Batch of ${formatCountLabel(total, "notification")} published.`, - ), - ); } } + + if (totalFailed > 0) { + this.logSuccessMessage( + `Batch published: ${totalSucceeded} succeeded, ${totalFailed} failed out of ${formatCountLabel(total, "notification")}.`, + flags, + ); + } else { + this.logSuccessMessage( + `Batch of ${formatCountLabel(total, "notification")} published.`, + flags, + ); + } } catch (error) { this.fail(error, flags as BaseFlags, "pushBatchPublish"); } diff --git a/src/commands/push/channels/list-channels.ts b/src/commands/push/channels/list-channels.ts index c97ec8eb..fae5f279 100644 --- a/src/commands/push/channels/list-channels.ts +++ b/src/commands/push/channels/list-channels.ts @@ -6,9 +6,7 @@ import { BaseFlags } from "../../../types/cli.js"; import { formatCountLabel, formatLimitWarning, - formatProgress, formatResource, - formatSuccess, } from "../../../utils/output.js"; import { buildPaginationNext, @@ -41,9 +39,7 @@ export default class PushChannelsListChannels extends AblyBaseCommand { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Fetching channels with push subscriptions")); - } + this.logProgress("Fetching channels with push subscriptions", flags); const result = await rest.push.admin.channelSubscriptions.listChannels({ limit: flags.limit, @@ -59,7 +55,7 @@ export default class PushChannelsListChannels extends AblyBaseCommand { channels.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -73,8 +69,9 @@ export default class PushChannelsListChannels extends AblyBaseCommand { return; } - this.log( - formatSuccess(`Found ${formatCountLabel(channels.length, "channel")}.`), + this.logSuccessMessage( + `Found ${formatCountLabel(channels.length, "channel")}.`, + flags, ); this.log(""); diff --git a/src/commands/push/channels/list.ts b/src/commands/push/channels/list.ts index 0174fd6f..08d65e26 100644 --- a/src/commands/push/channels/list.ts +++ b/src/commands/push/channels/list.ts @@ -9,10 +9,7 @@ import { formatHeading, formatLabel, formatLimitWarning, - formatProgress, formatResource, - formatSuccess, - formatWarning, } from "../../../utils/output.js"; import { buildPaginationNext, @@ -55,13 +52,10 @@ export default class PushChannelsList extends AblyBaseCommand { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching subscriptions for channel ${formatResource(flags.channel)}`, - ), - ); - } + this.logProgress( + `Fetching subscriptions for channel ${formatResource(flags.channel)}`, + flags, + ); const params: Record = { channel: flags.channel, @@ -82,7 +76,7 @@ export default class PushChannelsList extends AblyBaseCommand { subscriptions.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -95,14 +89,13 @@ export default class PushChannelsList extends AblyBaseCommand { } if (subscriptions.length === 0) { - this.logToStderr(formatWarning("No subscriptions found.")); + this.logWarning("No subscriptions found.", flags); return; } - this.log( - formatSuccess( - `Found ${formatCountLabel(subscriptions.length, "subscription")}.`, - ), + this.logSuccessMessage( + `Found ${formatCountLabel(subscriptions.length, "subscription")}.`, + flags, ); this.log(""); diff --git a/src/commands/push/channels/remove-where.ts b/src/commands/push/channels/remove-where.ts index 23f0eece..78e87efb 100644 --- a/src/commands/push/channels/remove-where.ts +++ b/src/commands/push/channels/remove-where.ts @@ -3,11 +3,7 @@ import { Flags } from "@oclif/core"; import { AblyBaseCommand } from "../../../base-command.js"; import { forceFlag, productApiFlags } from "../../../flags.js"; import { BaseFlags } from "../../../types/cli.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class PushChannelsRemoveWhere extends AblyBaseCommand { @@ -48,6 +44,15 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand { if (flags["device-id"]) params.deviceId = flags["device-id"]; if (flags["client-id"]) params.clientId = flags["client-id"]; + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm removal", + flags, + "pushChannelRemoveWhere", + ); + } + if (!flags.force && !this.shouldOutputJson(flags)) { const filterDesc = Object.entries(params) .map(([k, v]) => `${k}=${v}`) @@ -58,18 +63,15 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand { ); if (!confirmed) { - this.log("Operation cancelled."); + this.logWarning("Operation cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Removing matching subscriptions from channel ${formatResource(flags.channel)}`, - ), - ); - } + this.logProgress( + `Removing matching subscriptions from channel ${formatResource(flags.channel)}`, + flags, + ); await rest.push.admin.channelSubscriptions.removeWhere(params); @@ -79,7 +81,7 @@ export default class PushChannelsRemoveWhere extends AblyBaseCommand { flags, ); } else { - this.log(formatSuccess("Matching subscriptions removed.")); + this.logSuccessMessage("Matching subscriptions removed.", flags); } } catch (error) { this.fail(error, flags as BaseFlags, "pushChannelRemoveWhere"); diff --git a/src/commands/push/channels/remove.ts b/src/commands/push/channels/remove.ts index c280efa0..0cb50d4a 100644 --- a/src/commands/push/channels/remove.ts +++ b/src/commands/push/channels/remove.ts @@ -3,11 +3,7 @@ import { Flags } from "@oclif/core"; import { AblyBaseCommand } from "../../../base-command.js"; import { forceFlag, productApiFlags } from "../../../flags.js"; import { BaseFlags } from "../../../types/cli.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class PushChannelsRemove extends AblyBaseCommand { @@ -55,24 +51,30 @@ export default class PushChannelsRemove extends AblyBaseCommand { ? `device ${flags["device-id"]}` : `client ${flags["client-id"]}`; + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm removal", + flags, + "pushChannelRemove", + ); + } + if (!flags.force && !this.shouldOutputJson(flags)) { const confirmed = await promptForConfirmation( `Are you sure you want to unsubscribe ${target} from channel ${flags.channel}?`, ); if (!confirmed) { - this.log("Operation cancelled."); + this.logWarning("Operation cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Removing subscription from channel ${formatResource(flags.channel)}`, - ), - ); - } + this.logProgress( + `Removing subscription from channel ${formatResource(flags.channel)}`, + flags, + ); const subscription: Record = { channel: flags.channel, @@ -94,10 +96,9 @@ export default class PushChannelsRemove extends AblyBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Subscription removed from channel ${formatResource(flags.channel)}.`, - ), + this.logSuccessMessage( + `Subscription removed from channel ${formatResource(flags.channel)}.`, + flags, ); } } catch (error) { diff --git a/src/commands/push/channels/save.ts b/src/commands/push/channels/save.ts index a8f300b9..fb17f92e 100644 --- a/src/commands/push/channels/save.ts +++ b/src/commands/push/channels/save.ts @@ -3,11 +3,7 @@ import { Flags } from "@oclif/core"; import { AblyBaseCommand } from "../../../base-command.js"; import { productApiFlags } from "../../../flags.js"; import { BaseFlags } from "../../../types/cli.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class PushChannelsSave extends AblyBaseCommand { static override description = @@ -50,13 +46,10 @@ export default class PushChannelsSave extends AblyBaseCommand { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Subscribing to channel ${formatResource(flags.channel)}`, - ), - ); - } + this.logProgress( + `Subscribing to channel ${formatResource(flags.channel)}`, + flags, + ); const subscription: Record = { channel: flags.channel, @@ -66,16 +59,16 @@ export default class PushChannelsSave extends AblyBaseCommand { await rest.push.admin.channelSubscriptions.save(subscription as never); + const target = flags["device-id"] + ? `device ${formatResource(flags["device-id"])}` + : `client ${formatResource(flags["client-id"]!)}`; + if (this.shouldOutputJson(flags)) { this.logJsonResult({ subscription }, flags); } else { - const target = flags["device-id"] - ? `device ${formatResource(flags["device-id"])}` - : `client ${formatResource(flags["client-id"]!)}`; - this.log( - formatSuccess( - `Subscribed ${target} to channel ${formatResource(flags.channel)}.`, - ), + this.logSuccessMessage( + `Subscribed ${target} to channel ${formatResource(flags.channel)}.`, + flags, ); } } catch (error) { diff --git a/src/commands/push/config/clear-apns.ts b/src/commands/push/config/clear-apns.ts index 7f4041a5..86075e87 100644 --- a/src/commands/push/config/clear-apns.ts +++ b/src/commands/push/config/clear-apns.ts @@ -2,12 +2,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { forceFlag } from "../../../flags.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class PushConfigClearApns extends ControlBaseCommand { @@ -36,24 +31,30 @@ export default class PushConfigClearApns extends ControlBaseCommand { async (controlApi) => { const appId = await this.requireAppId(flags); + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm clearing APNs configuration", + flags, + "pushConfigClearApns", + ); + } + if (!flags.force && !this.shouldOutputJson(flags)) { const confirmed = await promptForConfirmation( `Are you sure you want to clear APNs configuration for app ${formatResource(appId)}?`, ); if (!confirmed) { - this.log("Operation cancelled."); + this.logWarning("Operation cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Checking APNs configuration for app ${formatResource(appId)}`, - ), - ); - } + this.logProgress( + `Checking APNs configuration for app ${formatResource(appId)}`, + flags, + ); const app = await controlApi.getApp(appId); const apnsConfigured = !!( @@ -66,24 +67,20 @@ export default class PushConfigClearApns extends ControlBaseCommand { { config: { appId, cleared: "apns", wasConfigured: false } }, flags, ); - } else { - this.log( - formatWarning( - `APNs is not configured for app ${formatResource(appId)}. Nothing to clear.`, - ), - ); } - return; - } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Clearing APNs configuration for app ${formatResource(appId)}`, - ), + this.logWarning( + `APNs is not configured for app ${formatResource(appId)}. Nothing to clear.`, + flags, ); + return; } + this.logProgress( + `Clearing APNs configuration for app ${formatResource(appId)}`, + flags, + ); + await controlApi.updateApp(appId, { apnsAuthType: null, apnsCertificate: null, @@ -98,10 +95,9 @@ export default class PushConfigClearApns extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ config: { appId, cleared: "apns" } }, flags); } else { - this.log( - formatSuccess( - `APNs configuration cleared for app ${formatResource(appId)}.`, - ), + this.logSuccessMessage( + `APNs configuration cleared for app ${formatResource(appId)}.`, + flags, ); } }, diff --git a/src/commands/push/config/clear-fcm.ts b/src/commands/push/config/clear-fcm.ts index 5e0fb130..548446f6 100644 --- a/src/commands/push/config/clear-fcm.ts +++ b/src/commands/push/config/clear-fcm.ts @@ -2,12 +2,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { forceFlag } from "../../../flags.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class PushConfigClearFcm extends ControlBaseCommand { @@ -36,24 +31,30 @@ export default class PushConfigClearFcm extends ControlBaseCommand { async (controlApi) => { const appId = await this.requireAppId(flags); + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm clearing FCM configuration", + flags, + "pushConfigClearFcm", + ); + } + if (!flags.force && !this.shouldOutputJson(flags)) { const confirmed = await promptForConfirmation( `Are you sure you want to clear FCM configuration for app ${formatResource(appId)}?`, ); if (!confirmed) { - this.log("Operation cancelled."); + this.logWarning("Operation cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Checking FCM configuration for app ${formatResource(appId)}`, - ), - ); - } + this.logProgress( + `Checking FCM configuration for app ${formatResource(appId)}`, + flags, + ); const app = await controlApi.getApp(appId); const fcmConfigured = !!app.fcmServiceAccountConfigured; @@ -64,24 +65,20 @@ export default class PushConfigClearFcm extends ControlBaseCommand { { config: { appId, cleared: "fcm", wasConfigured: false } }, flags, ); - } else { - this.log( - formatWarning( - `FCM is not configured for app ${formatResource(appId)}. Nothing to clear.`, - ), - ); } - return; - } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Clearing FCM configuration for app ${formatResource(appId)}`, - ), + this.logWarning( + `FCM is not configured for app ${formatResource(appId)}. Nothing to clear.`, + flags, ); + return; } + this.logProgress( + `Clearing FCM configuration for app ${formatResource(appId)}`, + flags, + ); + await controlApi.updateApp(appId, { fcmProjectId: null, fcmServiceAccount: null, @@ -90,10 +87,9 @@ export default class PushConfigClearFcm extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ config: { appId, cleared: "fcm" } }, flags); } else { - this.log( - formatSuccess( - `FCM configuration cleared for app ${formatResource(appId)}.`, - ), + this.logSuccessMessage( + `FCM configuration cleared for app ${formatResource(appId)}.`, + flags, ); } }, diff --git a/src/commands/push/config/set-apns.ts b/src/commands/push/config/set-apns.ts index 6d3cbf7d..8515390b 100644 --- a/src/commands/push/config/set-apns.ts +++ b/src/commands/push/config/set-apns.ts @@ -3,11 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { ControlBaseCommand } from "../../../control-base-command.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class PushConfigSetApns extends ControlBaseCommand { static override description = "Configure APNs push notifications for an app"; @@ -76,13 +72,10 @@ export default class PushConfigSetApns extends ControlBaseCommand { ); } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Uploading APNs P12 certificate for app ${formatResource(appId)}`, - ), - ); - } + this.logProgress( + `Uploading APNs P12 certificate for app ${formatResource(appId)}`, + flags, + ); const certificateData = fs.readFileSync(certPath); @@ -101,10 +94,9 @@ export default class PushConfigSetApns extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `APNs P12 certificate uploaded for app ${formatResource(appId)}.`, - ), + this.logSuccessMessage( + `APNs P12 certificate uploaded for app ${formatResource(appId)}.`, + flags, ); } } else if (flags["key-file"]) { @@ -139,13 +131,10 @@ export default class PushConfigSetApns extends ControlBaseCommand { ); } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Configuring APNs P8 key for app ${formatResource(appId)}`, - ), - ); - } + this.logProgress( + `Configuring APNs P8 key for app ${formatResource(appId)}`, + flags, + ); const keyContents = fs.readFileSync(keyPath, "utf8"); @@ -161,10 +150,9 @@ export default class PushConfigSetApns extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ config: { appId, method: "p8" } }, flags); } else { - this.log( - formatSuccess( - `APNs P8 key configured for app ${formatResource(appId)}.`, - ), + this.logSuccessMessage( + `APNs P8 key configured for app ${formatResource(appId)}.`, + flags, ); } } diff --git a/src/commands/push/config/set-fcm.ts b/src/commands/push/config/set-fcm.ts index 7022298c..f437e4d8 100644 --- a/src/commands/push/config/set-fcm.ts +++ b/src/commands/push/config/set-fcm.ts @@ -3,11 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { ControlBaseCommand } from "../../../control-base-command.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class PushConfigSetFcm extends ControlBaseCommand { static override description = "Configure FCM push notifications for an app"; @@ -75,11 +71,10 @@ export default class PushConfigSetFcm extends ControlBaseCommand { ); } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Configuring FCM for app ${formatResource(appId)}`), - ); - } + this.logProgress( + `Configuring FCM for app ${formatResource(appId)}`, + flags, + ); await controlApi.updateApp(appId, { fcmProjectId: parsed.project_id as string, @@ -89,10 +84,9 @@ export default class PushConfigSetFcm extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ config: { appId, provider: "fcm" } }, flags); } else { - this.log( - formatSuccess( - `FCM configuration updated for app ${formatResource(appId)}.`, - ), + this.logSuccessMessage( + `FCM configuration updated for app ${formatResource(appId)}.`, + flags, ); } }, diff --git a/src/commands/push/config/show.ts b/src/commands/push/config/show.ts index b3d8b2a2..3e23a211 100644 --- a/src/commands/push/config/show.ts +++ b/src/commands/push/config/show.ts @@ -1,11 +1,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; -import { - formatLabel, - formatProgress, - formatResource, -} from "../../../utils/output.js"; +import { formatLabel, formatResource } from "../../../utils/output.js"; export default class PushConfigShow extends ControlBaseCommand { static override description = @@ -32,13 +28,10 @@ export default class PushConfigShow extends ControlBaseCommand { async (controlApi) => { const appId = await this.requireAppId(flags); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching push configuration for app ${formatResource(appId)}`, - ), - ); - } + this.logProgress( + `Fetching push configuration for app ${formatResource(appId)}`, + flags, + ); const app = await controlApi.getApp(appId); diff --git a/src/commands/push/devices/get.ts b/src/commands/push/devices/get.ts index 7395a15c..dc76b3d6 100644 --- a/src/commands/push/devices/get.ts +++ b/src/commands/push/devices/get.ts @@ -6,9 +6,7 @@ import { BaseFlags } from "../../../types/cli.js"; import { formatDeviceState, formatLabel, - formatProgress, formatResource, - formatSuccess, } from "../../../utils/output.js"; export default class PushDevicesGet extends AblyBaseCommand { @@ -38,9 +36,7 @@ export default class PushDevicesGet extends AblyBaseCommand { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress(`Fetching device ${formatResource(deviceId)}`)); - } + this.logProgress(`Fetching device ${formatResource(deviceId)}`, flags); const device = await rest.push.admin.deviceRegistrations.get(deviceId); @@ -49,7 +45,10 @@ export default class PushDevicesGet extends AblyBaseCommand { return; } - this.log(formatSuccess(`Device ${formatResource(deviceId)} found.`)); + this.logSuccessMessage( + `Device ${formatResource(deviceId)} found.`, + flags, + ); this.log(""); this.log(`${formatLabel("Device ID")} ${device.id}`); this.log(`${formatLabel("Platform")} ${device.platform}`); diff --git a/src/commands/push/devices/list.ts b/src/commands/push/devices/list.ts index c909664b..06cd9e6b 100644 --- a/src/commands/push/devices/list.ts +++ b/src/commands/push/devices/list.ts @@ -7,8 +7,6 @@ import { formatDeviceState, formatHeading, formatLabel, - formatProgress, - formatSuccess, formatCountLabel, formatLimitWarning, } from "../../../utils/output.js"; @@ -54,9 +52,7 @@ export default class PushDevicesList extends AblyBaseCommand { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Fetching device registrations")); - } + this.logProgress("Fetching device registrations", flags); const params: Record = { limit: flags.limit, @@ -77,7 +73,7 @@ export default class PushDevicesList extends AblyBaseCommand { devices.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -91,10 +87,9 @@ export default class PushDevicesList extends AblyBaseCommand { return; } - this.log( - formatSuccess( - `Found ${formatCountLabel(devices.length, "device registration")}.`, - ), + this.logSuccessMessage( + `Found ${formatCountLabel(devices.length, "device registration")}.`, + flags, ); this.log(""); diff --git a/src/commands/push/devices/remove-where.ts b/src/commands/push/devices/remove-where.ts index 81dfa4ec..12880e95 100644 --- a/src/commands/push/devices/remove-where.ts +++ b/src/commands/push/devices/remove-where.ts @@ -3,7 +3,6 @@ import { Flags } from "@oclif/core"; import { AblyBaseCommand } from "../../../base-command.js"; import { forceFlag, productApiFlags } from "../../../flags.js"; import { BaseFlags } from "../../../types/cli.js"; -import { formatProgress, formatSuccess } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class PushDevicesRemoveWhere extends AblyBaseCommand { @@ -46,6 +45,15 @@ export default class PushDevicesRemoveWhere extends AblyBaseCommand { if (flags["device-id"]) params.deviceId = flags["device-id"]; if (flags["client-id"]) params.clientId = flags["client-id"]; + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm removal", + flags, + "pushDeviceRemoveWhere", + ); + } + if (!flags.force && !this.shouldOutputJson(flags)) { const filterDesc = Object.entries(params) .map(([k, v]) => `${k}=${v}`) @@ -56,14 +64,12 @@ export default class PushDevicesRemoveWhere extends AblyBaseCommand { ); if (!confirmed) { - this.log("Operation cancelled."); + this.logWarning("Operation cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Removing matching device registrations")); - } + this.logProgress("Removing matching device registrations", flags); await rest.push.admin.deviceRegistrations.removeWhere(params); @@ -73,7 +79,7 @@ export default class PushDevicesRemoveWhere extends AblyBaseCommand { flags, ); } else { - this.log(formatSuccess("Matching device registrations removed.")); + this.logSuccessMessage("Matching device registrations removed.", flags); } } catch (error) { this.fail(error, flags as BaseFlags, "pushDeviceRemoveWhere"); diff --git a/src/commands/push/devices/remove.ts b/src/commands/push/devices/remove.ts index 1238504a..227328e6 100644 --- a/src/commands/push/devices/remove.ts +++ b/src/commands/push/devices/remove.ts @@ -3,11 +3,7 @@ import { Args } from "@oclif/core"; import { AblyBaseCommand } from "../../../base-command.js"; import { forceFlag, productApiFlags } from "../../../flags.js"; import { BaseFlags } from "../../../types/cli.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; export default class PushDevicesRemove extends AblyBaseCommand { @@ -39,27 +35,37 @@ export default class PushDevicesRemove extends AblyBaseCommand { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm removal", + flags, + "pushDeviceRemove", + ); + } + if (!flags.force && !this.shouldOutputJson(flags)) { const confirmed = await promptForConfirmation( `Are you sure you want to remove device ${deviceId}?`, ); if (!confirmed) { - this.log("Operation cancelled."); + this.logWarning("Operation cancelled.", flags); return; } } - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress(`Removing device ${formatResource(deviceId)}`)); - } + this.logProgress(`Removing device ${formatResource(deviceId)}`, flags); await rest.push.admin.deviceRegistrations.remove(deviceId); if (this.shouldOutputJson(flags)) { this.logJsonResult({ device: { id: deviceId, removed: true } }, flags); } else { - this.log(formatSuccess(`Device ${formatResource(deviceId)} removed.`)); + this.logSuccessMessage( + `Device ${formatResource(deviceId)} removed.`, + flags, + ); } } catch (error) { this.fail(error, flags as BaseFlags, "pushDeviceRemove"); diff --git a/src/commands/push/devices/save.ts b/src/commands/push/devices/save.ts index b446614c..c735da94 100644 --- a/src/commands/push/devices/save.ts +++ b/src/commands/push/devices/save.ts @@ -5,11 +5,7 @@ import * as path from "node:path"; import { AblyBaseCommand } from "../../../base-command.js"; import { productApiFlags } from "../../../flags.js"; import { BaseFlags } from "../../../types/cli.js"; -import { - formatProgress, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class PushDevicesSave extends AblyBaseCommand { static override description = "Register or update a push device"; @@ -181,13 +177,10 @@ export default class PushDevicesSave extends AblyBaseCommand { } } - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Saving device registration ${formatResource(typeof deviceData.id === "string" ? deviceData.id : "")}`, - ), - ); - } + this.logProgress( + `Saving device registration ${formatResource(typeof deviceData.id === "string" ? deviceData.id : "")}`, + flags, + ); const result = await rest.push.admin.deviceRegistrations.save( deviceData as never, @@ -196,10 +189,9 @@ export default class PushDevicesSave extends AblyBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ device: result }, flags); } else { - this.log( - formatSuccess( - `Device registration saved for ${formatResource(typeof deviceData.id === "string" ? deviceData.id : "")}.`, - ), + this.logSuccessMessage( + `Device registration saved for ${formatResource(typeof deviceData.id === "string" ? deviceData.id : "")}.`, + flags, ); } } catch (error) { diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index 7683ff82..c7e26aca 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -5,12 +5,7 @@ import * as path from "node:path"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class PushPublish extends AblyBaseCommand { @@ -109,13 +104,10 @@ export default class PushPublish extends AblyBaseCommand { } if (hasDirectRecipient && flags.channel) { - const channelIgnoredWarning = - "--channel is ignored when --device-id, --client-id, or --recipient is provided."; - if (this.shouldOutputJson(flags)) { - this.logJsonStatus("warning", channelIgnoredWarning, flags); - } else { - this.log(formatWarning(channelIgnoredWarning)); - } + this.logWarning( + "--channel is ignored when --device-id, --client-id, or --recipient is provided.", + flags as BaseFlags, + ); } try { @@ -235,9 +227,7 @@ export default class PushPublish extends AblyBaseCommand { ); } - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Publishing push notification")); - } + this.logProgress("Publishing push notification", flags); if (recipient) { await rest.push.admin.publish(recipient, payload); @@ -247,9 +237,9 @@ export default class PushPublish extends AblyBaseCommand { { notification: { published: true, recipient } }, flags, ); - } else { - this.log(formatSuccess("Push notification published.")); } + + this.logSuccessMessage("Push notification published.", flags); } else { const channelName = flags.channel!; @@ -272,13 +262,12 @@ export default class PushPublish extends AblyBaseCommand { { notification: { published: true, channel: channelName } }, flags, ); - } else { - this.log( - formatSuccess( - `Push notification published to channel: ${formatResource(channelName)}.`, - ), - ); } + + this.logSuccessMessage( + `Push notification published to channel: ${formatResource(channelName)}.`, + flags, + ); } } catch (error) { this.fail(error, flags as BaseFlags, "pushPublish"); diff --git a/src/commands/queues/create.ts b/src/commands/queues/create.ts index 6ce65a2d..a595e167 100644 --- a/src/commands/queues/create.ts +++ b/src/commands/queues/create.ts @@ -1,11 +1,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; export default class QueuesCreateCommand extends ControlBaseCommand { static description = "Create a queue"; @@ -82,9 +78,6 @@ export default class QueuesCreateCommand extends ControlBaseCommand { flags, ); } else { - this.log( - formatSuccess(`Queue created: ${formatResource(createdQueue.name)}.`), - ); this.log(`${formatLabel("Queue ID")} ${createdQueue.id}`); this.log(`${formatLabel("Name")} ${createdQueue.name}`); this.log(`${formatLabel("Region")} ${createdQueue.region}`); @@ -105,6 +98,11 @@ export default class QueuesCreateCommand extends ControlBaseCommand { `${formatLabel("Destination")} ${createdQueue.stomp.destination}`, ); } + + this.logSuccessMessage( + `Queue created: ${formatResource(createdQueue.name)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "queueCreate"); } diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index c2c51ef5..7f5e57df 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -1,11 +1,8 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { - formatLabel, - formatResource, - formatSuccess, -} from "../../utils/output.js"; +import { forceFlag } from "../../flags.js"; +import { formatLabel, formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class QueuesDeleteCommand extends ControlBaseCommand { @@ -31,12 +28,7 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { description: "The app ID or name (defaults to current app)", required: false, }), - force: Flags.boolean({ - char: "f", - default: false, - description: "Force deletion without confirmation", - required: false, - }), + ...forceFlag, }; async run(): Promise { @@ -86,7 +78,7 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { ); if (!confirmed) { - this.log("Deletion cancelled"); + this.logWarning("Deletion cancelled.", flags); return; } } @@ -104,13 +96,12 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `Queue deleted: ${formatResource(queue.name)} (${queue.id}).`, - ), - ); } + + this.logSuccessMessage( + `Queue deleted: ${formatResource(queue.name)} (${queue.id}).`, + flags, + ); } catch (error) { this.fail(error, flags, "queueDelete"); } diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index b855920b..b2e36333 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -125,7 +125,7 @@ export default class RoomsList extends ChatBaseCommand { rooms.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } // Output rooms based on format diff --git a/src/commands/rooms/messages/delete.ts b/src/commands/rooms/messages/delete.ts index ce100868..f4d37ae6 100644 --- a/src/commands/rooms/messages/delete.ts +++ b/src/commands/rooms/messages/delete.ts @@ -3,11 +3,7 @@ import type { OperationDetails } from "@ably/chat"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { - formatProgress, - formatSuccess, - formatResource, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class MessagesDelete extends ChatBaseCommand { static override args = { @@ -53,16 +49,13 @@ export default class MessagesDelete extends ChatBaseCommand { const room = await chatClient.rooms.get(args.room); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - "Deleting message " + - formatResource(args.serial) + - " in room " + - formatResource(args.room), - ), - ); - } + this.logProgress( + "Deleting message " + + formatResource(args.serial) + + " in room " + + formatResource(args.room), + flags, + ); // Build operation details const details: OperationDetails | undefined = flags.description @@ -99,13 +92,13 @@ export default class MessagesDelete extends ChatBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Message ${formatResource(args.serial)} deleted from room ${formatResource(args.room)}.`, - ), - ); this.log(` Version serial: ${formatResource(result.version.serial)}`); } + + this.logSuccessMessage( + `Message ${formatResource(args.serial)} deleted from room ${formatResource(args.room)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "roomMessageDelete", { room: args.room, diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index 9589e4d2..8c5dd575 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -8,8 +8,6 @@ import { formatIndex, formatLabel, formatLimitWarning, - formatProgress, - formatSuccess, formatResource, formatTimestamp, formatMessageTimestamp, @@ -94,13 +92,12 @@ export default class MessagesHistory extends ChatBaseCommand { }, flags, ); - } else { - this.log( - formatProgress( - `Fetching ${flags.limit} most recent messages from room ${formatResource(args.room)}`, - ), - ); } + + this.logProgress( + `Fetching ${flags.limit} most recent messages from room ${formatResource(args.room)}`, + flags, + ); } // Build history query parameters @@ -152,7 +149,7 @@ export default class MessagesHistory extends ChatBaseCommand { true, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -176,9 +173,7 @@ export default class MessagesHistory extends ChatBaseCommand { flags, ); } else { - // Display messages count - this.log(formatSuccess(`Retrieved ${items.length} messages.`)); - + this.logSuccessMessage(`Retrieved ${items.length} messages.`, flags); if (items.length === 0) { this.log(chalk.dim("No messages found in this room.")); } else { diff --git a/src/commands/rooms/messages/reactions/remove.ts b/src/commands/rooms/messages/reactions/remove.ts index a84505fc..e494e3b5 100644 --- a/src/commands/rooms/messages/reactions/remove.ts +++ b/src/commands/rooms/messages/reactions/remove.ts @@ -3,7 +3,7 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; import { clientIdFlag, productApiFlags } from "../../../../flags.js"; -import { formatResource, formatSuccess } from "../../../../utils/output.js"; +import { formatResource } from "../../../../utils/output.js"; import { REACTION_TYPE_MAP } from "../../../../utils/chat-constants.js"; export default class MessagesReactionsRemove extends ChatBaseCommand { @@ -106,13 +106,12 @@ export default class MessagesReactionsRemove extends ChatBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `Removed reaction ${chalk.yellow(reaction)} from message ${formatResource(messageSerial)} in room ${formatResource(room)}.`, - ), - ); } + + this.logSuccessMessage( + `Removed reaction ${chalk.yellow(reaction)} from message ${formatResource(messageSerial)} in room ${formatResource(room)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "roomMessageReactionRemove", { room, diff --git a/src/commands/rooms/messages/reactions/send.ts b/src/commands/rooms/messages/reactions/send.ts index c24e5ecb..e55c9b24 100644 --- a/src/commands/rooms/messages/reactions/send.ts +++ b/src/commands/rooms/messages/reactions/send.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; import { clientIdFlag, productApiFlags } from "../../../../flags.js"; -import { formatResource, formatSuccess } from "../../../../utils/output.js"; +import { formatResource } from "../../../../utils/output.js"; import { REACTION_TYPE_MAP } from "../../../../utils/chat-constants.js"; export default class MessagesReactionsSend extends ChatBaseCommand { @@ -140,13 +140,12 @@ export default class MessagesReactionsSend extends ChatBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `Sent reaction ${chalk.yellow(reaction)} to message ${formatResource(messageSerial)} in room ${formatResource(room)}.`, - ), - ); } + + this.logSuccessMessage( + `Sent reaction ${chalk.yellow(reaction)} to message ${formatResource(messageSerial)} in room ${formatResource(room)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "roomMessageReactionSend", { room, diff --git a/src/commands/rooms/messages/reactions/subscribe.ts b/src/commands/rooms/messages/reactions/subscribe.ts index 2d0a696d..9751e733 100644 --- a/src/commands/rooms/messages/reactions/subscribe.ts +++ b/src/commands/rooms/messages/reactions/subscribe.ts @@ -17,10 +17,7 @@ import { formatClientId, formatEventType, formatLabel, - formatListening, - formatProgress, formatResource, - formatSuccess, formatTimestamp, } from "../../../../utils/output.js"; @@ -83,13 +80,10 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { "connecting", `Connecting to Ably and subscribing to message reactions in room ${room}...`, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Connecting to Ably and subscribing to message reactions in room ${formatResource(room)}`, - ), - ); - } + this.logProgress( + `Connecting to Ably and subscribing to message reactions in room ${formatResource(room)}`, + flags, + ); // Get the room this.logCliEvent( @@ -233,14 +227,11 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { ); } - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to message reactions in room: ${formatResource(room)}.`, - ), - ); - this.log(formatListening("Listening for message reactions.")); - } + this.logSuccessMessage( + `Subscribed to message reactions in room: ${formatResource(room)}.`, + flags, + ); + this.logListening("Listening for message reactions.", flags); this.logCliEvent( flags, diff --git a/src/commands/rooms/messages/send.ts b/src/commands/rooms/messages/send.ts index b3b24335..2eaf146f 100644 --- a/src/commands/rooms/messages/send.ts +++ b/src/commands/rooms/messages/send.ts @@ -5,11 +5,7 @@ import { errorMessage, extractErrorInfo } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { interpolateMessage } from "../../../utils/message.js"; -import { - formatProgress, - formatSuccess, - formatResource, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; // Define interfaces for the message send command interface MessageToSend { @@ -152,9 +148,10 @@ export default class MessagesSend extends ChatBaseCommand { `Sending ${count} messages with ${delay}ms delay...`, { count, delay }, ); - if (count > 1 && !this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Sending ${count} messages with ${delay}ms delay`), + if (count > 1) { + this.logProgress( + `Sending ${count} messages with ${delay}ms delay`, + flags, ); } @@ -181,8 +178,9 @@ export default class MessagesSend extends ChatBaseCommand { ); }, 2000) : setInterval(() => { - this.log( - `Progress: ${sentCount}/${count} messages sent (${errorCount} errors)`, + this.logProgress( + `${sentCount}/${count} messages sent (${errorCount} errors)`, + flags, ); }, 1000); @@ -220,13 +218,6 @@ export default class MessagesSend extends ChatBaseCommand { `Message ${i + 1} sent`, { index: i + 1 }, ); - - if ( - !this.shouldSuppressOutput(flags) && - !this.shouldOutputJson(flags) - ) { - // Logged implicitly by progress interval - } }) .catch((error: unknown) => { errorCount++; @@ -245,12 +236,6 @@ export default class MessagesSend extends ChatBaseCommand { `Error sending message ${i + 1}: ${errorMsg}`, { error: errorMsg, index: i + 1 }, ); - if ( - !this.shouldSuppressOutput(flags) && - !this.shouldOutputJson(flags) - ) { - // Logged implicitly by progress interval - } }); // Delay before sending next message if not the last one @@ -306,12 +291,12 @@ export default class MessagesSend extends ChatBaseCommand { "\r" + " ".repeat(process.stdout.columns) + "\r", ); } - this.log( - formatSuccess( - `${sentCount}/${count} messages sent to room ${formatResource(args.room)} (${errorCount} errors).`, - ), - ); } + + this.logSuccessMessage( + `${sentCount}/${count} messages sent to room ${formatResource(args.room)} (${errorCount} errors).`, + flags, + ); } } else { // Single message @@ -358,12 +343,14 @@ export default class MessagesSend extends ChatBaseCommand { }, flags, ); - } else { - this.log( - formatSuccess( - `Message sent to room ${formatResource(args.room)}.`, - ), - ); + } + + this.logSuccessMessage( + `Message sent to room ${formatResource(args.room)}.`, + flags, + ); + + if (!this.shouldOutputJson(flags)) { this.log(` Serial: ${formatResource(sentMessage.serial)}`); } } diff --git a/src/commands/rooms/messages/subscribe.ts b/src/commands/rooms/messages/subscribe.ts index 75bd78d4..9fb21a25 100644 --- a/src/commands/rooms/messages/subscribe.ts +++ b/src/commands/rooms/messages/subscribe.ts @@ -6,7 +6,6 @@ import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { formatLabel, - formatProgress, formatResource, formatTimestamp, formatMessageTimestamp, @@ -236,13 +235,10 @@ export default class MessagesSubscribe extends ChatBaseCommand { ? this.roomNames.map((r) => formatResource(r)).join(", ") : formatResource(this.roomNames[0]!); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Attaching to room${this.roomNames.length > 1 ? "s" : ""}: ${roomList}`, - ), - ); - } + this.logProgress( + `Attaching to room${this.roomNames.length > 1 ? "s" : ""}: ${roomList}`, + flags, + ); if (!this.chatClient) { throw new Error("Failed to create Chat or Ably client"); diff --git a/src/commands/rooms/messages/update.ts b/src/commands/rooms/messages/update.ts index e31b67a1..20c4d53d 100644 --- a/src/commands/rooms/messages/update.ts +++ b/src/commands/rooms/messages/update.ts @@ -8,11 +8,7 @@ import type { import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { - formatProgress, - formatSuccess, - formatResource, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class MessagesUpdate extends ChatBaseCommand { static override args = { @@ -132,16 +128,13 @@ export default class MessagesUpdate extends ChatBaseCommand { const room = await chatClient.rooms.get(args.room); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - "Updating message " + - formatResource(args.serial) + - " in room " + - formatResource(args.room), - ), - ); - } + this.logProgress( + "Updating message " + + formatResource(args.serial) + + " in room " + + formatResource(args.room), + flags, + ); // Build update params const updateParams: UpdateMessageParams = { @@ -190,13 +183,13 @@ export default class MessagesUpdate extends ChatBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`, - ), - ); this.log(` Version serial: ${formatResource(result.version.serial)}`); } + + this.logSuccessMessage( + `Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "roomMessageUpdate", { room: args.room, diff --git a/src/commands/rooms/occupancy/subscribe.ts b/src/commands/rooms/occupancy/subscribe.ts index 8f16d797..f7e42ca2 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -5,10 +5,7 @@ import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { formatLabel, - formatListening, - formatProgress, formatResource, - formatSuccess, formatTimestamp, } from "../../../utils/output.js"; @@ -54,9 +51,7 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { "connecting", "Connecting to Ably...", ); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Connecting to Ably")); - } + this.logProgress("Connecting to Ably", flags); // Create Chat client this.chatClient = await this.createChatClient(flags); @@ -135,14 +130,11 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { "Subscribed to occupancy updates", ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to occupancy in room: ${formatResource(this.roomName)}.`, - ), - ); - this.log(formatListening("Listening for occupancy updates.")); - } + this.logSuccessMessage( + `Subscribed to occupancy in room: ${formatResource(this.roomName)}.`, + flags, + ); + this.logListening("Listening for occupancy updates.", flags); // Wait until the user interrupts or the optional duration elapses await Promise.race([ diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 16c66091..bc9281b1 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -4,9 +4,6 @@ import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { isJsonData } from "../../../utils/json-formatter.js"; import { - formatSuccess, - formatListening, - formatProgress, formatResource, formatTimestamp, formatEventType, @@ -151,13 +148,10 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { await currentRoom.attach(); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Entering presence in room: ${formatResource(this.roomName)}`, - ), - ); - } + this.logProgress( + `Entering presence in room: ${formatResource(this.roomName)}`, + flags, + ); this.logCliEvent(flags, "presence", "entering", "Entering presence", { room: this.roomName, @@ -185,10 +179,9 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Entered presence in room: ${formatResource(this.roomName)}.`, - ), + this.logSuccessMessage( + `Entered presence in room: ${formatResource(this.roomName)}.`, + flags, ); this.log( `${formatLabel("Client ID")} ${formatClientId(this.chatClient.clientId ?? "unknown")}`, @@ -199,20 +192,13 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { if (this.data !== null) { this.log(`${formatLabel("Data")} ${JSON.stringify(this.data)}`); } - this.log( - formatListening( - flags["show-others"] - ? "Listening for presence events." - : "Holding presence.", - ), - ); } - this.logJsonStatus( - "holding", - "Holding presence. Press Ctrl+C to exit.", - flags, - ); + if (flags["show-others"]) { + this.logListening("Listening for presence events.", flags); + } else { + this.logHolding("Holding presence. Press Ctrl+C to exit.", flags); + } // Wait until the user interrupts or the optional duration elapses const waitPromises: Promise[] = [ @@ -224,6 +210,40 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { this.fail(error, flags, "roomPresenceEnter", { room: this.roomName, }); + } finally { + this.logCliEvent( + flags, + "presence", + "finallyBlockReached", + "Reached finally block for cleanup.", + ); + + if (!this.cleanupInProgress && !this.shouldOutputJson(flags)) { + this.logCliEvent( + flags, + "presence", + "implicitCleanupInFinally", + "Performing cleanup in finally (no prior signal or natural end).", + ); + } else { + this.logCliEvent( + flags, + "presence", + "explicitCleanupOrJsonMode", + "Cleanup already in progress or JSON output mode", + ); + } + + if (this.cleanupInProgress) { + this.logSuccessMessage("Graceful shutdown complete.", flags); + } else if (!this.shouldOutputJson(flags)) { + this.logCliEvent( + flags, + "presence", + "completedNormally", + "Command completed normally", + ); + } } } } diff --git a/src/commands/rooms/presence/get.ts b/src/commands/rooms/presence/get.ts index 36cb723b..ede94a0a 100644 --- a/src/commands/rooms/presence/get.ts +++ b/src/commands/rooms/presence/get.ts @@ -11,9 +11,7 @@ import { formatLabel, formatLimitWarning, formatMessageTimestamp, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; import { buildPaginationNext, @@ -61,13 +59,10 @@ export default class RoomsPresenceGet extends AblyBaseCommand { const { room: roomName } = args; const channelName = chatChannelName(roomName); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching presence members for room: ${formatResource(roomName)}`, - ), - ); - } + this.logProgress( + `Fetching presence members for room: ${formatResource(roomName)}`, + flags, + ); this.logCliEvent( flags, @@ -100,7 +95,7 @@ export default class RoomsPresenceGet extends AblyBaseCommand { items.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -123,7 +118,7 @@ export default class RoomsPresenceGet extends AblyBaseCommand { flags, ); } else if (items.length === 0) { - this.log(formatWarning("No members currently present in this room.")); + this.logWarning("No members currently present in this room.", flags); } else { this.log( `\n${formatHeading(`Presence members in room: ${formatResource(roomName)}`)} (${formatCountLabel(items.length, "member")}):\n`, diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index 7fe8cb1f..33950df2 100644 --- a/src/commands/rooms/presence/subscribe.ts +++ b/src/commands/rooms/presence/subscribe.ts @@ -8,11 +8,8 @@ import { formatClientId, formatEventType, formatLabel, - formatListening, formatMessageTimestamp, - formatProgress, formatResource, - formatSuccess, formatTimestamp, } from "../../../utils/output.js"; @@ -47,15 +44,32 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { this.roomName = args.room; try { - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Subscribing to presence events in room: ${formatResource(this.roomName)}`, - ), + // Show a progress signal early so E2E harnesses know the command is running + this.logProgress( + `Subscribing to presence in room: ${formatResource(this.roomName)}`, + flags, + ); + + // Try to create clients, but don't fail if auth fails + try { + this.chatClient = await this.createChatClient(flags); + } catch (authError) { + // Auth failed, but we still want to show the signal and wait + this.logCliEvent( + flags, + "initialization", + "authFailed", + `Authentication failed: ${authError instanceof Error ? authError.message : String(authError)}`, + ); + this.logWarning( + "Failed to connect to Ably (authentication failed).", + flags, ); - } - this.chatClient = await this.createChatClient(flags); + // Wait for the duration even with auth failures + await this.waitAndTrackCleanup(flags, "presence", flags.duration); + return; + } if (!this.chatClient) { this.fail( @@ -153,15 +167,11 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { "Listening for presence events. Press Ctrl+C to exit.", ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to presence in room: ${formatResource(this.roomName)}.`, - ), - ); - this.log(formatListening("Listening for presence events.")); - this.log(""); - } + this.logSuccessMessage( + `Subscribed to presence in room: ${formatResource(this.roomName)}.`, + flags, + ); + this.logListening("Listening for presence events.", flags); // Wait until the user interrupts or the optional duration elapses await Promise.race([ diff --git a/src/commands/rooms/reactions/send.ts b/src/commands/rooms/reactions/send.ts index 55192bad..7b192866 100644 --- a/src/commands/rooms/reactions/send.ts +++ b/src/commands/rooms/reactions/send.ts @@ -4,7 +4,7 @@ import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { errorMessage } from "../../../utils/errors.js"; import { clientIdFlag, productApiFlags } from "../../../flags.js"; -import { formatResource, formatSuccess } from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class RoomsReactionsSend extends ChatBaseCommand { static override args = { @@ -140,13 +140,12 @@ export default class RoomsReactionsSend extends ChatBaseCommand { { reaction: { emoji, metadata: this.metadataObj, room: roomName } }, flags, ); - } else { - this.log( - formatSuccess( - `Sent reaction ${emoji} in room ${formatResource(roomName)}.`, - ), - ); } + + this.logSuccessMessage( + `Sent reaction ${emoji} in room ${formatResource(roomName)}.`, + flags, + ); } catch (error) { this.fail(error, flags, "roomReactionSend", { room: roomName, diff --git a/src/commands/rooms/reactions/subscribe.ts b/src/commands/rooms/reactions/subscribe.ts index eb56e1da..88bb4cbe 100644 --- a/src/commands/rooms/reactions/subscribe.ts +++ b/src/commands/rooms/reactions/subscribe.ts @@ -7,10 +7,7 @@ import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { formatClientId, formatLabel, - formatListening, - formatProgress, formatResource, - formatSuccess, formatTimestamp, } from "../../../utils/output.js"; @@ -66,13 +63,10 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { "connecting", `Connecting to Ably and subscribing to reactions in room ${roomName}...`, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Connecting to Ably and subscribing to reactions in room ${formatResource(roomName)}`, - ), - ); - } + this.logProgress( + `Connecting to Ably and subscribing to reactions in room ${formatResource(roomName)}`, + flags, + ); // Get the room this.logCliEvent( @@ -154,14 +148,11 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { "Subscribed to reactions", ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to reactions in room: ${formatResource(roomName)}.`, - ), - ); - this.log(formatListening("Listening for reactions.")); - } + this.logSuccessMessage( + `Subscribed to reactions in room: ${formatResource(roomName)}.`, + flags, + ); + this.logListening("Listening for reactions.", flags); this.logCliEvent( flags, diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index 197c3353..6d48348a 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -3,11 +3,7 @@ import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; -import { - formatListening, - formatResource, - formatSuccess, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; // The heartbeats are throttled to one every 10 seconds. There's a 2 second // leeway to send a keystroke/heartbeat after the 10 second mark so the @@ -131,23 +127,22 @@ export default class TypingKeystroke extends ChatBaseCommand { }, flags, ); + } + + this.logSuccessMessage( + `Started typing in room: ${formatResource(roomName)}.`, + flags, + ); + if (flags["auto-type"]) { + this.logListening( + "Will automatically remain typing until terminated.", + flags, + ); } else { - this.log( - formatSuccess(`Started typing in room: ${formatResource(roomName)}.`), + this.logListening( + "Sent a single typing indicator. Use --auto-type to keep typing automatically.", + flags, ); - if (flags["auto-type"]) { - this.log( - formatListening( - "Will automatically remain typing until terminated.", - ), - ); - } else { - this.log( - formatListening( - "Sent a single typing indicator. Use --auto-type to keep typing automatically.", - ), - ); - } } // Keep typing active by calling keystroke() periodically if autoType is enabled diff --git a/src/commands/spaces/create.ts b/src/commands/spaces/create.ts index d1674718..52c006de 100644 --- a/src/commands/spaces/create.ts +++ b/src/commands/spaces/create.ts @@ -2,12 +2,7 @@ import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag } from "../../flags.js"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; -import { - formatProgress, - formatResource, - formatSuccess, - formatWarning, -} from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class SpacesCreate extends SpacesBaseCommand { static override args = { @@ -35,11 +30,10 @@ export default class SpacesCreate extends SpacesBaseCommand { const spaceName = args.space_name; try { - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Initializing space ${formatResource(spaceName)}`), - ); - } + this.logProgress( + `Initializing space ${formatResource(spaceName)}`, + flags, + ); await this.initializeSpace(flags, spaceName, { enterSpace: false, @@ -57,12 +51,11 @@ export default class SpacesCreate extends SpacesBaseCommand { flags, ); } else { - this.log( - formatSuccess( - `Space ${formatResource(spaceName)} initialized. Use "ably spaces members enter" to activate it.`, - ), + this.logSuccessMessage( + `Space ${formatResource(spaceName)} initialized. Use "ably spaces members enter" to activate it.`, + flags, ); - this.log(formatWarning(ephemeralSpaceWarning)); + this.logWarning(ephemeralSpaceWarning, flags); } } catch (error) { this.fail(error, flags, "spaceCreate"); diff --git a/src/commands/spaces/cursors/get.ts b/src/commands/spaces/cursors/get.ts index cdffdd8e..f3177bf9 100644 --- a/src/commands/spaces/cursors/get.ts +++ b/src/commands/spaces/cursors/get.ts @@ -7,9 +7,7 @@ import { formatCountLabel, formatHeading, formatIndex, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; import { formatCursorBlock, @@ -46,13 +44,10 @@ export default class SpacesCursorsGet extends SpacesBaseCommand { setupConnectionLogging: false, }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching cursors for space ${formatResource(spaceName)}`, - ), - ); - } + this.logProgress( + `Fetching cursors for space ${formatResource(spaceName)}`, + flags, + ); const allCursors = await this.space!.cursors.getAll(); @@ -68,7 +63,7 @@ export default class SpacesCursorsGet extends SpacesBaseCommand { flags, ); } else if (cursors.length === 0) { - this.logToStderr(formatWarning("No active cursors found in space.")); + this.logWarning("No active cursors found in space.", flags); } else { this.log( `\n${formatHeading("Current cursors")} (${formatCountLabel(cursors.length, "cursor")}):\n`, diff --git a/src/commands/spaces/cursors/set.ts b/src/commands/spaces/cursors/set.ts index f771a60f..fa353f20 100644 --- a/src/commands/spaces/cursors/set.ts +++ b/src/commands/spaces/cursors/set.ts @@ -3,13 +3,7 @@ import { Args, Flags } from "@oclif/core"; import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { - formatListening, - formatProgress, - formatResource, - formatSuccess, - formatLabel, -} from "../../../utils/output.js"; +import { formatResource, formatLabel } from "../../../utils/output.js"; export default class SpacesCursorsSet extends SpacesBaseCommand { static override args = { @@ -152,9 +146,7 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { ); } - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Entering space")); - } + this.logProgress("Entering space", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -193,8 +185,9 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { flags, ); } else { - this.log( - formatSuccess(`Set cursor in space ${formatResource(spaceName)}.`), + this.logSuccessMessage( + `Set cursor in space ${formatResource(spaceName)}.`, + flags, ); const lines: string[] = [ `${formatLabel("Position X")} ${position.x}`, @@ -215,11 +208,10 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { "Starting cursor movement simulation", ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress("Starting cursor movement simulation every 250ms"), - ); - } + this.logProgress( + "Starting cursor movement simulation every 250ms", + flags, + ); this.simulationIntervalId = setInterval(() => { void (async () => { @@ -280,17 +272,10 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { } // Hold in both simulate and non-simulate modes - if (!this.shouldOutputJson(flags)) { - this.log( - formatListening( - flags.simulate ? "Simulating cursor movement." : "Holding cursor.", - ), - ); - } - - this.logJsonStatus( - "holding", - "Holding cursor. Press Ctrl+C to exit.", + this.logHolding( + flags.simulate + ? "Simulating cursor movement. Press Ctrl+C to exit." + : "Holding cursor. Press Ctrl+C to exit.", flags, ); diff --git a/src/commands/spaces/cursors/subscribe.ts b/src/commands/spaces/cursors/subscribe.ts index d17b9abd..c7d6b65e 100644 --- a/src/commands/spaces/cursors/subscribe.ts +++ b/src/commands/spaces/cursors/subscribe.ts @@ -2,11 +2,7 @@ import { type CursorUpdate } from "@ably/spaces"; import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { - formatListening, - formatProgress, - formatTimestamp, -} from "../../../utils/output.js"; +import { formatTimestamp } from "../../../utils/output.js"; import { formatCursorBlock, formatCursorOutput, @@ -42,9 +38,7 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { const { space_name: spaceName } = args; try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Subscribing to cursor updates")); - } + this.logProgress("Subscribing to cursor updates", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -103,9 +97,7 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { }); } - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Listening for cursor movements.")); - } + this.logListening("Listening for cursor movements.", flags); await this.waitAndTrackCleanup(flags, "cursor", flags.duration); } catch (error) { diff --git a/src/commands/spaces/get.ts b/src/commands/spaces/get.ts index 86fb67e0..c3421cd1 100644 --- a/src/commands/spaces/get.ts +++ b/src/commands/spaces/get.ts @@ -10,7 +10,6 @@ import { formatHeading, formatIndex, formatLabel, - formatProgress, formatResource, } from "../../utils/output.js"; import type { MemberOutput } from "../../utils/spaces-output.js"; @@ -69,13 +68,10 @@ export default class SpacesGet extends SpacesBaseCommand { const spaceName = args.space_name; try { - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching state for space ${formatResource(spaceName)}`, - ), - ); - } + this.logProgress( + `Fetching state for space ${formatResource(spaceName)}`, + flags, + ); const rest = await this.createAblyRestClient(flags); if (!rest) return; diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 74870f28..604f1246 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -115,7 +115,7 @@ export default class SpacesList extends SpacesBaseCommand { spaces.length, ); if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { diff --git a/src/commands/spaces/locations/get.ts b/src/commands/spaces/locations/get.ts index 0076a136..474d1ef8 100644 --- a/src/commands/spaces/locations/get.ts +++ b/src/commands/spaces/locations/get.ts @@ -7,9 +7,7 @@ import { formatHeading, formatIndex, formatLabel, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; import type { LocationEntry } from "../../../utils/spaces-output.js"; @@ -43,13 +41,10 @@ export default class SpacesLocationsGet extends SpacesBaseCommand { setupConnectionLogging: false, }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching locations for space ${formatResource(spaceName)}`, - ), - ); - } + this.logProgress( + `Fetching locations for space ${formatResource(spaceName)}`, + flags, + ); const locationsFromSpace = await this.space!.locations.getAll(); @@ -72,9 +67,7 @@ export default class SpacesLocationsGet extends SpacesBaseCommand { flags, ); } else if (entries.length === 0) { - this.logToStderr( - formatWarning("No locations are currently set in this space."), - ); + this.logWarning("No locations are currently set in this space.", flags); } else { this.log( `\n${formatHeading("Current locations")} (${formatCountLabel(entries.length, "location")}):\n`, diff --git a/src/commands/spaces/locations/set.ts b/src/commands/spaces/locations/set.ts index 6a2ae8c8..c46120e6 100644 --- a/src/commands/spaces/locations/set.ts +++ b/src/commands/spaces/locations/set.ts @@ -3,9 +3,6 @@ import { Args, Flags } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatSuccess, - formatListening, - formatProgress, formatResource, formatLabel, formatClientId, @@ -44,9 +41,7 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { const location = this.parseJsonFlag(flags.location, "location", flags); try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Entering space")); - } + this.logProgress("Entering space", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -63,8 +58,9 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ location }, flags); } else { - this.log( - formatSuccess(`Location set in space: ${formatResource(spaceName)}.`), + this.logSuccessMessage( + `Location set in space: ${formatResource(spaceName)}.`, + flags, ); this.log( `${formatLabel("Client ID")} ${formatClientId(this.realtimeClient!.auth.clientId)}`, @@ -73,14 +69,8 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { `${formatLabel("Connection ID")} ${this.realtimeClient!.connection.id}`, ); this.log(`${formatLabel("Location")} ${JSON.stringify(location)}`); - this.log(formatListening("Holding location.")); } - - this.logJsonStatus( - "holding", - "Holding location. Press Ctrl+C to exit.", - flags, - ); + this.logHolding("Holding location. Press Ctrl+C to exit.", flags); await this.waitAndTrackCleanup(flags, "location", flags.duration); } catch (error) { diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index 2f3372c8..2d760bea 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -3,11 +3,7 @@ import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { - formatListening, - formatProgress, - formatTimestamp, -} from "../../../utils/output.js"; +import { formatTimestamp } from "../../../utils/output.js"; import { formatLocationUpdateBlock } from "../../../utils/spaces-output.js"; export default class SpacesLocationsSubscribe extends SpacesBaseCommand { @@ -39,15 +35,11 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { const { space_name: spaceName } = args; try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Subscribing to location updates")); - } + this.logProgress("Subscribing to location updates", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Listening for location updates.")); - } + this.logListening("Listening for location updates.", flags); this.logCliEvent( flags, diff --git a/src/commands/spaces/locks/acquire.ts b/src/commands/spaces/locks/acquire.ts index 02e89663..8e7599d5 100644 --- a/src/commands/spaces/locks/acquire.ts +++ b/src/commands/spaces/locks/acquire.ts @@ -4,12 +4,7 @@ import { Args, Flags } from "@oclif/core"; import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { - formatSuccess, - formatListening, - formatProgress, - formatResource, -} from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; import { formatLockBlock, formatLockOutput, @@ -77,9 +72,7 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { const { lockId } = this; try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Entering space")); - } + this.logProgress("Entering space", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -124,22 +117,19 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ lock: formatLockOutput(lock) }, flags); } else { - this.log(formatSuccess(`Lock acquired: ${formatResource(lockId)}.`)); + this.logSuccessMessage( + `Lock acquired: ${formatResource(lockId)}.`, + flags, + ); this.log(formatLockBlock(lock)); - this.log(formatListening("Holding lock.")); } + this.logHolding("Holding lock. Press Ctrl+C to exit.", flags); } catch (error) { this.fail(error, flags, "lockAcquire", { lockId, }); } - this.logJsonStatus( - "holding", - "Holding lock. Press Ctrl+C to exit.", - flags, - ); - this.logCliEvent( flags, "lock", diff --git a/src/commands/spaces/locks/get.ts b/src/commands/spaces/locks/get.ts index 24864841..4965eba0 100644 --- a/src/commands/spaces/locks/get.ts +++ b/src/commands/spaces/locks/get.ts @@ -7,9 +7,7 @@ import { formatCountLabel, formatHeading, formatIndex, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; import { formatLockBlock, @@ -70,13 +68,10 @@ export default class SpacesLocksGet extends SpacesBaseCommand { spaceName: string, lockId: string, ): void { - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching lock ${formatResource(lockId)} from space ${formatResource(spaceName)}`, - ), - ); - } + this.logProgress( + `Fetching lock ${formatResource(lockId)} from space ${formatResource(spaceName)}`, + flags, + ); const lock = this.space!.locks.get(lockId); @@ -84,10 +79,9 @@ export default class SpacesLocksGet extends SpacesBaseCommand { if (this.shouldOutputJson(flags)) { this.logJsonResult({ lock: null }, flags); } else { - this.logToStderr( - formatWarning( - `Lock ${formatResource(lockId)} not found in space ${formatResource(spaceName)}.`, - ), + this.logWarning( + `Lock ${formatResource(lockId)} not found in space ${formatResource(spaceName)}.`, + flags, ); } @@ -105,11 +99,10 @@ export default class SpacesLocksGet extends SpacesBaseCommand { flags: Record, spaceName: string, ): Promise { - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress(`Fetching locks for space ${formatResource(spaceName)}`), - ); - } + this.logProgress( + `Fetching locks for space ${formatResource(spaceName)}`, + flags, + ); const locks: Lock[] = await this.space!.locks.getAll(); @@ -121,9 +114,7 @@ export default class SpacesLocksGet extends SpacesBaseCommand { flags, ); } else if (locks.length === 0) { - this.logToStderr( - formatWarning("No locks are currently active in this space."), - ); + this.logWarning("No locks are currently active in this space.", flags); } else { this.log( `\n${formatHeading("Current locks")} (${formatCountLabel(locks.length, "lock")}):\n`, diff --git a/src/commands/spaces/locks/subscribe.ts b/src/commands/spaces/locks/subscribe.ts index 40048f62..4a1cf745 100644 --- a/src/commands/spaces/locks/subscribe.ts +++ b/src/commands/spaces/locks/subscribe.ts @@ -4,9 +4,7 @@ import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatListening, formatMessageTimestamp, - formatProgress, formatTimestamp, } from "../../../utils/output.js"; import { @@ -44,9 +42,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { const { space_name: spaceName } = args; try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Subscribing to lock events")); - } + this.logProgress("Subscribing to lock events", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -81,9 +77,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { "Successfully subscribed to lock events", ); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Listening for lock events.")); - } + this.logListening("Listening for lock events.", flags); await this.waitAndTrackCleanup(flags, "lock", flags.duration); } catch (error) { diff --git a/src/commands/spaces/members/enter.ts b/src/commands/spaces/members/enter.ts index 04bd383f..dd6f9df1 100644 --- a/src/commands/spaces/members/enter.ts +++ b/src/commands/spaces/members/enter.ts @@ -4,9 +4,6 @@ import { Args, Flags } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatSuccess, - formatListening, - formatProgress, formatResource, formatLabel, formatClientId, @@ -47,9 +44,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { const { space_name: spaceName } = args; try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Entering space")); - } + this.logProgress("Entering space", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -77,7 +72,10 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { const self = await this.space!.members.getSelf(); this.logJsonResult({ member: formatMemberOutput(self!) }, flags); } else { - this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); + this.logSuccessMessage( + `Entered space: ${formatResource(spaceName)}.`, + flags, + ); this.log( `${formatLabel("Client ID")} ${formatClientId(this.realtimeClient!.auth.clientId)}`, ); @@ -87,14 +85,8 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { if (profileData) { this.log(`${formatLabel("Profile")} ${JSON.stringify(profileData)}`); } - this.log(formatListening("Holding presence.")); } - - this.logJsonStatus( - "holding", - "Holding presence. Press Ctrl+C to exit.", - flags, - ); + this.logHolding("Holding presence. Press Ctrl+C to exit.", flags); // Wait until the user interrupts or the optional duration elapses await this.waitAndTrackCleanup(flags, "member", flags.duration); diff --git a/src/commands/spaces/members/get.ts b/src/commands/spaces/members/get.ts index 35cc2a2f..18581a82 100644 --- a/src/commands/spaces/members/get.ts +++ b/src/commands/spaces/members/get.ts @@ -7,9 +7,7 @@ import { formatCountLabel, formatHeading, formatIndex, - formatProgress, formatResource, - formatWarning, } from "../../../utils/output.js"; import { formatMemberBlock, @@ -46,13 +44,10 @@ export default class SpacesMembersGet extends SpacesBaseCommand { setupConnectionLogging: false, }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Fetching members for space ${formatResource(spaceName)}`, - ), - ); - } + this.logProgress( + `Fetching members for space ${formatResource(spaceName)}`, + flags, + ); const members: SpaceMember[] = await this.space!.members.getAll(); @@ -64,7 +59,7 @@ export default class SpacesMembersGet extends SpacesBaseCommand { flags, ); } else if (members.length === 0) { - this.logToStderr(formatWarning("No members currently in this space.")); + this.logWarning("No members currently in this space.", flags); } else { this.log( `\n${formatHeading("Current members")} (${formatCountLabel(members.length, "member")}):\n`, diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 07736512..3d295c13 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -4,9 +4,7 @@ import { Args } from "@oclif/core"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - formatListening, formatMessageTimestamp, - formatProgress, formatTimestamp, } from "../../../utils/output.js"; import { @@ -50,15 +48,11 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { try { // Always show the readiness signal first, before attempting auth - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Subscribing to member updates")); - } + this.logProgress("Subscribing to member updates", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Listening for member events.")); - } + this.logListening("Listening for member events.", flags); // Subscribe to member presence events this.logCliEvent( diff --git a/src/commands/spaces/occupancy/subscribe.ts b/src/commands/spaces/occupancy/subscribe.ts index 80133d01..04e3deee 100644 --- a/src/commands/spaces/occupancy/subscribe.ts +++ b/src/commands/spaces/occupancy/subscribe.ts @@ -6,11 +6,8 @@ import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { formatEventType, formatLabel, - formatListening, formatMessageTimestamp, - formatProgress, formatResource, - formatSuccess, formatTimestamp, } from "../../../utils/output.js"; @@ -73,13 +70,10 @@ export default class SpacesOccupancySubscribe extends SpacesBaseCommand { { spaceName, channel: channelName }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatProgress( - `Subscribing to occupancy events on space: ${formatResource(spaceName)}`, - ), - ); - } + this.logProgress( + `Subscribing to occupancy events on space: ${formatResource(spaceName)}`, + flags, + ); await channel.subscribe(occupancyEventName, (message: Ably.Message) => { const timestamp = formatMessageTimestamp(message.timestamp); @@ -136,14 +130,11 @@ export default class SpacesOccupancySubscribe extends SpacesBaseCommand { } }); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to occupancy on space: ${formatResource(spaceName)}.`, - ), - ); - this.log(formatListening("Listening for occupancy events.")); - } + this.logSuccessMessage( + `Subscribed to occupancy on space: ${formatResource(spaceName)}.`, + flags, + ); + this.logListening("Listening for occupancy events.", flags); this.logCliEvent( flags, diff --git a/src/commands/spaces/subscribe.ts b/src/commands/spaces/subscribe.ts index dcbce363..51cd61f8 100644 --- a/src/commands/spaces/subscribe.ts +++ b/src/commands/spaces/subscribe.ts @@ -6,9 +6,8 @@ import { SpacesBaseCommand } from "../../spaces-base-command.js"; import { formatEventType, formatLabel, - formatListening, formatMessageTimestamp, - formatProgress, + formatResource, formatTimestamp, } from "../../utils/output.js"; import { @@ -52,9 +51,7 @@ export default class SpacesSubscribe extends SpacesBaseCommand { >(); try { - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Subscribing to space updates")); - } + this.logProgress("Subscribing to space updates", flags); await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -161,9 +158,11 @@ export default class SpacesSubscribe extends SpacesBaseCommand { this.space!.members.subscribe("update", memberListener); this.space!.locations.subscribe("update", locationListener); - if (!this.shouldOutputJson(flags)) { - this.log(formatListening("Listening for space updates.")); - } + this.logSuccessMessage( + `Subscribed to space: ${formatResource(spaceName)}.`, + flags, + ); + this.logListening("Listening for space updates.", flags); this.logCliEvent( flags, diff --git a/src/commands/status.ts b/src/commands/status.ts index 72cad328..dfc4b3b1 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -7,7 +7,6 @@ import { AblyBaseCommand } from "../base-command.js"; import { coreGlobalFlags } from "../flags.js"; import { BaseFlags } from "../types/cli.js"; import openUrl from "../utils/open-url.js"; -import { formatProgress, formatSuccess } from "../utils/output.js"; import { getCliVersion } from "../utils/version.js"; interface StatusResponse { @@ -40,8 +39,8 @@ export default class StatusCommand extends AblyBaseCommand { isInteractive || isJson ? null : ora("Checking Ably service status...").start(); - if (isInteractive && !isJson) { - this.log(formatProgress("Checking Ably service status")); + if (isInteractive) { + this.logProgress("Checking Ably service status", flags); } try { @@ -72,11 +71,15 @@ export default class StatusCommand extends AblyBaseCommand { flags as BaseFlags, ); } else if (data.status) { - this.log(formatSuccess("Ably services are operational.")); this.log("No incidents currently reported"); + } + + if (data.status) { + this.logSuccessMessage("Ably services are operational.", flags); } else { - this.log( - `${chalk.red("⨯")} ${chalk.red("Incident detected")} - There are currently open incidents`, + this.logWarning( + "Incident detected - There are currently open incidents.", + flags, ); } diff --git a/src/commands/support/ask.ts b/src/commands/support/ask.ts index ef77dd1d..4f07ab57 100644 --- a/src/commands/support/ask.ts +++ b/src/commands/support/ask.ts @@ -40,7 +40,7 @@ export default class AskCommand extends ControlBaseCommand { ? null : ora("Thinking...").start(); if (isInteractive && !this.shouldOutputJson(flags)) { - this.log("Thinking..."); + this.logProgress("Thinking", flags); } try { @@ -58,10 +58,9 @@ export default class AskCommand extends ControlBaseCommand { response = await controlApi.askHelp(args.question, conversation); } else { if (spinner) spinner.stop(); - this.log( - chalk.yellow( - "No previous conversation found. Starting a new conversation.", - ), + this.logWarning( + "No previous conversation found. Starting a new conversation.", + flags, ); response = await controlApi.askHelp(args.question); } diff --git a/src/commands/test/wait.ts b/src/commands/test/wait.ts index cc9d5810..a32e3c10 100644 --- a/src/commands/test/wait.ts +++ b/src/commands/test/wait.ts @@ -23,14 +23,15 @@ export default class TestWait extends AblyBaseCommand { async run(): Promise { const { flags } = await this.parse(TestWait); - this.log( - `Waiting for ${flags.duration} seconds. Press Ctrl+C to interrupt...`, + this.logProgress( + `Waiting for ${flags.duration} seconds. Press Ctrl+C to interrupt`, + flags, ); // Use a simple promise with timeout await new Promise((resolve) => { const timeout = setTimeout(() => { - this.log("Wait completed successfully."); + this.logSuccessMessage("Wait completed successfully.", flags); resolve(); }, flags.duration * 1000); diff --git a/test/e2e/channels/channels-e2e.test.ts b/test/e2e/channels/channels-e2e.test.ts index 4c9de1a2..0d9e65b8 100644 --- a/test/e2e/channels/channels-e2e.test.ts +++ b/test/e2e/channels/channels-e2e.test.ts @@ -232,7 +232,7 @@ describe("Channel E2E Tests", () => { if (!publishResult.stdout || publishResult.stdout.trim() === "") { throw new Error( - `Publish command returned empty output. Exit code: ${publishResult.exitCode}, Stderr: ${publishResult.stderr}, Stdout length: ${publishResult.stdout.length}`, + `Publish command returned empty stderr. Exit code: ${publishResult.exitCode}, Stderr: ${publishResult.stderr}, Stdout length: ${publishResult.stdout.length}`, ); } @@ -408,7 +408,7 @@ describe("Channel E2E Tests", () => { if (!batchPublishResult.stdout || batchPublishResult.stdout.trim() === "") { throw new Error( - `Batch publish command returned empty output. Exit code: ${batchPublishResult.exitCode}, Stderr: ${batchPublishResult.stderr}, Stdout length: ${batchPublishResult.stdout.length}`, + `Batch publish command returned empty stderr. Exit code: ${batchPublishResult.exitCode}, Stderr: ${batchPublishResult.stderr}, Stdout length: ${batchPublishResult.stdout.length}`, ); } @@ -477,7 +477,7 @@ describe("Channel E2E Tests", () => { if (!countPublishResult.stdout || countPublishResult.stdout.trim() === "") { throw new Error( - `Count publish command returned empty output. Exit code: ${countPublishResult.exitCode}, Stderr: ${countPublishResult.stderr}, Stdout length: ${countPublishResult.stdout.length}`, + `Count publish command returned empty stderr. Exit code: ${countPublishResult.exitCode}, Stderr: ${countPublishResult.stderr}, Stdout length: ${countPublishResult.stdout.length}`, ); } diff --git a/test/unit/commands/rooms/messages.test.ts b/test/unit/commands/rooms/messages.test.ts index 57149513..81330d26 100644 --- a/test/unit/commands/rooms/messages.test.ts +++ b/test/unit/commands/rooms/messages.test.ts @@ -347,7 +347,8 @@ describe("rooms messages commands", function () { const events = records.filter( (r) => r.type === "event" && - (r.message as Record).room === "test-room", + (r.message as Record | undefined)?.room === + "test-room", ); expect(events.length).toBeGreaterThan(0); const record = events[0]; diff --git a/test/unit/commands/rooms/messages/subscribe.test.ts b/test/unit/commands/rooms/messages/subscribe.test.ts index 2659f6d3..cd0e7a5e 100644 --- a/test/unit/commands/rooms/messages/subscribe.test.ts +++ b/test/unit/commands/rooms/messages/subscribe.test.ts @@ -145,7 +145,8 @@ describe("rooms:messages:subscribe command", () => { const events = records.filter( (r) => r.type === "event" && - (r.message as Record).room === "test-room", + (r.message as Record | undefined)?.room === + "test-room", ); expect(events.length).toBeGreaterThan(0); const record = events[0]; From c05a5dea995bb9fb032b43c146d77ba2165852c7 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:46:20 +0100 Subject: [PATCH 5/9] Update unit tests for stderr status messages and NDJSON output Mechanical test updates across ~104 test files: - Switch stdout assertions to stderr for status/progress/success messages - Use parseJsonOutput() for JSON tests that now receive multiple records (result + completed signal) - Adjust JSON output parsing to handle NDJSON format --- test/unit/commands/accounts/list.test.ts | 5 +- test/unit/commands/accounts/login.test.ts | 31 +++++---- test/unit/commands/accounts/logout.test.ts | 25 +++++-- test/unit/commands/accounts/switch.test.ts | 13 ++-- test/unit/commands/apps/create.test.ts | 41 +++++++----- test/unit/commands/apps/delete.test.ts | 32 ++++++--- test/unit/commands/apps/list.test.ts | 3 +- test/unit/commands/apps/rules/create.test.ts | 41 +++++++----- test/unit/commands/apps/rules/delete.test.ts | 10 +-- test/unit/commands/apps/rules/list.test.ts | 5 +- test/unit/commands/apps/rules/update.test.ts | 38 ++++++----- test/unit/commands/apps/switch.test.ts | 11 ++-- test/unit/commands/apps/update.test.ts | 65 +++++++++++++------ .../commands/auth/issue-ably-token.test.ts | 3 +- .../commands/auth/issue-jwt-token.test.ts | 3 +- test/unit/commands/auth/keys/create.test.ts | 30 +++++---- test/unit/commands/auth/keys/current.test.ts | 3 +- test/unit/commands/auth/keys/get.test.ts | 6 +- test/unit/commands/auth/keys/list.test.ts | 3 +- test/unit/commands/auth/keys/revoke.test.ts | 3 +- test/unit/commands/auth/keys/switch.test.ts | 16 +++-- test/unit/commands/auth/keys/update.test.ts | 3 +- test/unit/commands/auth/revoke-token.test.ts | 11 ++-- test/unit/commands/bench/publisher.test.ts | 10 ++- test/unit/commands/bench/subscriber.test.ts | 26 +++++--- .../channels/annotations/delete.test.ts | 24 +++---- .../commands/channels/annotations/get.test.ts | 9 ++- .../channels/annotations/publish.test.ts | 41 ++++++------ .../channels/annotations/subscribe.test.ts | 4 +- test/unit/commands/channels/append.test.ts | 16 +++-- .../commands/channels/batch-publish.test.ts | 39 +++++++---- test/unit/commands/channels/delete.test.ts | 16 +++-- test/unit/commands/channels/list.test.ts | 18 ++++- .../commands/channels/occupancy/get.test.ts | 3 +- .../channels/occupancy/subscribe.test.ts | 6 +- .../commands/channels/presence/enter.test.ts | 43 ++++++------ .../commands/channels/presence/get.test.ts | 30 +++++++-- .../channels/presence/subscribe.test.ts | 6 +- test/unit/commands/channels/publish.test.ts | 29 +++++---- test/unit/commands/channels/update.test.ts | 16 +++-- test/unit/commands/config/path.test.ts | 5 +- test/unit/commands/config/show.test.ts | 11 ++-- .../unit/commands/integrations/create.test.ts | 27 ++++---- .../unit/commands/integrations/delete.test.ts | 14 ++-- test/unit/commands/integrations/get.test.ts | 5 +- test/unit/commands/integrations/list.test.ts | 3 +- .../unit/commands/integrations/update.test.ts | 37 +++++++---- .../logs/channel-lifecycle/subscribe.test.ts | 8 +-- .../logs/connection-lifecycle/history.test.ts | 3 +- .../connection-lifecycle/subscribe.test.ts | 8 +-- test/unit/commands/logs/push/history.test.ts | 3 +- test/unit/commands/logs/subscribe.test.ts | 6 +- test/unit/commands/push/batch-publish.test.ts | 20 +++--- .../push/channels/list-channels.test.ts | 10 ++- test/unit/commands/push/channels/list.test.ts | 8 ++- .../push/channels/remove-where.test.ts | 14 ++-- .../commands/push/channels/remove.test.ts | 12 +++- test/unit/commands/push/channels/save.test.ts | 18 +++-- .../commands/push/config/clear-apns.test.ts | 18 +++-- .../commands/push/config/clear-fcm.test.ts | 18 +++-- .../commands/push/config/set-apns.test.ts | 18 ++--- .../unit/commands/push/config/set-fcm.test.ts | 12 +++- test/unit/commands/push/config/show.test.ts | 8 ++- test/unit/commands/push/devices/get.test.ts | 10 ++- test/unit/commands/push/devices/list.test.ts | 18 ++++- .../push/devices/remove-where.test.ts | 14 ++-- .../unit/commands/push/devices/remove.test.ts | 16 +++-- test/unit/commands/push/devices/save.test.ts | 20 ++++-- test/unit/commands/push/publish.test.ts | 23 +++---- test/unit/commands/queues/create.test.ts | 27 ++++---- test/unit/commands/queues/delete.test.ts | 16 ++--- test/unit/commands/queues/list.test.ts | 7 +- test/unit/commands/rooms/features.test.ts | 28 ++++---- test/unit/commands/rooms/list.test.ts | 3 +- test/unit/commands/rooms/messages.test.ts | 12 ++-- .../commands/rooms/messages/delete.test.ts | 6 +- .../commands/rooms/messages/history.test.ts | 4 +- .../rooms/messages/reactions/remove.test.ts | 19 +++--- .../rooms/messages/reactions/send.test.ts | 19 +++--- .../unit/commands/rooms/messages/send.test.ts | 8 +-- .../commands/rooms/messages/subscribe.test.ts | 4 +- .../commands/rooms/messages/update.test.ts | 6 +- .../unit/commands/rooms/occupancy/get.test.ts | 5 +- .../rooms/occupancy/subscribe.test.ts | 8 +-- .../commands/rooms/presence/enter.test.ts | 6 +- test/unit/commands/rooms/presence/get.test.ts | 13 ++-- .../commands/rooms/reactions/send.test.ts | 12 ++-- .../commands/rooms/typing/keystroke.test.ts | 17 ++--- test/unit/commands/spaces/create.test.ts | 6 +- test/unit/commands/spaces/cursors/set.test.ts | 17 ++--- .../commands/spaces/cursors/subscribe.test.ts | 6 +- test/unit/commands/spaces/list.test.ts | 7 +- .../commands/spaces/locations/set.test.ts | 17 ++--- .../spaces/locations/subscribe.test.ts | 4 +- .../commands/spaces/locks/acquire.test.ts | 15 +++-- .../commands/spaces/locks/subscribe.test.ts | 4 +- .../commands/spaces/members/enter.test.ts | 11 ++-- .../commands/spaces/members/subscribe.test.ts | 10 +-- .../spaces/occupancy/subscribe.test.ts | 6 +- test/unit/commands/spaces/spaces.test.ts | 20 +++--- test/unit/commands/status.test.ts | 10 +-- test/unit/commands/support/ask.test.ts | 3 +- test/unit/commands/test/wait.test.ts | 10 +-- test/unit/commands/version.test.ts | 3 +- 104 files changed, 919 insertions(+), 574 deletions(-) diff --git a/test/unit/commands/accounts/list.test.ts b/test/unit/commands/accounts/list.test.ts index 624297be..c48d65f7 100644 --- a/test/unit/commands/accounts/list.test.ts +++ b/test/unit/commands/accounts/list.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("accounts:list command", () => { beforeEach(() => { @@ -33,7 +34,7 @@ describe("accounts:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("accounts"); expect(result.accounts).toEqual([]); @@ -61,7 +62,7 @@ describe("accounts:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("accounts"); expect(result.accounts.length).toBeGreaterThan(0); diff --git a/test/unit/commands/accounts/login.test.ts b/test/unit/commands/accounts/login.test.ts index 9c6099b8..096c87c9 100644 --- a/test/unit/commands/accounts/login.test.ts +++ b/test/unit/commands/accounts/login.test.ts @@ -11,6 +11,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; describe("accounts:login command", () => { const mockAccessToken = "test_access_token_12345"; @@ -48,14 +49,16 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("account"); - expect(result.account).toHaveProperty("id", mockAccountId); - expect(result.account).toHaveProperty("name", "Test Account"); - expect(result.account.user).toHaveProperty("email", "test@example.com"); + const account = result.account as Record; + expect(account).toHaveProperty("id", mockAccountId); + expect(account).toHaveProperty("name", "Test Account"); + const user = account.user as Record; + expect(user).toHaveProperty("email", "test@example.com"); // Verify config was updated correctly via mock const mock = getMockConfigManager(); @@ -87,11 +90,12 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", true); - expect(result.account).toHaveProperty("alias", customAlias); + const account = result.account as Record; + expect(account).toHaveProperty("alias", customAlias); // Verify config was written with custom alias via mock const mock = getMockConfigManager(); @@ -129,7 +133,7 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", true); @@ -175,7 +179,7 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", true); @@ -203,7 +207,7 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; expect(result).toHaveProperty("type", "error"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", false); @@ -219,7 +223,7 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; expect(result).toHaveProperty("type", "error"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", false); @@ -238,7 +242,7 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "error")!; expect(result).toHaveProperty("type", "error"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", false); @@ -276,11 +280,12 @@ describe("accounts:login command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "accounts:login"); expect(result).toHaveProperty("success", true); - expect(result.account).toHaveProperty("id", mockAccountId); + const account = result.account as Record; + expect(account).toHaveProperty("id", mockAccountId); // Verify config was written correctly via mock const mock = getMockConfigManager(); diff --git a/test/unit/commands/accounts/logout.test.ts b/test/unit/commands/accounts/logout.test.ts index 82107be1..2f728665 100644 --- a/test/unit/commands/accounts/logout.test.ts +++ b/test/unit/commands/accounts/logout.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; describe("accounts:logout command", () => { standardHelpTests("accounts:logout", import.meta.url); @@ -24,7 +25,9 @@ describe("accounts:logout command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("No account"); @@ -54,7 +57,9 @@ describe("accounts:logout command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("account"); expect(result.account).toHaveProperty("alias", "testaccount"); @@ -72,7 +77,9 @@ describe("accounts:logout command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; expect(result).toHaveProperty("success", true); expect(result.account).toHaveProperty("alias", "testaccount"); @@ -112,7 +119,9 @@ describe("accounts:logout command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; expect(result).toHaveProperty("success", true); expect(result.account).toHaveProperty("alias", "primary"); expect(result.account.remainingAccounts).toContain("secondary"); @@ -134,7 +143,9 @@ describe("accounts:logout command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; expect(result).toHaveProperty("success", true); expect(result.account).toHaveProperty("alias", "secondary"); expect(result.account.remainingAccounts).toContain("primary"); @@ -174,7 +185,9 @@ describe("accounts:logout command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find( + (r) => r.type === "result" || r.type === "error", + )!; expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("not found"); diff --git a/test/unit/commands/accounts/switch.test.ts b/test/unit/commands/accounts/switch.test.ts index 7c1c8783..89ac20cb 100644 --- a/test/unit/commands/accounts/switch.test.ts +++ b/test/unit/commands/accounts/switch.test.ts @@ -10,6 +10,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("accounts:switch command", () => { const mockAccountId = "switch-account-id"; @@ -42,13 +43,13 @@ describe("accounts:switch command", () => { user: { email: mockUserEmail }, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["accounts:switch", "second"], import.meta.url, ); - expect(stdout).toContain("Switched to account:"); - expect(stdout).toContain(mockAccountName); + expect(stderr).toContain("Switched to account"); + expect(stderr).toContain(mockAccountName); expect(stdout).toContain(mockUserEmail); }); @@ -75,7 +76,7 @@ describe("accounts:switch command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result.error.message).toContain("No accounts configured"); }); @@ -89,7 +90,7 @@ describe("accounts:switch command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("No accounts configured"); @@ -103,7 +104,7 @@ describe("accounts:switch command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("not found"); diff --git a/test/unit/commands/apps/create.test.ts b/test/unit/commands/apps/create.test.ts index 3bb72fd7..aca955a0 100644 --- a/test/unit/commands/apps/create.test.ts +++ b/test/unit/commands/apps/create.test.ts @@ -57,17 +57,16 @@ describe("apps:create command", () => { tlsOnly: false, }); - const { stdout } = await runCommand([ + const { stdout, stderr } = await runCommand([ "apps:create", "--name", `"${mockAppName}"`, ]); - expect(stdout).toContain("App created:"); + expect(stderr).toContain("App created:"); expect(stdout).toContain(newAppId); expect(stdout).toContain(mockAppName); - expect(stdout).toContain("Automatically switched to app"); - expect(stdout).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/); + expect(stderr).toContain("Automatically switched to app"); }); it("should create an app with TLS only flag", async () => { @@ -100,14 +99,14 @@ describe("apps:create command", () => { tlsOnly: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:create", "--name", `"${mockAppName}"`, "--tls-only"], import.meta.url, ); - expect(stdout).toContain("App created:"); + expect(stderr).toContain("App created:"); expect(stdout).toContain("TLS Only: Yes"); - expect(stdout).toContain("Automatically switched to app"); + expect(stderr).toContain("Automatically switched to app"); }); it("should output JSON format when --json flag is used", async () => { @@ -142,13 +141,19 @@ describe("apps:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "apps:create"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("app"); - expect(result.app).toHaveProperty("id", newAppId); - expect(result.app).toHaveProperty("name", mockAppName); + expect(result!.app).toHaveProperty("id", newAppId); + expect(result!.app).toHaveProperty("name", mockAppName); }); it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { @@ -189,13 +194,13 @@ describe("apps:create command", () => { tlsOnly: false, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:create", "--name", mockAppName], import.meta.url, ); - expect(stdout).toContain("App created:"); - expect(stdout).toContain("Automatically switched to app"); + expect(stderr).toContain("App created:"); + expect(stderr).toContain("Automatically switched to app"); }); it("should automatically switch to the newly created app", async () => { @@ -223,14 +228,14 @@ describe("apps:create command", () => { tlsOnly: false, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:create", "--name", `"${mockAppName}"`], import.meta.url, ); - expect(stdout).toContain("App created:"); - expect(stdout).toContain( - `Automatically switched to app: ${mockAppName} (${newAppId})`, + expect(stderr).toContain("App created:"); + expect(stderr).toContain( + `Automatically switched to app ${mockAppName} (${newAppId})`, ); // Verify the mock config was updated with the new app diff --git a/test/unit/commands/apps/delete.test.ts b/test/unit/commands/apps/delete.test.ts index 2b19bd2b..b2f00f8c 100644 --- a/test/unit/commands/apps/delete.test.ts +++ b/test/unit/commands/apps/delete.test.ts @@ -59,12 +59,12 @@ describe("apps:delete command", () => { // Mock the app deletion endpoint nockControl().delete(`/v1/apps/${appId}`).reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:delete", appId, "--force"], import.meta.url, ); - expect(stdout).toContain("App deleted successfully"); + expect(stderr).toContain("App deleted successfully"); }); it("should output JSON format when --json flag is used", async () => { @@ -103,13 +103,19 @@ describe("apps:delete command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "apps:delete"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("app"); - expect(result.app).toHaveProperty("id", appId); - expect(result.app).toHaveProperty("name", mockAppName); + expect(result!.app).toHaveProperty("id", appId); + expect(result!.app).toHaveProperty("name", mockAppName); }); it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { @@ -162,12 +168,12 @@ describe("apps:delete command", () => { .delete(`/v1/apps/${appId}`) .reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:delete", appId, "--force"], import.meta.url, ); - expect(stdout).toContain("App deleted successfully"); + expect(stderr).toContain("App deleted successfully"); }); }); @@ -305,7 +311,13 @@ describe("apps:delete command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the error record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "error"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "error"); expect(result).toHaveProperty("command", "apps:delete"); expect(result).toHaveProperty("success", false); @@ -442,12 +454,12 @@ describe("apps:delete command", () => { // Mock the app deletion endpoint nockControl().delete(`/v1/apps/${appId}`).reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:delete", "--force"], import.meta.url, ); - expect(stdout).toContain("App deleted successfully"); + expect(stderr).toContain("App deleted successfully"); } finally { // Restore original environment variable if (originalAppId) { diff --git a/test/unit/commands/apps/list.test.ts b/test/unit/commands/apps/list.test.ts index 0140cd50..401b070c 100644 --- a/test/unit/commands/apps/list.test.ts +++ b/test/unit/commands/apps/list.test.ts @@ -6,6 +6,7 @@ import { CONTROL_HOST, } from "../../../helpers/control-api-test-helpers.js"; import { runCommand } from "@oclif/test"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -89,7 +90,7 @@ describe("apps:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "apps:list"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/apps/rules/create.test.ts b/test/unit/commands/apps/rules/create.test.ts index 1be713f7..6cc964da 100644 --- a/test/unit/commands/apps/rules/create.test.ts +++ b/test/unit/commands/apps/rules/create.test.ts @@ -12,6 +12,7 @@ import { standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; import { mockNamespace } from "../../../../fixtures/control-api.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; describe("apps:rules:create command", () => { const mockRuleName = "chat"; @@ -39,12 +40,12 @@ describe("apps:rules:create command", () => { .post(`/v1/apps/${appId}/namespaces`) .reply(201, mockNamespace({ id: mockRuleId })); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:rules:create", "--name", mockRuleName], import.meta.url, ); - expect(stdout).toContain("Rule chat created."); + expect(stderr).toContain("Channel rule chat created."); }); it("should create a rule with persisted flag", async () => { @@ -55,13 +56,14 @@ describe("apps:rules:create command", () => { }) .reply(201, mockNamespace({ id: mockRuleId, persisted: true })); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:rules:create", "--name", mockRuleName, "--persisted"], import.meta.url, ); - expect(stdout).toContain("Rule chat created."); - expect(stdout).toContain("Persisted: ✓ Yes"); + expect(stderr).toContain("Channel rule chat created."); + expect(stdout).toContain("Persisted:"); + expect(stdout).toContain("Yes"); }); it("should create a rule with mutable-messages flag and auto-enable persistence", async () => { @@ -84,9 +86,9 @@ describe("apps:rules:create command", () => { import.meta.url, ); - expect(stdout).toContain("Rule chat created."); - expect(stdout).toContain("Persisted: ✓ Yes"); - expect(stdout).toContain("Mutable Messages: ✓ Yes"); + expect(stderr).toContain("Channel rule chat created."); + expect(stdout).toContain("Persisted:"); + expect(stdout).toContain("Mutable Messages:"); expect(stderr).toContain("persistence is automatically enabled"); }); @@ -98,13 +100,13 @@ describe("apps:rules:create command", () => { }) .reply(201, mockNamespace({ id: mockRuleId, pushEnabled: true })); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:rules:create", "--name", mockRuleName, "--push-enabled"], import.meta.url, ); - expect(stdout).toContain("Rule chat created."); - expect(stdout).toContain("Push Enabled: ✓ Yes"); + expect(stderr).toContain("Channel rule chat created."); + expect(stdout).toContain("Push Enabled:"); }); it("should output JSON format when --json flag is used", async () => { @@ -118,10 +120,11 @@ describe("apps:rules:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("rule"); - expect(result.rule).toHaveProperty("id", mockRuleId); + const rule = result.rule as Record; + expect(rule).toHaveProperty("id", mockRuleId); }); it("should include mutableMessages in JSON output when --mutable-messages is used", async () => { @@ -150,10 +153,16 @@ describe("apps:rules:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); expect(result).toHaveProperty("success", true); - expect(result.rule).toHaveProperty("persisted", true); - expect(result.rule).toHaveProperty("mutableMessages", true); + expect(result!.rule).toHaveProperty("persisted", true); + expect(result!.rule).toHaveProperty("mutableMessages", true); }); }); diff --git a/test/unit/commands/apps/rules/delete.test.ts b/test/unit/commands/apps/rules/delete.test.ts index 44f1ffff..9a7bd449 100644 --- a/test/unit/commands/apps/rules/delete.test.ts +++ b/test/unit/commands/apps/rules/delete.test.ts @@ -12,6 +12,7 @@ import { standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; import { mockNamespace } from "../../../../fixtures/control-api.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; describe("apps:rules:delete command", () => { const mockRuleId = "chat"; @@ -41,12 +42,12 @@ describe("apps:rules:delete command", () => { .delete(`/v1/apps/${appId}/namespaces/${mockRuleId}`) .reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:rules:delete", mockRuleId, "--force"], import.meta.url, ); - expect(stdout).toContain("deleted"); + expect(stderr).toContain("deleted"); }); it("should output JSON format when --json flag is used", async () => { @@ -64,10 +65,11 @@ describe("apps:rules:delete command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("rule"); - expect(result.rule).toHaveProperty("id", mockRuleId); + const rule = result.rule as Record; + expect(rule).toHaveProperty("id", mockRuleId); }); }); diff --git a/test/unit/commands/apps/rules/list.test.ts b/test/unit/commands/apps/rules/list.test.ts index 46eebd96..b9866cbf 100644 --- a/test/unit/commands/apps/rules/list.test.ts +++ b/test/unit/commands/apps/rules/list.test.ts @@ -5,6 +5,7 @@ import { controlApiCleanup, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -97,7 +98,7 @@ describe("apps:rules:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("rules"); expect(result.rules).toHaveLength(1); @@ -117,7 +118,7 @@ describe("apps:rules:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result.rules).toHaveLength(2); }); diff --git a/test/unit/commands/apps/rules/update.test.ts b/test/unit/commands/apps/rules/update.test.ts index ad19632d..4b571d20 100644 --- a/test/unit/commands/apps/rules/update.test.ts +++ b/test/unit/commands/apps/rules/update.test.ts @@ -12,6 +12,7 @@ import { standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; import { mockNamespace } from "../../../../fixtures/control-api.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; describe("apps:rules:update command", () => { const mockRuleId = "chat"; @@ -43,13 +44,13 @@ describe("apps:rules:update command", () => { .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) .reply(200, mockNamespace({ id: mockRuleId, persisted: true })); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:rules:update", mockRuleId, "--persisted"], import.meta.url, ); - expect(stdout).toContain("updated"); - expect(stdout).toContain("Persisted: ✓ Yes"); + expect(stderr).toContain("updated"); + expect(stdout).toContain("Persisted:"); }); it("should update a rule with mutable-messages flag and auto-enable persistence", async () => { @@ -64,12 +65,12 @@ describe("apps:rules:update command", () => { }) .reply(200, mockNamespace({ id: mockRuleId, persisted: true })); - const { stdout, stderr } = await runCommand( + const { stderr } = await runCommand( ["apps:rules:update", mockRuleId, "--mutable-messages"], import.meta.url, ); - expect(stdout).toContain("updated"); + expect(stderr).toContain("updated"); expect(stderr).toContain("persistence is automatically enabled"); }); @@ -127,7 +128,7 @@ describe("apps:rules:update command", () => { }) .reply(200, mockNamespace({ id: mockRuleId })); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "apps:rules:update", mockRuleId, @@ -137,7 +138,7 @@ describe("apps:rules:update command", () => { import.meta.url, ); - expect(stdout).toContain("updated"); + expect(stderr).toContain("updated"); expect(stdout).toContain("Persisted: No"); }); @@ -151,13 +152,13 @@ describe("apps:rules:update command", () => { .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) .reply(200, mockNamespace({ id: mockRuleId, pushEnabled: true })); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:rules:update", mockRuleId, "--push-enabled"], import.meta.url, ); - expect(stdout).toContain("updated"); - expect(stdout).toContain("Push Enabled: ✓ Yes"); + expect(stderr).toContain("updated"); + expect(stdout).toContain("Push Enabled:"); }); it("should output JSON format when --json flag is used", async () => { @@ -175,11 +176,12 @@ describe("apps:rules:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("rule"); - expect(result.rule).toHaveProperty("id", mockRuleId); - expect(result.rule).toHaveProperty("persisted", true); + const rule = result.rule as Record; + expect(rule).toHaveProperty("id", mockRuleId); + expect(rule).toHaveProperty("persisted", true); }); it("should include mutableMessages in JSON output when updating", async () => { @@ -197,9 +199,15 @@ describe("apps:rules:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); expect(result).toHaveProperty("success", true); - expect(result.rule).toHaveProperty("persisted", true); + expect(result!.rule).toHaveProperty("persisted", true); }); }); diff --git a/test/unit/commands/apps/switch.test.ts b/test/unit/commands/apps/switch.test.ts index 1e54a053..b9a583c3 100644 --- a/test/unit/commands/apps/switch.test.ts +++ b/test/unit/commands/apps/switch.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; import { nockControl, controlApiCleanup, @@ -52,14 +53,14 @@ describe("apps:switch command", () => { }, ]); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:switch", mockAppId], import.meta.url, ); - expect(stdout).toContain("Switched to app"); - expect(stdout).toContain(mockAppName); - expect(stdout).toContain(mockAppId); + expect(stderr).toContain("Switched to app"); + expect(stderr).toContain(mockAppName); + expect(stderr).toContain(mockAppId); }); it("should output JSON when --json flag is used", async () => { @@ -89,7 +90,7 @@ describe("apps:switch command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "apps:switch"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/apps/update.test.ts b/test/unit/commands/apps/update.test.ts index daf1bf4d..b09edbd6 100644 --- a/test/unit/commands/apps/update.test.ts +++ b/test/unit/commands/apps/update.test.ts @@ -48,12 +48,12 @@ describe("apps:update command", () => { tlsOnly: false, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:update", appId, "--name", updatedName], import.meta.url, ); - expect(stdout).toContain("App updated successfully"); + expect(stderr).toContain("App updated successfully"); expect(stdout).toContain(appId); expect(stdout).toContain(updatedName); }); @@ -78,12 +78,12 @@ describe("apps:update command", () => { tlsOnly: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:update", appId, "--tls-only"], import.meta.url, ); - expect(stdout).toContain("App updated successfully"); + expect(stderr).toContain("App updated successfully"); expect(stdout).toContain("TLS Only: Yes"); }); @@ -109,12 +109,12 @@ describe("apps:update command", () => { tlsOnly: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["apps:update", appId, "--name", updatedName, "--tls-only"], import.meta.url, ); - expect(stdout).toContain("App updated successfully"); + expect(stderr).toContain("App updated successfully"); expect(stdout).toContain(updatedName); expect(stdout).toContain("TLS Only: Yes"); }); @@ -143,13 +143,19 @@ describe("apps:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "apps:update"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("app"); - expect(result.app).toHaveProperty("id", appId); - expect(result.app).toHaveProperty("name", updatedName); + expect(result!.app).toHaveProperty("id", appId); + expect(result!.app).toHaveProperty("name", updatedName); }); it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { @@ -178,12 +184,12 @@ describe("apps:update command", () => { tlsOnly: false, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["apps:update", appId, "--name", updatedName], import.meta.url, ); - expect(stdout).toContain("App updated successfully"); + expect(stderr).toContain("App updated successfully"); }); }); @@ -244,8 +250,14 @@ describe("apps:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "error"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "error", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "apps:update"); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); @@ -312,8 +324,14 @@ describe("apps:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "error"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "error", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "apps:update"); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); @@ -369,11 +387,20 @@ describe("apps:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "apps:update"); expect(result).toHaveProperty("success", true); - expect(result.app).toHaveProperty("apnsUsesSandboxCert", false); + expect((result as Record).app).toHaveProperty( + "apnsUsesSandboxCert", + false, + ); }); }); }); diff --git a/test/unit/commands/auth/issue-ably-token.test.ts b/test/unit/commands/auth/issue-ably-token.test.ts index ea06f457..4390dac6 100644 --- a/test/unit/commands/auth/issue-ably-token.test.ts +++ b/test/unit/commands/auth/issue-ably-token.test.ts @@ -7,6 +7,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("auth:issue-ably-token command", () => { beforeEach(() => { @@ -189,7 +190,7 @@ describe("auth:issue-ably-token command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("token"); expect(result.token).toHaveProperty("capability"); }); diff --git a/test/unit/commands/auth/issue-jwt-token.test.ts b/test/unit/commands/auth/issue-jwt-token.test.ts index b79aa189..5790ca33 100644 --- a/test/unit/commands/auth/issue-jwt-token.test.ts +++ b/test/unit/commands/auth/issue-jwt-token.test.ts @@ -7,6 +7,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("auth:issue-jwt-token command", () => { describe("functionality", () => { @@ -147,7 +148,7 @@ describe("auth:issue-jwt-token command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "auth:issue-jwt-token"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/auth/keys/create.test.ts b/test/unit/commands/auth/keys/create.test.ts index 9770cf0c..689e45bc 100644 --- a/test/unit/commands/auth/keys/create.test.ts +++ b/test/unit/commands/auth/keys/create.test.ts @@ -53,12 +53,12 @@ describe("auth:keys:create command", () => { revocable: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["auth:keys:create", "--name", `"${mockKeyName}"`, "--app", appId], import.meta.url, ); - expect(stdout).toContain("Key created:"); + expect(stderr).toContain("Key created:"); expect(stdout).toContain(mockKeyName); expect(stdout).toContain(mockKeyId); }); @@ -90,7 +90,7 @@ describe("auth:keys:create command", () => { revocable: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "auth:keys:create", "--name", @@ -103,7 +103,7 @@ describe("auth:keys:create command", () => { import.meta.url, ); - expect(stdout).toContain("Key created:"); + expect(stderr).toContain("Key created:"); expect(stdout).toContain("channel1"); expect(stdout).toContain("publish"); expect(stdout).toContain("subscribe"); @@ -139,8 +139,14 @@ describe("auth:keys:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ) as Record; + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "auth:keys:create"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("key"); @@ -181,12 +187,12 @@ describe("auth:keys:create command", () => { revocable: true, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["auth:keys:create", "--name", `"${mockKeyName}"`, "--app", appId], import.meta.url, ); - expect(stdout).toContain("Key created:"); + expect(stderr).toContain("Key created:"); }); }); @@ -397,7 +403,7 @@ describe("auth:keys:create command", () => { revocable: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "auth:keys:create", "--name", @@ -410,7 +416,7 @@ describe("auth:keys:create command", () => { import.meta.url, ); - expect(stdout).toContain("Key created:"); + expect(stderr).toContain("Key created:"); expect(stdout).toContain("publish"); }); @@ -441,7 +447,7 @@ describe("auth:keys:create command", () => { revocable: true, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "auth:keys:create", "--name", @@ -454,7 +460,7 @@ describe("auth:keys:create command", () => { import.meta.url, ); - expect(stdout).toContain("Key created:"); + expect(stderr).toContain("Key created:"); expect(stdout).toContain("chat-*"); expect(stdout).toContain("subscribe"); expect(stdout).toContain("updates"); diff --git a/test/unit/commands/auth/keys/current.test.ts b/test/unit/commands/auth/keys/current.test.ts index b22d0d06..1fe0e85f 100644 --- a/test/unit/commands/auth/keys/current.test.ts +++ b/test/unit/commands/auth/keys/current.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; import { mockAppResolution, controlApiCleanup, @@ -50,7 +51,7 @@ describe("auth:keys:current command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "auth:keys:current"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/auth/keys/get.test.ts b/test/unit/commands/auth/keys/get.test.ts index a04487c3..0fddc12a 100644 --- a/test/unit/commands/auth/keys/get.test.ts +++ b/test/unit/commands/auth/keys/get.test.ts @@ -16,6 +16,7 @@ import { standardFlagTests, standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("auth:keys:get command", () => { const mockKeyId = "testkey"; @@ -124,7 +125,8 @@ describe("auth:keys:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("key"); expect(result.key).toHaveProperty("id", mockKeyId); @@ -215,7 +217,7 @@ describe("auth:keys:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result.key).toHaveProperty("envKeyOverride"); expect(result.key.envKeyOverride).toHaveProperty( "keyName", diff --git a/test/unit/commands/auth/keys/list.test.ts b/test/unit/commands/auth/keys/list.test.ts index 66f8f9a0..75900dc3 100644 --- a/test/unit/commands/auth/keys/list.test.ts +++ b/test/unit/commands/auth/keys/list.test.ts @@ -7,6 +7,7 @@ import { getControlApiContext, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -151,7 +152,7 @@ describe("auth:keys:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "auth:keys:list"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/auth/keys/revoke.test.ts b/test/unit/commands/auth/keys/revoke.test.ts index b62dcec1..8cf3766b 100644 --- a/test/unit/commands/auth/keys/revoke.test.ts +++ b/test/unit/commands/auth/keys/revoke.test.ts @@ -16,6 +16,7 @@ import { standardFlagTests, standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; describe("auth:keys:revoke command", () => { const mockKeyId = "testkey"; @@ -84,7 +85,7 @@ describe("auth:keys:revoke command", () => { ); // The JSON output should be parseable - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "auth:keys:revoke"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/auth/keys/switch.test.ts b/test/unit/commands/auth/keys/switch.test.ts index 87a14702..c2e4d384 100644 --- a/test/unit/commands/auth/keys/switch.test.ts +++ b/test/unit/commands/auth/keys/switch.test.ts @@ -47,13 +47,13 @@ describe("auth:keys:switch command", () => { }, ]); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["auth:keys:switch", `${appId}.${mockKeyId}`], import.meta.url, ); - expect(stdout).toContain("Switched to key"); - expect(stdout).toContain(`${appId}.${mockKeyId}`); + expect(stderr).toContain("Switched to key"); + expect(stderr).toContain(`${appId}.${mockKeyId}`); }); it("should output JSON when --json flag is used", async () => { @@ -79,8 +79,14 @@ describe("auth:keys:switch command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ) as Record; + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "auth:keys:switch"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("key"); diff --git a/test/unit/commands/auth/keys/update.test.ts b/test/unit/commands/auth/keys/update.test.ts index cdd8a18f..1dbe058f 100644 --- a/test/unit/commands/auth/keys/update.test.ts +++ b/test/unit/commands/auth/keys/update.test.ts @@ -6,6 +6,7 @@ import { mockAppResolution, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; import { mockKeysList, buildMockKey, @@ -116,7 +117,7 @@ describe("auth:keys:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "auth:keys:update"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/auth/revoke-token.test.ts b/test/unit/commands/auth/revoke-token.test.ts index 3a68c01c..82dc2dde 100644 --- a/test/unit/commands/auth/revoke-token.test.ts +++ b/test/unit/commands/auth/revoke-token.test.ts @@ -8,6 +8,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; describe("auth:revoke-token command", () => { const mockToken = "test-token-12345"; @@ -40,12 +41,12 @@ describe("auth:revoke-token command", () => { }) .reply(200, {}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["auth:revoke-token", mockToken, "--client-id", mockClientId], import.meta.url, ); - expect(stdout).toContain("Token successfully revoked"); + expect(stderr).toContain("Token successfully revoked"); }); it("should use token as client-id when --client-id not provided", async () => { @@ -58,7 +59,7 @@ describe("auth:revoke-token command", () => { }) .reply(200, {}); - const { stdout, stderr } = await runCommand( + const { stderr } = await runCommand( ["auth:revoke-token", mockToken], import.meta.url, ); @@ -68,7 +69,7 @@ describe("auth:revoke-token command", () => { "Revoking a specific token is only possible if it has a client ID", ); expect(stderr).toContain("Using the token argument as a client ID"); - expect(stdout).toContain("Token successfully revoked"); + expect(stderr).toContain("Token successfully revoked"); }); it("should output JSON format when --json flag is used", async () => { @@ -85,7 +86,7 @@ describe("auth:revoke-token command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("revocation"); expect(result.revocation).toHaveProperty( diff --git a/test/unit/commands/bench/publisher.test.ts b/test/unit/commands/bench/publisher.test.ts index 8a176f1b..10eb1947 100644 --- a/test/unit/commands/bench/publisher.test.ts +++ b/test/unit/commands/bench/publisher.test.ts @@ -81,8 +81,14 @@ describe("bench:publisher command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "bench:publisher"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("benchmark"); diff --git a/test/unit/commands/bench/subscriber.test.ts b/test/unit/commands/bench/subscriber.test.ts index ea4f3f7d..5cfc5420 100644 --- a/test/unit/commands/bench/subscriber.test.ts +++ b/test/unit/commands/bench/subscriber.test.ts @@ -59,33 +59,43 @@ describe("bench:subscriber command", () => { describe("functionality", () => { it("should subscribe to the specified channel with duration flag", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["bench:subscriber", "test-channel"], import.meta.url, ); // Should show subscription message - expect(stdout).toContain("test-channel"); + expect(stderr).toContain("test-channel"); }, 10_000); it("should output subscription status", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["bench:subscriber", "test-channel"], import.meta.url, ); - expect(stdout).toContain("Subscribed to channel"); + expect(stderr).toContain("Subscribed to channel"); }, 10_000); - it("should suppress human-readable output when --json flag is used", async () => { + it("should emit JSON status records instead of human-readable output when --json flag is used", async () => { const { stdout } = await runCommand( ["bench:subscriber", "test-channel", "--json"], import.meta.url, ); - // In JSON mode, human-readable progress/success messages are suppressed - expect(stdout).not.toContain("Subscribed to channel"); - expect(stdout).not.toContain("Attaching to channel"); + // In JSON mode, progress/success helpers emit JSON status records (not formatted text) + expect(stdout).not.toContain("✓"); + const lines = stdout.trim().split("\n").filter(Boolean); + const statusLines = lines + .map((l: string) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter((r: Record | null) => r?.type === "status"); + expect(statusLines.length).toBeGreaterThan(0); }, 10_000); }); diff --git a/test/unit/commands/channels/annotations/delete.test.ts b/test/unit/commands/channels/annotations/delete.test.ts index b191dcf9..1b9251af 100644 --- a/test/unit/commands/channels/annotations/delete.test.ts +++ b/test/unit/commands/channels/annotations/delete.test.ts @@ -36,7 +36,7 @@ describe("channels:annotations:delete command", () => { const mock = getMockAblyRealtime(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:delete", "test-channel", @@ -53,7 +53,7 @@ describe("channels:annotations:delete command", () => { type: "reactions:flag.v1", }, ); - expect(stdout).toContain("Annotation deleted"); + expect(stderr).toContain("Annotation deleted"); }); it("should pass --name flag to annotation", async () => { @@ -82,7 +82,7 @@ describe("channels:annotations:delete command", () => { const mock = getMockAblyRealtime(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:delete", "test-channel", @@ -98,7 +98,7 @@ describe("channels:annotations:delete command", () => { type: "reactions:multiple.v1", name: "thumbsup", }); - expect(stdout).toContain("Annotation deleted"); + expect(stderr).toContain("Annotation deleted"); }); it("should output JSON when --json flag is used", async () => { @@ -116,7 +116,10 @@ describe("channels:annotations:delete command", () => { }); expect(records.length).toBeGreaterThanOrEqual(1); - const result = records[0]; + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "channels:annotations:delete"); expect(result).toHaveProperty("success", true); @@ -125,8 +128,8 @@ describe("channels:annotations:delete command", () => { expect(result.annotation).toHaveProperty("serial", "serial-001"); }); - it("should show Type and Name labels in non-JSON output", async () => { - const { stdout } = await runCommand( + it("should show success message in non-JSON output", async () => { + const { stderr } = await runCommand( [ "channels:annotations:delete", "test-channel", @@ -138,10 +141,9 @@ describe("channels:annotations:delete command", () => { import.meta.url, ); - expect(stdout).toContain("Type"); - expect(stdout).toContain("reactions:distinct.v1"); - expect(stdout).toContain("Name"); - expect(stdout).toContain("thumbsup"); + expect(stderr).toContain("Annotation deleted"); + expect(stderr).toContain("serial-001"); + expect(stderr).toContain("test-channel"); }); it("should not include name in JSON output when not provided", async () => { diff --git a/test/unit/commands/channels/annotations/get.test.ts b/test/unit/commands/channels/annotations/get.test.ts index 3d457a3c..911f1b47 100644 --- a/test/unit/commands/channels/annotations/get.test.ts +++ b/test/unit/commands/channels/annotations/get.test.ts @@ -120,14 +120,19 @@ describe("channels:annotations:get command", () => { }); expect(records.length).toBeGreaterThanOrEqual(1); - const result = records[0]; + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "channels:annotations:get"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("channel", "test-channel"); expect(result).toHaveProperty("serial", "serial-001"); expect(result).toHaveProperty("annotations"); - expect(result.annotations as unknown[]).toHaveLength(1); + expect( + (result as Record).annotations as unknown[], + ).toHaveLength(1); }); it("should display annotation details in human-readable output", async () => { diff --git a/test/unit/commands/channels/annotations/publish.test.ts b/test/unit/commands/channels/annotations/publish.test.ts index 6b4543d2..83d8d7d0 100644 --- a/test/unit/commands/channels/annotations/publish.test.ts +++ b/test/unit/commands/channels/annotations/publish.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("channels:annotations:publish command", () => { beforeEach(() => { @@ -29,7 +30,7 @@ describe("channels:annotations:publish command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:publish", "test-channel", @@ -46,7 +47,7 @@ describe("channels:annotations:publish command", () => { type: "reactions:flag.v1", }, ); - expect(stdout).toContain("Annotation published"); + expect(stderr).toContain("Annotation published"); }); it("should pass --name flag to annotation", async () => { @@ -166,7 +167,7 @@ describe("channels:annotations:publish command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:publish", "test-channel", @@ -180,14 +181,14 @@ describe("channels:annotations:publish command", () => { "serial-001", { type: "reactions:total.v1" }, ); - expect(stdout).toContain("Annotation published"); + expect(stderr).toContain("Annotation published"); }); it("should succeed with distinct.v1 type and --name", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:publish", "test-channel", @@ -203,14 +204,14 @@ describe("channels:annotations:publish command", () => { type: "reactions:distinct.v1", name: "thumbsup", }); - expect(stdout).toContain("Annotation published"); + expect(stderr).toContain("Annotation published"); }); it("should succeed with unique.v1 type and --name", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:publish", "test-channel", @@ -226,14 +227,14 @@ describe("channels:annotations:publish command", () => { type: "reactions:unique.v1", name: "thumbsup", }); - expect(stdout).toContain("Annotation published"); + expect(stderr).toContain("Annotation published"); }); it("should succeed with multiple.v1 type and --name only (count defaults to 1)", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:annotations:publish", "test-channel", @@ -249,7 +250,7 @@ describe("channels:annotations:publish command", () => { type: "reactions:multiple.v1", name: "thumbsup", }); - expect(stdout).toContain("Annotation published"); + expect(stderr).toContain("Annotation published"); }); it("should output JSON when --json flag is used", async () => { @@ -264,7 +265,8 @@ describe("channels:annotations:publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "channels:annotations:publish"); expect(result).toHaveProperty("success", true); @@ -285,7 +287,7 @@ describe("channels:annotations:publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result.annotation).not.toHaveProperty("name"); expect(result.annotation).not.toHaveProperty("count"); expect(result.annotation).not.toHaveProperty("data"); @@ -312,7 +314,7 @@ describe("channels:annotations:publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result.annotation).toHaveProperty("name", "thumbsup"); expect(result.annotation).toHaveProperty("count", 3); expect(result.annotation).toHaveProperty("data", "test-data"); @@ -333,12 +335,12 @@ describe("channels:annotations:publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result.annotation.data).toEqual({ foo: "bar" }); }); - it("should show data and encoding in non-JSON output when provided", async () => { - const { stdout } = await runCommand( + it("should show success message in non-JSON output when data and encoding are provided", async () => { + const { stderr } = await runCommand( [ "channels:annotations:publish", "test-channel", @@ -352,10 +354,9 @@ describe("channels:annotations:publish command", () => { import.meta.url, ); - expect(stdout).toContain("Data"); - expect(stdout).toContain("test-data"); - expect(stdout).toContain("Encoding"); - expect(stdout).toContain("utf8"); + expect(stderr).toContain("Annotation published"); + expect(stderr).toContain("serial-001"); + expect(stderr).toContain("test-channel"); }); }); diff --git a/test/unit/commands/channels/annotations/subscribe.test.ts b/test/unit/commands/channels/annotations/subscribe.test.ts index e8461365..16e54b2d 100644 --- a/test/unit/commands/channels/annotations/subscribe.test.ts +++ b/test/unit/commands/channels/annotations/subscribe.test.ts @@ -63,12 +63,12 @@ describe("channels:annotations:subscribe command", () => { it("should subscribe to annotations on a channel", async () => { const mock = getMockAblyRealtime(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:annotations:subscribe", "test-channel"], import.meta.url, ); - expect(stdout).toContain("test-channel"); + expect(stderr).toContain("test-channel"); expect(mock.channels.get).toHaveBeenCalledWith( "test-channel", expect.objectContaining({ diff --git a/test/unit/commands/channels/append.test.ts b/test/unit/commands/channels/append.test.ts index d3bced00..e9a63102 100644 --- a/test/unit/commands/channels/append.test.ts +++ b/test/unit/commands/channels/append.test.ts @@ -28,7 +28,7 @@ describe("channels:append command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:append", "test-channel", @@ -44,7 +44,7 @@ describe("channels:append command", () => { serial: "serial-001", data: "appended", }); - expect(stdout).toContain("Appended"); + expect(stderr).toContain("Appended"); }); it("should append with plain text", async () => { @@ -173,7 +173,13 @@ describe("channels:append command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "channels:append"); expect(result).toHaveProperty("success", true); @@ -191,12 +197,12 @@ describe("channels:append command", () => { const channel = mock.channels._getChannel("test-channel"); channel.appendMessage.mockResolvedValue({ versionSerial: null }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:append", "test-channel", "serial-001", '{"data":"hello"}'], import.meta.url, ); - expect(stdout).toContain("superseded"); + expect(stderr).toContain("superseded"); }); it("should display version serial in human-readable output", async () => { diff --git a/test/unit/commands/channels/batch-publish.test.ts b/test/unit/commands/channels/batch-publish.test.ts index 371173a9..21292966 100644 --- a/test/unit/commands/channels/batch-publish.test.ts +++ b/test/unit/commands/channels/batch-publish.test.ts @@ -61,7 +61,7 @@ describe("channels:batch-publish command", () => { it("should publish to multiple channels using --channels flag", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:batch-publish", "--channels", @@ -71,8 +71,8 @@ describe("channels:batch-publish command", () => { import.meta.url, ); - expect(stdout).toContain("Sending batch publish request"); - expect(stdout).toContain("Batch publish successful"); + expect(stderr).toContain("Sending batch publish request"); + expect(stderr).toContain("Batch publish successful"); expect(mock.request).toHaveBeenCalledWith( "post", "/messages", @@ -88,7 +88,7 @@ describe("channels:batch-publish command", () => { it("should publish using --channels-json flag", async () => { const mock = getMockAblyRest(); - const { stdout, error } = await runCommand( + const { stderr, error } = await runCommand( [ "channels:batch-publish", "--channels-json", @@ -99,8 +99,8 @@ describe("channels:batch-publish command", () => { ); expect(error).toBeUndefined(); - expect(stdout).toContain("Sending batch publish request"); - expect(stdout).toContain("Batch publish successful"); + expect(stderr).toContain("Sending batch publish request"); + expect(stderr).toContain("Batch publish successful"); expect(mock.request).toHaveBeenCalledWith( "post", "/messages", @@ -203,8 +203,13 @@ describe("channels:batch-publish command", () => { expect(error).toBeUndefined(); - // In JSON mode, progress messages are suppressed by JSON guard - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("publish"); expect(result.publish).toHaveProperty("channels"); @@ -241,7 +246,7 @@ describe("channels:batch-publish command", () => { }, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "channels:batch-publish", "--channels", @@ -253,9 +258,9 @@ describe("channels:batch-publish command", () => { expect(stdout).toContain("partially successful"); // Verify successful channel output (resource() uses cyan, not quotes) - expect(stdout).toContain("Published to channel"); - expect(stdout).toContain("channel1"); - expect(stdout).toContain("msg-1"); + expect(stderr).toContain("Published to channel"); + expect(stderr).toContain("channel1"); + expect(stderr).toContain("msg-1"); // Verify failed channel output with error message and code expect(stdout).toContain("Failed to publish to channel"); expect(stdout).toContain("channel2"); @@ -322,8 +327,14 @@ describe("channels:batch-publish command", () => { // In JSON mode, errors are output as JSON and the command exits with code 1 expect(error).toBeDefined(); - // In JSON mode, progress messages are suppressed by JSON guard - const result = JSON.parse(stdout); + // Parse NDJSON output — find the error record + const records = stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.success === false); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("Network error"); diff --git a/test/unit/commands/channels/delete.test.ts b/test/unit/commands/channels/delete.test.ts index 25aeb80b..a3da0fda 100644 --- a/test/unit/commands/channels/delete.test.ts +++ b/test/unit/commands/channels/delete.test.ts @@ -26,7 +26,7 @@ describe("channels:delete command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:delete", "test-channel", "serial-001"], import.meta.url, ); @@ -36,7 +36,7 @@ describe("channels:delete command", () => { expect(channel.deleteMessage.mock.calls[0][0]).toEqual({ serial: "serial-001", }); - expect(stdout).toContain("deleted"); + expect(stderr).toContain("deleted"); }); it("should pass description as operation metadata", async () => { @@ -78,7 +78,13 @@ describe("channels:delete command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "channels:delete"); expect(result).toHaveProperty("success", true); @@ -96,12 +102,12 @@ describe("channels:delete command", () => { const channel = mock.channels._getChannel("test-channel"); channel.deleteMessage.mockResolvedValue({ versionSerial: null }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:delete", "test-channel", "serial-001"], import.meta.url, ); - expect(stdout).toContain("superseded"); + expect(stderr).toContain("superseded"); }); it("should display version serial in human-readable output", async () => { diff --git a/test/unit/commands/channels/list.test.ts b/test/unit/commands/channels/list.test.ts index 30dbf269..920058d9 100644 --- a/test/unit/commands/channels/list.test.ts +++ b/test/unit/commands/channels/list.test.ts @@ -9,6 +9,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("channels:list command", () => { // Mock channel response data @@ -117,7 +118,7 @@ describe("channels:list command", () => { ); // Parse the JSON that was output - const jsonOutput = JSON.parse(stdout); + const jsonOutput = parseJsonOutput(stdout); // Verify the structure of the JSON output expect(jsonOutput).toHaveProperty("channels"); @@ -131,6 +132,19 @@ describe("channels:list command", () => { expect(jsonOutput).toHaveProperty("timestamp"); }); + it("should output channel IDs as strings in JSON output", async () => { + const { stdout } = await runCommand( + ["channels:list", "--json"], + import.meta.url, + ); + + const jsonOutput = parseJsonOutput(stdout); + + // Channels are output as string IDs (channelId extracted from each item) + expect(typeof jsonOutput.channels[0]).toBe("string"); + expect(jsonOutput.channels[0]).toBe("test-channel-1"); + }); + it("should handle API errors in JSON mode", async () => { const mock = getMockAblyRest(); mock.request.mockRejectedValue(new Error("Network error")); @@ -140,7 +154,7 @@ describe("channels:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("Network error"); diff --git a/test/unit/commands/channels/occupancy/get.test.ts b/test/unit/commands/channels/occupancy/get.test.ts index fd3662a0..22175989 100644 --- a/test/unit/commands/channels/occupancy/get.test.ts +++ b/test/unit/commands/channels/occupancy/get.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("ChannelsOccupancyGet", function () { beforeEach(function () { @@ -74,7 +75,7 @@ describe("ChannelsOccupancyGet", function () { expect(mock.request).toHaveBeenCalledOnce(); // Parse and verify the JSON output - const parsedOutput = JSON.parse(stdout.trim()); + const parsedOutput = parseJsonOutput(stdout); expect(parsedOutput).toHaveProperty("success", true); expect(parsedOutput).toHaveProperty("occupancy"); expect(parsedOutput.occupancy).toHaveProperty( diff --git a/test/unit/commands/channels/occupancy/subscribe.test.ts b/test/unit/commands/channels/occupancy/subscribe.test.ts index bf3d948e..3d660ffc 100644 --- a/test/unit/commands/channels/occupancy/subscribe.test.ts +++ b/test/unit/commands/channels/occupancy/subscribe.test.ts @@ -43,13 +43,13 @@ describe("channels:occupancy:subscribe command", () => { it("should subscribe to occupancy events and show initial message", async () => { const mock = getMockAblyRealtime(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:occupancy:subscribe", "test-channel"], import.meta.url, ); - expect(stdout).toContain("Subscribing to occupancy events on channel"); - expect(stdout).toContain("test-channel"); + expect(stderr).toContain("Subscribing to occupancy events on channel"); + expect(stderr).toContain("test-channel"); expect(mock.channels.get).toHaveBeenCalledWith("test-channel", { params: { occupancy: "metrics" }, }); diff --git a/test/unit/commands/channels/presence/enter.test.ts b/test/unit/commands/channels/presence/enter.test.ts index adb1e175..935fa909 100644 --- a/test/unit/commands/channels/presence/enter.test.ts +++ b/test/unit/commands/channels/presence/enter.test.ts @@ -53,15 +53,15 @@ describe("channels:presence:enter command", () => { const mock = getMockAblyRealtime(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:presence:enter", "test-channel"], import.meta.url, ); // Should show progress and successful entry - expect(stdout).toContain("Entering presence on channel"); - expect(stdout).toContain("test-channel"); - expect(stdout).toContain("Entered"); + expect(stderr).toContain("Entering presence on channel"); + expect(stderr).toContain("test-channel"); + expect(stderr).toContain("Entered"); // Verify presence.enter was called expect(channel.presence.enter).toHaveBeenCalled(); }); @@ -70,7 +70,7 @@ describe("channels:presence:enter command", () => { const mock = getMockAblyRealtime(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:presence:enter", "test-channel", @@ -80,7 +80,7 @@ describe("channels:presence:enter command", () => { import.meta.url, ); - expect(stdout).toContain("Entered"); + expect(stderr).toContain("Entered"); // Verify presence.enter was called with the data expect(channel.presence.enter).toHaveBeenCalledWith({ status: "online", @@ -141,10 +141,9 @@ describe("channels:presence:enter command", () => { // Parse JSON lines const lines = stdout.trim().split("\n").filter(Boolean); - expect(lines.length).toBeGreaterThanOrEqual(1); - - const result = JSON.parse(lines[0]); - expect(result.type).toBe("result"); + const parsed = lines.map((l) => JSON.parse(l)); + const result = parsed.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result.presenceMessage).toBeDefined(); expect(result.presenceMessage.action).toBe("enter"); expect(result.presenceMessage.channel).toBe("test-channel"); @@ -159,11 +158,11 @@ describe("channels:presence:enter command", () => { ); const lines = stdout.trim().split("\n").filter(Boolean); - expect(lines.length).toBeGreaterThanOrEqual(2); - - const status = JSON.parse(lines[1]); - expect(status.type).toBe("status"); - expect(status.status).toBe("holding"); + const parsed = lines.map((l) => JSON.parse(l)); + const status = parsed.find( + (r) => r.type === "status" && r.status === "holding", + ); + expect(status).toBeDefined(); expect(status.message).toContain("Holding presence"); }); @@ -182,7 +181,7 @@ describe("channels:presence:enter command", () => { const mock = getMockAblyRealtime(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:presence:enter", "test-channel"], import.meta.url, ); @@ -190,26 +189,26 @@ describe("channels:presence:enter command", () => { // Without --show-others, the command should not subscribe to presence events expect(channel.presence.subscribe).not.toHaveBeenCalled(); // But should still show entry confirmation - expect(stdout).toContain("Entered"); - expect(stdout).toContain("test-channel"); + expect(stderr).toContain("Entered"); + expect(stderr).toContain("test-channel"); }); it("should show holding message without --show-others", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:presence:enter", "test-channel"], import.meta.url, ); - expect(stdout).toContain("Holding presence"); + expect(stderr).toContain("Holding presence"); }); it("should show listening message with --show-others", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:presence:enter", "test-channel", "--show-others"], import.meta.url, ); - expect(stdout).toContain("Listening for presence events"); + expect(stderr).toContain("Listening for presence events"); }); }); diff --git a/test/unit/commands/channels/presence/get.test.ts b/test/unit/commands/channels/presence/get.test.ts index fb19e51c..db6e1a65 100644 --- a/test/unit/commands/channels/presence/get.test.ts +++ b/test/unit/commands/channels/presence/get.test.ts @@ -56,13 +56,13 @@ describe("channels:presence:get command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["channels:presence:get", "test-channel"], import.meta.url, ); expect(channel.presence.get).toHaveBeenCalledWith({ limit: 100 }); - expect(stdout).toContain("Fetching presence members"); + expect(stderr).toContain("Fetching presence members"); expect(stdout).toContain("test-channel"); expect(stdout).toContain("client-1"); expect(stdout).toContain("client-2"); @@ -88,7 +88,16 @@ describe("channels:presence:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout.trim()); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result") as Record< + string, + unknown + >; + expect(result).toBeDefined(); expect(result.type).toBe("result"); expect(result.members).toBeDefined(); expect(result.members).toHaveLength(2); @@ -148,10 +157,21 @@ describe("channels:presence:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout.trim()); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result") as Record< + string, + unknown + >; + expect(result).toBeDefined(); expect(result.hasMore).toBe(true); expect(result.next).toBeDefined(); - expect(result.next.hint).toContain("--limit"); + expect((result.next as Record).hint).toContain( + "--limit", + ); }); }); diff --git a/test/unit/commands/channels/presence/subscribe.test.ts b/test/unit/commands/channels/presence/subscribe.test.ts index 0d20210f..57846e38 100644 --- a/test/unit/commands/channels/presence/subscribe.test.ts +++ b/test/unit/commands/channels/presence/subscribe.test.ts @@ -55,14 +55,14 @@ describe("channels:presence:subscribe command", () => { const mock = getMockAblyRealtime(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:presence:subscribe", "test-channel"], import.meta.url, ); // Should show subscription message - expect(stdout).toContain("test-channel"); - expect(stdout).toContain("presence"); + expect(stderr).toContain("test-channel"); + expect(stderr).toContain("presence"); // Verify presence.subscribe was called expect(channel.presence.subscribe).toHaveBeenCalled(); }); diff --git a/test/unit/commands/channels/publish.test.ts b/test/unit/commands/channels/publish.test.ts index 86dbf075..c816e3bb 100644 --- a/test/unit/commands/channels/publish.test.ts +++ b/test/unit/commands/channels/publish.test.ts @@ -29,7 +29,7 @@ describe("ChannelsPublish", function () { const restMock = getMockAblyRest(); const channel = restMock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:publish", "test-channel", @@ -43,14 +43,14 @@ describe("ChannelsPublish", function () { expect(restMock.channels.get).toHaveBeenCalledWith("test-channel"); expect(channel.publish).toHaveBeenCalledOnce(); expect(channel.publish.mock.calls[0][0]).toEqual({ data: "hello" }); - expect(stdout).toContain("Message published to channel"); + expect(stderr).toContain("Message published to channel"); }); it("should publish a message using Realtime successfully", async function () { const realtimeMock = getMockAblyRealtime(); const channel = realtimeMock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:publish", "test-channel", @@ -66,7 +66,7 @@ describe("ChannelsPublish", function () { expect(channel.publish.mock.calls[0][0]).toEqual({ data: "realtime hello", }); - expect(stdout).toContain("Message published to channel"); + expect(stderr).toContain("Message published to channel"); }); it("should publish with specified event name", async function () { @@ -96,7 +96,7 @@ describe("ChannelsPublish", function () { const restMock = getMockAblyRest(); const channel = restMock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:publish", "test-channel", @@ -112,7 +112,7 @@ describe("ChannelsPublish", function () { ); expect(channel.publish).toHaveBeenCalledTimes(3); - expect(stdout).toContain("messages published to channel"); + expect(stderr).toContain("messages published to channel"); }); it("should output JSON when requested", async function () { @@ -131,8 +131,13 @@ describe("ChannelsPublish", function () { import.meta.url, ); - // Parse the JSON output - const jsonOutput = JSON.parse(stdout.trim()); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const jsonOutput = records.find((r) => r.type === "result"); + expect(jsonOutput).toBeDefined(); expect(jsonOutput).toHaveProperty("type", "result"); expect(jsonOutput).toHaveProperty("command", "channels:publish"); expect(jsonOutput).toHaveProperty("success", true); @@ -522,7 +527,7 @@ describe("ChannelsPublish", function () { publishedData.push(message.data ?? ""); }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "channels:publish", "test-channel", @@ -540,9 +545,9 @@ describe("ChannelsPublish", function () { // Should have attempted all 5, but only 4 succeeded expect(channel.publish).toHaveBeenCalledTimes(5); expect(publishedData).toHaveLength(4); - expect(stdout).toContain("4/5"); - expect(stdout).toContain("1"); - expect(stdout).toMatch(/error/i); + expect(stderr).toContain("4/5"); + expect(stderr).toContain("1"); + expect(stderr).toMatch(/error/i); }); it("should reject --count 0", async function () { diff --git a/test/unit/commands/channels/update.test.ts b/test/unit/commands/channels/update.test.ts index dd53eda1..7aba6b6c 100644 --- a/test/unit/commands/channels/update.test.ts +++ b/test/unit/commands/channels/update.test.ts @@ -28,7 +28,7 @@ describe("channels:update command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:update", "test-channel", "serial-001", '{"data":"updated"}'], import.meta.url, ); @@ -39,7 +39,7 @@ describe("channels:update command", () => { serial: "serial-001", data: "updated", }); - expect(stdout).toContain("updated"); + expect(stderr).toContain("updated"); }); it("should update a message with plain text", async () => { @@ -146,7 +146,13 @@ describe("channels:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "channels:update"); expect(result).toHaveProperty("success", true); @@ -164,12 +170,12 @@ describe("channels:update command", () => { const channel = mock.channels._getChannel("test-channel"); channel.updateMessage.mockResolvedValue({ versionSerial: null }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["channels:update", "test-channel", "serial-001", '{"data":"hello"}'], import.meta.url, ); - expect(stdout).toContain("superseded"); + expect(stderr).toContain("superseded"); }); it("should display version serial in human-readable output", async () => { diff --git a/test/unit/commands/config/path.test.ts b/test/unit/commands/config/path.test.ts index db97617b..ed52d0d3 100644 --- a/test/unit/commands/config/path.test.ts +++ b/test/unit/commands/config/path.test.ts @@ -5,6 +5,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("config:path command", () => { describe("functionality", () => { @@ -21,7 +22,7 @@ describe("config:path command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("config"); expect(result.config).toHaveProperty("path"); expect(result.config.path).toBe("/mock/config/path"); @@ -33,7 +34,7 @@ describe("config:path command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("config"); expect(result.config).toHaveProperty("path"); expect(result.config.path).toBe("/mock/config/path"); diff --git a/test/unit/commands/config/show.test.ts b/test/unit/commands/config/show.test.ts index a30388aa..6f6144ef 100644 --- a/test/unit/commands/config/show.test.ts +++ b/test/unit/commands/config/show.test.ts @@ -7,6 +7,7 @@ import { standardHelpTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("config:show command", () => { const mockAccessToken = "fake_access_token"; @@ -95,7 +96,7 @@ apiKey = "${mockApiKey}" import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "config:show"); expect(result).toHaveProperty("success", true); @@ -119,7 +120,7 @@ apiKey = "${mockApiKey}" // Pretty JSON should have newlines and indentation expect(stdout).toContain("\n"); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "config:show"); expect(result).toHaveProperty("success", true); @@ -133,7 +134,7 @@ apiKey = "${mockApiKey}" import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "config:show"); expect(result).toHaveProperty("success", true); @@ -156,7 +157,7 @@ apiKey = "${mockApiKey}" import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "config:show"); expect(result).toHaveProperty("success", true); @@ -185,7 +186,7 @@ apiKey = "${mockApiKey}" import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "error"); expect(result).toHaveProperty("command", "config:show"); expect(result).toHaveProperty("success", false); diff --git a/test/unit/commands/integrations/create.test.ts b/test/unit/commands/integrations/create.test.ts index ba11738a..e6328440 100644 --- a/test/unit/commands/integrations/create.test.ts +++ b/test/unit/commands/integrations/create.test.ts @@ -11,6 +11,7 @@ import { standardFlagTests, standardControlApiErrorTests, } from "../../../helpers/standard-tests.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; describe("integrations:create command", () => { const mockRuleId = "rule-123456"; @@ -40,7 +41,7 @@ describe("integrations:create command", () => { status: "enabled", }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "integrations:create", "--rule-type", @@ -55,7 +56,7 @@ describe("integrations:create command", () => { import.meta.url, ); - expect(stdout).toContain("Integration rule created:"); + expect(stderr).toContain("Integration rule created:"); expect(stdout).toContain(mockRuleId); expect(stdout).toContain("http"); }); @@ -81,7 +82,7 @@ describe("integrations:create command", () => { status: "enabled", }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "integrations:create", "--rule-type", @@ -92,7 +93,7 @@ describe("integrations:create command", () => { import.meta.url, ); - expect(stdout).toContain("Integration rule created:"); + expect(stderr).toContain("Integration rule created:"); expect(stdout).toContain("amqp"); }); @@ -135,7 +136,7 @@ describe("integrations:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "integrations:create"); expect(result).toHaveProperty("success", true); @@ -181,7 +182,7 @@ describe("integrations:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "integrations:create"); expect(result).toHaveProperty("success", true); @@ -226,7 +227,7 @@ describe("integrations:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "integrations:create"); expect(result).toHaveProperty("success", true); @@ -382,11 +383,13 @@ describe("integrations:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "integrations:create"); expect(result).toHaveProperty("success", true); - expect(result.integration.source.type).toBe("channel.presence"); + const integration = result.integration as Record; + const source = integration.source as Record; + expect(source.type).toBe("channel.presence"); }); it("should accept channel.lifecycle source type", async () => { @@ -424,11 +427,13 @@ describe("integrations:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "integrations:create"); expect(result).toHaveProperty("success", true); - expect(result.integration.source.type).toBe("channel.lifecycle"); + const integration = result.integration as Record; + const source = integration.source as Record; + expect(source.type).toBe("channel.lifecycle"); }); }); diff --git a/test/unit/commands/integrations/delete.test.ts b/test/unit/commands/integrations/delete.test.ts index 9d4658ad..6f0f44ec 100644 --- a/test/unit/commands/integrations/delete.test.ts +++ b/test/unit/commands/integrations/delete.test.ts @@ -49,13 +49,13 @@ describe("integrations:delete command", () => { // Mock DELETE endpoint nockControl().delete(`/v1/apps/${appId}/rules/${mockRuleId}`).reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["integrations:delete", mockRuleId, "--force"], import.meta.url, ); - expect(stdout).toContain("Integration rule deleted:"); - expect(stdout).toContain(mockRuleId); + expect(stderr).toContain("Integration rule deleted:"); + expect(stderr).toContain(mockRuleId); }); it("should display integration details before deletion with --force", async () => { @@ -216,12 +216,12 @@ describe("integrations:delete command", () => { nockControl().delete(`/v1/apps/${appId}/rules/${mockRuleId}`).reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["integrations:delete", mockRuleId, "-f"], import.meta.url, ); - expect(stdout).toContain("Integration rule deleted:"); + expect(stderr).toContain("Integration rule deleted:"); }); it("should accept --app flag", async () => { @@ -267,12 +267,12 @@ describe("integrations:delete command", () => { nockControl().delete(`/v1/apps/${appId}/rules/${mockRuleId}`).reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["integrations:delete", mockRuleId, "--app", appId, "--force"], import.meta.url, ); - expect(stdout).toContain("Integration rule deleted:"); + expect(stderr).toContain("Integration rule deleted:"); }); }); diff --git a/test/unit/commands/integrations/get.test.ts b/test/unit/commands/integrations/get.test.ts index 44a42510..329e7066 100644 --- a/test/unit/commands/integrations/get.test.ts +++ b/test/unit/commands/integrations/get.test.ts @@ -5,6 +5,7 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -88,7 +89,7 @@ describe("integrations:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("rule"); expect(result.rule).toHaveProperty("id", mockRuleId); expect(result.rule).toHaveProperty("appId", appId); @@ -136,7 +137,7 @@ describe("integrations:get command", () => { // Pretty JSON should have newlines expect(stdout).toContain("\n"); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("rule"); expect(result.rule).toHaveProperty("id", mockRuleId); }); diff --git a/test/unit/commands/integrations/list.test.ts b/test/unit/commands/integrations/list.test.ts index eae8c442..a47e6d7f 100644 --- a/test/unit/commands/integrations/list.test.ts +++ b/test/unit/commands/integrations/list.test.ts @@ -5,6 +5,7 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -75,7 +76,7 @@ describe("integrations:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "integrations:list"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts index b94b895e..cf1c3648 100644 --- a/test/unit/commands/integrations/update.test.ts +++ b/test/unit/commands/integrations/update.test.ts @@ -58,12 +58,12 @@ describe("integrations:update command", () => { .patch(`/v1/apps/${appId}/rules/${mockRuleId}`) .reply(200, updatedIntegration); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["integrations:update", mockRuleId, "--channel-filter", "messages:*"], import.meta.url, ); - expect(stdout).toContain("Integration rule updated."); + expect(stderr).toContain("Integration rule updated."); expect(stdout).toContain(mockRuleId); }); @@ -105,12 +105,12 @@ describe("integrations:update command", () => { .patch(`/v1/apps/${appId}/rules/${mockRuleId}`) .reply(200, updatedIntegration); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["integrations:update", mockRuleId, "--target-url", newUrl], import.meta.url, ); - expect(stdout).toContain("Integration rule updated."); + expect(stderr).toContain("Integration rule updated."); }); it("should output JSON format when --json flag is used", async () => { @@ -161,8 +161,14 @@ describe("integrations:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "integrations:update"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("rule"); @@ -220,11 +226,20 @@ describe("integrations:update command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("command", "integrations:update"); expect(result).toHaveProperty("success", true); - expect(result.rule).toHaveProperty("requestMode", "batch"); + expect((result as Record).rule).toHaveProperty( + "requestMode", + "batch", + ); }); }); @@ -377,7 +392,7 @@ describe("integrations:update command", () => { .patch(`/v1/apps/${appId}/rules/${mockRuleId}`) .reply(200, updatedIntegration); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "integrations:update", mockRuleId, @@ -389,7 +404,7 @@ describe("integrations:update command", () => { import.meta.url, ); - expect(stdout).toContain("Integration rule updated."); + expect(stderr).toContain("Integration rule updated."); }); }); diff --git a/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts b/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts index 15d5c628..c9837817 100644 --- a/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts +++ b/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts @@ -43,14 +43,14 @@ describe("logs:channel-lifecycle:subscribe command", () => { describe("functionality", () => { it("should subscribe to channel lifecycle events and show initial message", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["logs:channel-lifecycle:subscribe"], import.meta.url, ); - expect(stdout).toContain("Subscribed to"); - expect(stdout).toContain("[meta]channel.lifecycle"); - expect(stdout).toContain("Press Ctrl+C to exit"); + expect(stderr).toContain("Subscribed to"); + expect(stderr).toContain("[meta]channel.lifecycle"); + expect(stderr).toContain("Press Ctrl+C to exit"); }); it("should subscribe to channel messages", async () => { diff --git a/test/unit/commands/logs/connection-lifecycle/history.test.ts b/test/unit/commands/logs/connection-lifecycle/history.test.ts index d1331a34..98595368 100644 --- a/test/unit/commands/logs/connection-lifecycle/history.test.ts +++ b/test/unit/commands/logs/connection-lifecycle/history.test.ts @@ -4,6 +4,7 @@ import { getMockAblyRest, createMockPaginatedResult, } from "../../../../helpers/mock-ably-rest.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -62,7 +63,7 @@ describe("logs:connection-lifecycle:history command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("messages"); expect(Array.isArray(result.messages)).toBe(true); diff --git a/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts b/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts index ddc6b56c..538954f7 100644 --- a/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts +++ b/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts @@ -36,7 +36,7 @@ describe("LogsConnectionLifecycleSubscribe", function () { it("should subscribe to the meta connection lifecycle channel", async () => { const mock = getMockAblyRealtime(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["logs:connection-lifecycle:subscribe"], import.meta.url, ); @@ -47,7 +47,7 @@ describe("LogsConnectionLifecycleSubscribe", function () { ); const channel = mock.channels._getChannel("[meta]connection.lifecycle"); expect(channel.subscribe).toHaveBeenCalled(); - expect(stdout).toContain("Subscribed to connection lifecycle logs"); + expect(stderr).toContain("Subscribed to connection lifecycle logs"); }); }); @@ -67,7 +67,7 @@ describe("LogsConnectionLifecycleSubscribe", function () { // Emit SIGINT to stop the command - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["logs:connection-lifecycle:subscribe"], import.meta.url, ); @@ -77,7 +77,7 @@ describe("LogsConnectionLifecycleSubscribe", function () { {}, ); expect(channel.subscribe).toHaveBeenCalled(); - expect(stdout).toContain("Subscribed to connection lifecycle logs"); + expect(stderr).toContain("Subscribed to connection lifecycle logs"); }); it("should handle rewind parameter", async function () { diff --git a/test/unit/commands/logs/push/history.test.ts b/test/unit/commands/logs/push/history.test.ts index ccfe177b..8b4df29e 100644 --- a/test/unit/commands/logs/push/history.test.ts +++ b/test/unit/commands/logs/push/history.test.ts @@ -4,6 +4,7 @@ import { getMockAblyRest, createMockPaginatedResult, } from "../../../../helpers/mock-ably-rest.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -59,7 +60,7 @@ describe("logs:push:history command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("messages"); expect(Array.isArray(result.messages)).toBe(true); diff --git a/test/unit/commands/logs/subscribe.test.ts b/test/unit/commands/logs/subscribe.test.ts index e388faef..103e6a4d 100644 --- a/test/unit/commands/logs/subscribe.test.ts +++ b/test/unit/commands/logs/subscribe.test.ts @@ -41,10 +41,10 @@ describe("logs:subscribe command", () => { describe("functionality", () => { it("should subscribe to log channel and show initial message", async () => { - const { stdout } = await runCommand(["logs:subscribe"], import.meta.url); + const { stderr } = await runCommand(["logs:subscribe"], import.meta.url); - expect(stdout).toContain("Subscribed to app logs"); - expect(stdout).toContain("Press Ctrl+C to exit"); + expect(stderr).toContain("Subscribed to app logs"); + expect(stderr).toContain("Press Ctrl+C to exit"); }); it("should subscribe to specific log types", async () => { diff --git a/test/unit/commands/push/batch-publish.test.ts b/test/unit/commands/push/batch-publish.test.ts index 545de6cd..6cd344fc 100644 --- a/test/unit/commands/push/batch-publish.test.ts +++ b/test/unit/commands/push/batch-publish.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("push:batch-publish command", () => { beforeEach(() => { @@ -25,12 +26,12 @@ describe("push:batch-publish command", () => { const payload = '[{"recipient":{"deviceId":"dev-1"},"payload":{"notification":{"title":"Hello"}}}]'; - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:batch-publish", payload, "--force"], import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(mock.request).toHaveBeenCalledWith( "post", "/push/batch/publish", @@ -45,12 +46,12 @@ describe("push:batch-publish command", () => { const payload = '[{"channels":["my-channel"],"payload":{"notification":{"title":"Hello"}}}]'; - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:batch-publish", payload, "--force"], import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(mock.request).toHaveBeenCalledWith( "post", "/messages", @@ -70,7 +71,8 @@ describe("push:batch-publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("publish"); @@ -104,12 +106,12 @@ describe("push:batch-publish command", () => { const payload = '[{"channels":"my-channel","payload":{"notification":{"title":"Hello"}}}]'; - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:batch-publish", payload, "--force"], import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(mock.request).toHaveBeenCalledWith( "post", "/messages", @@ -189,7 +191,7 @@ describe("push:batch-publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result.publish.failed).toBeTruthy(); expect(result.publish.failedItems).toHaveLength(1); expect(result.publish.failedItems[0].originalIndex).toBe(1); @@ -232,7 +234,7 @@ describe("push:batch-publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result.publish.failed).toBeTruthy(); expect(result.publish.failedItems).toHaveLength(1); expect(result.publish.failedItems[0].originalIndex).toBe(1); diff --git a/test/unit/commands/push/channels/list-channels.test.ts b/test/unit/commands/push/channels/list-channels.test.ts index dd100bec..1c81f648 100644 --- a/test/unit/commands/push/channels/list-channels.test.ts +++ b/test/unit/commands/push/channels/list-channels.test.ts @@ -64,8 +64,14 @@ describe("push:channels:list-channels command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("channels"); }); diff --git a/test/unit/commands/push/channels/list.test.ts b/test/unit/commands/push/channels/list.test.ts index 0f25b9d7..aa7f8ed9 100644 --- a/test/unit/commands/push/channels/list.test.ts +++ b/test/unit/commands/push/channels/list.test.ts @@ -71,7 +71,13 @@ describe("push:channels:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("subscriptions"); diff --git a/test/unit/commands/push/channels/remove-where.test.ts b/test/unit/commands/push/channels/remove-where.test.ts index 8acf923c..8e7d56b2 100644 --- a/test/unit/commands/push/channels/remove-where.test.ts +++ b/test/unit/commands/push/channels/remove-where.test.ts @@ -26,12 +26,12 @@ describe("push:channels:remove-where command", () => { it("should remove matching subscriptions with --force", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:channels:remove-where", "--channel", "my-channel", "--force"], import.meta.url, ); - expect(stdout).toContain("removed"); + expect(stderr).toContain("removed"); expect( mock.push.admin.channelSubscriptions.removeWhere, ).toHaveBeenCalledWith( @@ -60,8 +60,14 @@ describe("push:channels:remove-where command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("subscriptions"); expect(result.subscriptions).toHaveProperty("removed", true); diff --git a/test/unit/commands/push/channels/remove.test.ts b/test/unit/commands/push/channels/remove.test.ts index e9aaf3b4..0021e3b2 100644 --- a/test/unit/commands/push/channels/remove.test.ts +++ b/test/unit/commands/push/channels/remove.test.ts @@ -26,7 +26,7 @@ describe("push:channels:remove command", () => { it("should remove subscription with --force", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:channels:remove", "--channel", @@ -38,7 +38,7 @@ describe("push:channels:remove command", () => { import.meta.url, ); - expect(stdout).toContain("removed"); + expect(stderr).toContain("removed"); expect(mock.push.admin.channelSubscriptions.remove).toHaveBeenCalledWith( expect.objectContaining({ channel: "my-channel", @@ -70,7 +70,13 @@ describe("push:channels:remove command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("subscription"); diff --git a/test/unit/commands/push/channels/save.test.ts b/test/unit/commands/push/channels/save.test.ts index 8d4edaff..028b5885 100644 --- a/test/unit/commands/push/channels/save.test.ts +++ b/test/unit/commands/push/channels/save.test.ts @@ -25,7 +25,7 @@ describe("push:channels:save command", () => { it("should save subscription with device ID", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:channels:save", "--channel", @@ -36,8 +36,8 @@ describe("push:channels:save command", () => { import.meta.url, ); - expect(stdout).toContain("Subscribed"); - expect(stdout).toContain("my-channel"); + expect(stderr).toContain("Subscribed"); + expect(stderr).toContain("my-channel"); expect(mock.push.admin.channelSubscriptions.save).toHaveBeenCalledWith( expect.objectContaining({ channel: "my-channel", @@ -49,7 +49,7 @@ describe("push:channels:save command", () => { it("should save subscription with client ID", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:channels:save", "--channel", @@ -60,7 +60,7 @@ describe("push:channels:save command", () => { import.meta.url, ); - expect(stdout).toContain("Subscribed"); + expect(stderr).toContain("Subscribed"); expect(mock.push.admin.channelSubscriptions.save).toHaveBeenCalledWith( expect.objectContaining({ channel: "my-channel", @@ -91,7 +91,13 @@ describe("push:channels:save command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("subscription"); diff --git a/test/unit/commands/push/config/clear-apns.test.ts b/test/unit/commands/push/config/clear-apns.test.ts index cafe746b..dd364530 100644 --- a/test/unit/commands/push/config/clear-apns.test.ts +++ b/test/unit/commands/push/config/clear-apns.test.ts @@ -60,12 +60,12 @@ describe("push:config:clear-apns command", () => { ]); nockControl().patch(`/v1/apps/${appId}`).reply(200, { id: appId }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:clear-apns", "--force"], import.meta.url, ); - expect(stdout).toContain("APNs configuration cleared"); + expect(stderr).toContain("APNs configuration cleared"); }); it("should warn when APNs is not configured", async () => { @@ -89,13 +89,13 @@ describe("push:config:clear-apns command", () => { }, ]); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:clear-apns", "--force"], import.meta.url, ); - expect(stdout).toContain("not configured"); - expect(stdout).toContain("Nothing to clear"); + expect(stderr).toContain("not configured"); + expect(stderr).toContain("Nothing to clear"); }); it("should output JSON when requested", async () => { @@ -126,7 +126,13 @@ describe("push:config:clear-apns command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("config"); diff --git a/test/unit/commands/push/config/clear-fcm.test.ts b/test/unit/commands/push/config/clear-fcm.test.ts index ff4a3817..549e730c 100644 --- a/test/unit/commands/push/config/clear-fcm.test.ts +++ b/test/unit/commands/push/config/clear-fcm.test.ts @@ -60,12 +60,12 @@ describe("push:config:clear-fcm command", () => { ]); nockControl().patch(`/v1/apps/${appId}`).reply(200, { id: appId }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:clear-fcm", "--force"], import.meta.url, ); - expect(stdout).toContain("FCM configuration cleared"); + expect(stderr).toContain("FCM configuration cleared"); }); it("should warn when FCM is not configured", async () => { @@ -89,13 +89,13 @@ describe("push:config:clear-fcm command", () => { }, ]); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:clear-fcm", "--force"], import.meta.url, ); - expect(stdout).toContain("not configured"); - expect(stdout).toContain("Nothing to clear"); + expect(stderr).toContain("not configured"); + expect(stderr).toContain("Nothing to clear"); }); it("should output JSON when requested", async () => { @@ -126,7 +126,13 @@ describe("push:config:clear-fcm command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("config"); diff --git a/test/unit/commands/push/config/set-apns.test.ts b/test/unit/commands/push/config/set-apns.test.ts index 750ac7d9..a45b97a7 100644 --- a/test/unit/commands/push/config/set-apns.test.ts +++ b/test/unit/commands/push/config/set-apns.test.ts @@ -12,6 +12,7 @@ import { standardFlagTests, standardControlApiErrorTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("push:config:set-apns command", () => { let appId: string; @@ -42,7 +43,7 @@ describe("push:config:set-apns command", () => { it("should configure APNs with P8 key successfully", async () => { nockControl().patch(`/v1/apps/${appId}`).reply(200, { id: appId }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:config:set-apns", "--key-file", @@ -57,7 +58,7 @@ describe("push:config:set-apns command", () => { import.meta.url, ); - expect(stdout).toContain("APNs P8 key configured"); + expect(stderr).toContain("APNs P8 key configured"); }); it("should upload P12 certificate successfully", async () => { @@ -65,12 +66,12 @@ describe("push:config:set-apns command", () => { .post(`/v1/apps/${appId}/pkcs12`) .reply(200, { id: "cert-123" }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:set-apns", "--certificate", p8FixturePath], import.meta.url, ); - expect(stdout).toContain("APNs P12 certificate uploaded"); + expect(stderr).toContain("APNs P12 certificate uploaded"); }); it("should output JSON for P8 key when requested", async () => { @@ -92,7 +93,8 @@ describe("push:config:set-apns command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("config"); @@ -109,7 +111,7 @@ describe("push:config:set-apns command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("config"); @@ -203,12 +205,12 @@ describe("push:config:set-apns command", () => { .patch(`/v1/apps/${appId}`) .reply(200, { id: appId, apnsUseSandboxEndpoint: true }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:set-apns", "--certificate", p8FixturePath, "--sandbox"], import.meta.url, ); - expect(stdout).toContain("APNs P12 certificate uploaded"); + expect(stderr).toContain("APNs P12 certificate uploaded"); }); it("should fail when certificate file not found", async () => { diff --git a/test/unit/commands/push/config/set-fcm.test.ts b/test/unit/commands/push/config/set-fcm.test.ts index c39cc57f..9004ad50 100644 --- a/test/unit/commands/push/config/set-fcm.test.ts +++ b/test/unit/commands/push/config/set-fcm.test.ts @@ -44,12 +44,12 @@ describe("push:config:set-fcm command", () => { it("should configure FCM successfully", async () => { nockControl().patch(`/v1/apps/${appId}`).reply(200, { id: appId }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:config:set-fcm", "--service-account", fcmFixturePath], import.meta.url, ); - expect(stdout).toContain("FCM configuration updated"); + expect(stderr).toContain("FCM configuration updated"); }); it("should output JSON when requested", async () => { @@ -60,7 +60,13 @@ describe("push:config:set-fcm command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("config"); diff --git a/test/unit/commands/push/config/show.test.ts b/test/unit/commands/push/config/show.test.ts index aab7e8f3..f328bcfd 100644 --- a/test/unit/commands/push/config/show.test.ts +++ b/test/unit/commands/push/config/show.test.ts @@ -161,7 +161,13 @@ describe("push:config:show command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("config"); diff --git a/test/unit/commands/push/devices/get.test.ts b/test/unit/commands/push/devices/get.test.ts index 95482d62..e3766ce3 100644 --- a/test/unit/commands/push/devices/get.test.ts +++ b/test/unit/commands/push/devices/get.test.ts @@ -59,8 +59,14 @@ describe("push:devices:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("device"); }); diff --git a/test/unit/commands/push/devices/list.test.ts b/test/unit/commands/push/devices/list.test.ts index bef4473f..c69bdade 100644 --- a/test/unit/commands/push/devices/list.test.ts +++ b/test/unit/commands/push/devices/list.test.ts @@ -78,8 +78,14 @@ describe("push:devices:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("devices"); expect(result).toHaveProperty("hasMore", false); @@ -99,7 +105,13 @@ describe("push:devices:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ) as Record; expect(result).toHaveProperty("hasMore", false); expect(result.devices).toHaveLength(2); }); diff --git a/test/unit/commands/push/devices/remove-where.test.ts b/test/unit/commands/push/devices/remove-where.test.ts index eedabd25..f0597ea7 100644 --- a/test/unit/commands/push/devices/remove-where.test.ts +++ b/test/unit/commands/push/devices/remove-where.test.ts @@ -25,12 +25,12 @@ describe("push:devices:remove-where command", () => { it("should remove devices matching filter with --force", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:devices:remove-where", "--client-id", "client-1", "--force"], import.meta.url, ); - expect(stdout).toContain("removed"); + expect(stderr).toContain("removed"); expect( mock.push.admin.deviceRegistrations.removeWhere, ).toHaveBeenCalledWith(expect.objectContaining({ clientId: "client-1" })); @@ -57,8 +57,14 @@ describe("push:devices:remove-where command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("devices"); expect(result.devices).toHaveProperty("removed", true); diff --git a/test/unit/commands/push/devices/remove.test.ts b/test/unit/commands/push/devices/remove.test.ts index ad55dcce..f4d9b3d3 100644 --- a/test/unit/commands/push/devices/remove.test.ts +++ b/test/unit/commands/push/devices/remove.test.ts @@ -25,13 +25,13 @@ describe("push:devices:remove command", () => { it("should remove a device with --force", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:devices:remove", "device-123", "--force"], import.meta.url, ); - expect(stdout).toContain("device-123"); - expect(stdout).toContain("removed"); + expect(stderr).toContain("device-123"); + expect(stderr).toContain("removed"); expect(mock.push.admin.deviceRegistrations.remove).toHaveBeenCalledWith( "device-123", ); @@ -43,8 +43,14 @@ describe("push:devices:remove command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const records = stdout + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + const result = records.find( + (r: Record) => r.type === "result", + ); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("device"); expect(result.device).toHaveProperty("id", "device-123"); diff --git a/test/unit/commands/push/devices/save.test.ts b/test/unit/commands/push/devices/save.test.ts index d5896581..315dfd3c 100644 --- a/test/unit/commands/push/devices/save.test.ts +++ b/test/unit/commands/push/devices/save.test.ts @@ -34,7 +34,7 @@ describe("push:devices:save command", () => { platform: "ios", }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:devices:save", "--id", @@ -51,7 +51,7 @@ describe("push:devices:save command", () => { import.meta.url, ); - expect(stdout).toContain("Device registration saved"); + expect(stderr).toContain("Device registration saved"); expect(mock.push.admin.deviceRegistrations.save).toHaveBeenCalledWith( expect.objectContaining({ id: "device-1", @@ -67,7 +67,7 @@ describe("push:devices:save command", () => { id: "device-2", }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:devices:save", "--data", @@ -76,7 +76,7 @@ describe("push:devices:save command", () => { import.meta.url, ); - expect(stdout).toContain("Device registration saved"); + expect(stderr).toContain("Device registration saved"); }); it("should output JSON when requested", async () => { @@ -103,7 +103,13 @@ describe("push:devices:save command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + // Parse NDJSON output — find the result record + const records = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("device"); @@ -116,7 +122,7 @@ describe("push:devices:save command", () => { platform: "browser", }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:devices:save", "--id", @@ -137,7 +143,7 @@ describe("push:devices:save command", () => { import.meta.url, ); - expect(stdout).toContain("Device registration saved"); + expect(stderr).toContain("Device registration saved"); expect(mock.push.admin.deviceRegistrations.save).toHaveBeenCalledWith( expect.objectContaining({ id: "browser-1", diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index 644879d0..a04939da 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("push:publish command", () => { beforeEach(() => { @@ -40,7 +41,7 @@ describe("push:publish command", () => { it("should publish to a device", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:publish", "--device-id", @@ -53,7 +54,7 @@ describe("push:publish command", () => { import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(mock.push.admin.publish).toHaveBeenCalledWith( { deviceId: "dev-1" }, expect.objectContaining({ @@ -68,12 +69,12 @@ describe("push:publish command", () => { it("should publish to a client", async () => { const mock = getMockAblyRest(); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:publish", "--client-id", "client-1", "--title", "Hi"], import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(mock.push.admin.publish).toHaveBeenCalledWith( { clientId: "client-1" }, expect.objectContaining({ @@ -87,12 +88,12 @@ describe("push:publish command", () => { const payload = '{"notification":{"title":"Custom"},"data":{"key":"val"}}'; - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["push:publish", "--device-id", "dev-1", "--payload", payload], import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(mock.push.admin.publish).toHaveBeenCalledWith( { deviceId: "dev-1" }, expect.objectContaining({ @@ -106,7 +107,7 @@ describe("push:publish command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("my-channel"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "push:publish", "--channel", @@ -120,7 +121,7 @@ describe("push:publish command", () => { import.meta.url, ); - expect(stdout).toContain("published"); + expect(stderr).toContain("published"); expect(channel.publish).toHaveBeenCalledWith( expect.objectContaining({ extras: { @@ -168,8 +169,8 @@ describe("push:publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("type", "result"); + const result = parseJsonOutput(stdout); + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("notification"); expect(result.notification).toHaveProperty("published", true); @@ -181,7 +182,7 @@ describe("push:publish command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("notification"); expect(result.notification).toHaveProperty("published", true); expect(result.notification).toHaveProperty("channel", "my-channel"); diff --git a/test/unit/commands/queues/create.test.ts b/test/unit/commands/queues/create.test.ts index a730c496..a05ff192 100644 --- a/test/unit/commands/queues/create.test.ts +++ b/test/unit/commands/queues/create.test.ts @@ -14,6 +14,7 @@ import { standardControlApiErrorTests, } from "../../../helpers/standard-tests.js"; import { mockQueue } from "../../../fixtures/control-api.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; describe("queues:create command", () => { const mockQueueName = "test-queue"; @@ -54,12 +55,12 @@ describe("queues:create command", () => { }) .reply(201, createMockQueueResponse(appId)); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["queues:create", "--name", mockQueueName], import.meta.url, ); - expect(stdout).toContain("Queue created:"); + expect(stderr).toContain("Queue created:"); expect(stdout).toContain(`Queue ID: ${mockQueueId}`); expect(stdout).toContain(`Name: ${mockQueueName}`); expect(stdout).toContain("Region: us-east-1-a"); @@ -95,7 +96,7 @@ describe("queues:create command", () => { ttl: 3600, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "queues:create", "--name", @@ -110,7 +111,7 @@ describe("queues:create command", () => { import.meta.url, ); - expect(stdout).toContain("Queue created:"); + expect(stderr).toContain("Queue created:"); expect(stdout).toContain("Region: eu-west-1-a"); expect(stdout).toContain("TTL: 3600 seconds"); expect(stdout).toContain("Max Length: 5000 messages"); @@ -137,7 +138,7 @@ describe("queues:create command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "queues:create"); expect(result).toHaveProperty("success", true); @@ -169,12 +170,12 @@ describe("queues:create command", () => { appId: customAppId, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["queues:create", "--name", mockQueueName, "--app", "custom-app-id"], import.meta.url, ); - expect(stdout).toContain("Queue created:"); + expect(stderr).toContain("Queue created:"); }); it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { @@ -208,12 +209,12 @@ describe("queues:create command", () => { .post(`/v1/apps/${appId}/queues`) .reply(201, createMockQueueResponse(appId)); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["queues:create", "--name", mockQueueName], import.meta.url, ); - expect(stdout).toContain("Queue created:"); + expect(stderr).toContain("Queue created:"); }); }); @@ -394,7 +395,7 @@ describe("queues:create command", () => { ttl: 1, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "queues:create", "--name", @@ -407,7 +408,7 @@ describe("queues:create command", () => { import.meta.url, ); - expect(stdout).toContain("Queue created:"); + expect(stderr).toContain("Queue created:"); expect(stdout).toContain("TTL: 1 seconds"); expect(stdout).toContain("Max Length: 1 messages"); }); @@ -438,7 +439,7 @@ describe("queues:create command", () => { ttl: 3600, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( [ "queues:create", "--name", @@ -453,7 +454,7 @@ describe("queues:create command", () => { import.meta.url, ); - expect(stdout).toContain("Queue created:"); + expect(stderr).toContain("Queue created:"); expect(stdout).toContain("Region: ap-southeast-2-a"); expect(stdout).toContain("TTL: 3600 seconds"); expect(stdout).toContain("Max Length: 10000 messages"); diff --git a/test/unit/commands/queues/delete.test.ts b/test/unit/commands/queues/delete.test.ts index ae0f3800..5352b8a0 100644 --- a/test/unit/commands/queues/delete.test.ts +++ b/test/unit/commands/queues/delete.test.ts @@ -46,12 +46,12 @@ describe("queues:delete command", () => { .delete(`/v1/apps/${appId}/queues/${mockQueueId}`) .reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["queues:delete", mockQueueId, "--force"], import.meta.url, ); - expect(stdout).toContain("Queue deleted:"); + expect(stderr).toContain("Queue deleted:"); }); it("should delete a queue with custom app ID", async () => { @@ -78,12 +78,12 @@ describe("queues:delete command", () => { .delete(`/v1/apps/${customAppId}/queues/${mockQueueId}`) .reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["queues:delete", mockQueueId, "--app", "custom-app-id", "--force"], import.meta.url, ); - expect(stdout).toContain("Queue deleted:"); + expect(stderr).toContain("Queue deleted:"); }); it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { @@ -352,7 +352,7 @@ describe("queues:delete command", () => { .get(`/v1/apps/${appId}/queues`) .reply(200, [createMockQueue(appId, mockQueueId)]); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["queues:delete", mockQueueId], import.meta.url, ); @@ -365,7 +365,7 @@ describe("queues:delete command", () => { expect(stdout).toContain( "Messages: 10 total (5 ready, 5 unacknowledged)", ); - expect(stdout).toContain("Deletion cancelled"); + expect(stderr).toContain("Deletion cancelled"); }); it("should proceed with deletion when user confirms", async () => { @@ -387,12 +387,12 @@ describe("queues:delete command", () => { .delete(`/v1/apps/${appId}/queues/${mockQueueId}`) .reply(204); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["queues:delete", mockQueueId], import.meta.url, ); - expect(stdout).toContain("Queue deleted:"); + expect(stderr).toContain("Queue deleted:"); }); }); diff --git a/test/unit/commands/queues/list.test.ts b/test/unit/commands/queues/list.test.ts index 919e3c52..fef764be 100644 --- a/test/unit/commands/queues/list.test.ts +++ b/test/unit/commands/queues/list.test.ts @@ -7,6 +7,7 @@ import { } from "../../../helpers/control-api-test-helpers.js"; import { runCommand } from "@oclif/test"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -140,7 +141,7 @@ describe("queues:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "queues:list"); expect(result).toHaveProperty("success", true); @@ -295,7 +296,7 @@ describe("queues:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "error"); expect(result).toHaveProperty("command", "queues:list"); expect(result).toHaveProperty("success", false); @@ -360,7 +361,7 @@ describe("queues:list command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "queues:list"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/rooms/features.test.ts b/test/unit/commands/rooms/features.test.ts index 1149e03e..6f1d5460 100644 --- a/test/unit/commands/rooms/features.test.ts +++ b/test/unit/commands/rooms/features.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblyChat } from "../../../helpers/mock-ably-chat.js"; @@ -31,25 +31,31 @@ describe("rooms feature commands", function () { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + room.occupancy.subscribe.mockImplementation( + (_callback: (event: unknown) => void) => { + return { unsubscribe: vi.fn() }; + }, + ); + + const { stderr } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); expect(room.occupancy.subscribe).toHaveBeenCalled(); - expect(stdout).toContain("Subscribed to occupancy"); + expect(stderr).toContain("Subscribed to occupancy"); }); it("should display subscribing message", async function () { const chatMock = getMockAblyChat(); chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - expect(stdout).toContain("Subscribed to occupancy in room"); + expect(stderr).toContain("Subscribed to occupancy in room"); }); }); @@ -62,14 +68,14 @@ describe("rooms feature commands", function () { // Emit SIGINT to stop the command - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:presence:enter", "test-room"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.presence.enter).toHaveBeenCalled(); - expect(stdout).toContain("Entered"); + expect(stderr).toContain("Entered"); }); it("should handle presence data", async function () { @@ -102,7 +108,7 @@ describe("rooms feature commands", function () { room.reactions.send.mockImplementation(async () => {}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:reactions:send", "test-room", "👍"], import.meta.url, ); @@ -112,7 +118,7 @@ describe("rooms feature commands", function () { name: "👍", metadata: {}, }); - expect(stdout).toContain("Sent reaction"); + expect(stderr).toContain("Sent reaction"); }); it("should handle metadata in reactions", async function () { @@ -148,14 +154,14 @@ describe("rooms feature commands", function () { // Emit SIGINT to stop the command - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:typing:keystroke", "test-room"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.typing.keystroke).toHaveBeenCalled(); - expect(stdout).toContain("typing"); + expect(stderr).toContain("typing"); }); }); diff --git a/test/unit/commands/rooms/list.test.ts b/test/unit/commands/rooms/list.test.ts index a17fcac8..fe12a668 100644 --- a/test/unit/commands/rooms/list.test.ts +++ b/test/unit/commands/rooms/list.test.ts @@ -4,6 +4,7 @@ import { getMockAblyRest, createMockPaginatedResult, } from "../../../helpers/mock-ably-rest.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -127,7 +128,7 @@ describe("rooms:list command", () => { import.meta.url, ); - const json = JSON.parse(stdout); + const json = parseJsonOutput(stdout); expect(json).toHaveProperty("rooms"); expect(json.rooms).toBeInstanceOf(Array); expect(json.rooms.length).toBe(2); diff --git a/test/unit/commands/rooms/messages.test.ts b/test/unit/commands/rooms/messages.test.ts index 81330d26..d6811d5b 100644 --- a/test/unit/commands/rooms/messages.test.ts +++ b/test/unit/commands/rooms/messages.test.ts @@ -19,7 +19,7 @@ describe("rooms messages commands", function () { createdAt: Date.now(), }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:send", "test-room", "HelloWorld"], import.meta.url, ); @@ -27,7 +27,7 @@ describe("rooms messages commands", function () { expect(room.messages.send).toHaveBeenCalledWith({ text: "HelloWorld", }); - expect(stdout).toContain("Message sent to room test-room"); + expect(stderr).toContain("Message sent to room test-room"); }); it("should emit JSON envelope with type result for --json single send", async function () { @@ -69,7 +69,7 @@ describe("rooms messages commands", function () { }, ); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "rooms:messages:send", "test-room", @@ -86,7 +86,7 @@ describe("rooms messages commands", function () { expect(sentTexts).toContain("Message1"); expect(sentTexts).toContain("Message2"); expect(sentTexts).toContain("Message3"); - expect(stdout).toContain("3/3 messages sent to room test-room"); + expect(stderr).toContain("3/3 messages sent to room test-room"); }); it("should handle metadata in messages", async function () { @@ -268,14 +268,14 @@ describe("rooms messages commands", function () { // Emit SIGINT after a delay to stop the command - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:subscribe", "test-room"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.messages.subscribe).toHaveBeenCalled(); - expect(stdout).toContain("Subscribed to room"); + expect(stderr).toContain("Subscribed to room"); }); it("should display received messages", async function () { diff --git a/test/unit/commands/rooms/messages/delete.test.ts b/test/unit/commands/rooms/messages/delete.test.ts index d46e03a3..35e6d431 100644 --- a/test/unit/commands/rooms/messages/delete.test.ts +++ b/test/unit/commands/rooms/messages/delete.test.ts @@ -35,7 +35,7 @@ describe("rooms:messages:delete command", () => { version: { serial: "version-serial-001", timestamp: new Date() }, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:delete", "test-room", "serial-001"], import.meta.url, ); @@ -44,8 +44,8 @@ describe("rooms:messages:delete command", () => { "serial-001", undefined, ); - expect(stdout).toContain("deleted"); - expect(stdout).toContain("test-room"); + expect(stderr).toContain("deleted"); + expect(stderr).toContain("test-room"); }); it("should pass description as OperationDetails", async () => { diff --git a/test/unit/commands/rooms/messages/history.test.ts b/test/unit/commands/rooms/messages/history.test.ts index 6e8151c6..23a195e1 100644 --- a/test/unit/commands/rooms/messages/history.test.ts +++ b/test/unit/commands/rooms/messages/history.test.ts @@ -74,13 +74,13 @@ describe("rooms:messages:history command", () => { .fn() .mockResolvedValue(createMockPaginatedResult([])); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["rooms:messages:history", "test-room"], import.meta.url, ); expect(room.messages.history).toHaveBeenCalled(); - expect(stdout).toContain("Retrieved 0 messages"); + expect(stderr).toContain("Retrieved 0 messages"); expect(stdout).toContain("No messages found"); }); diff --git a/test/unit/commands/rooms/messages/reactions/remove.test.ts b/test/unit/commands/rooms/messages/reactions/remove.test.ts index 774d5ebe..ebf01f76 100644 --- a/test/unit/commands/rooms/messages/reactions/remove.test.ts +++ b/test/unit/commands/rooms/messages/reactions/remove.test.ts @@ -31,7 +31,7 @@ describe("rooms:messages:reactions:remove command", () => { // Configure message reactions delete mock room.messages.reactions.delete.mockImplementation(async () => {}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "rooms:messages:reactions:remove", "test-room", @@ -48,10 +48,10 @@ describe("rooms:messages:reactions:remove command", () => { name: "👍", }, ); - expect(stdout).toContain("Removed reaction"); - expect(stdout).toContain("👍"); - expect(stdout).toContain("msg-serial-123"); - expect(stdout).toContain("test-room"); + expect(stderr).toContain("Removed reaction"); + expect(stderr).toContain("👍"); + expect(stderr).toContain("msg-serial-123"); + expect(stderr).toContain("test-room"); }); it("should remove a reaction with type flag", async () => { @@ -60,7 +60,7 @@ describe("rooms:messages:reactions:remove command", () => { room.messages.reactions.delete.mockImplementation(async () => {}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "rooms:messages:reactions:remove", "test-room", @@ -79,8 +79,8 @@ describe("rooms:messages:reactions:remove command", () => { type: expect.any(String), }, ); - expect(stdout).toContain("Removed reaction"); - expect(stdout).toContain("❤️"); + expect(stderr).toContain("Removed reaction"); + expect(stderr).toContain("❤️"); }); it("should output JSON when --json flag is used", async () => { @@ -100,7 +100,8 @@ describe("rooms:messages:reactions:remove command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const lines = stdout.trim().split("\n"); + const result = JSON.parse(lines[0]); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("reaction"); expect(result.reaction).toHaveProperty("room", "test-room"); diff --git a/test/unit/commands/rooms/messages/reactions/send.test.ts b/test/unit/commands/rooms/messages/reactions/send.test.ts index 27d5d892..11496f4c 100644 --- a/test/unit/commands/rooms/messages/reactions/send.test.ts +++ b/test/unit/commands/rooms/messages/reactions/send.test.ts @@ -29,7 +29,7 @@ describe("rooms:messages:reactions:send command", () => { // Configure message reactions send mock room.messages.reactions.send.mockImplementation(async () => {}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:reactions:send", "test-room", "msg-serial-123", "👍"], import.meta.url, ); @@ -41,10 +41,10 @@ describe("rooms:messages:reactions:send command", () => { name: "👍", }, ); - expect(stdout).toContain("Sent reaction"); - expect(stdout).toContain("👍"); - expect(stdout).toContain("msg-serial-123"); - expect(stdout).toContain("test-room"); + expect(stderr).toContain("Sent reaction"); + expect(stderr).toContain("👍"); + expect(stderr).toContain("msg-serial-123"); + expect(stderr).toContain("test-room"); }); it("should send a reaction with type flag", async () => { @@ -53,7 +53,7 @@ describe("rooms:messages:reactions:send command", () => { room.messages.reactions.send.mockImplementation(async () => {}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "rooms:messages:reactions:send", "test-room", @@ -72,8 +72,8 @@ describe("rooms:messages:reactions:send command", () => { type: expect.any(String), }, ); - expect(stdout).toContain("Sent reaction"); - expect(stdout).toContain("❤️"); + expect(stderr).toContain("Sent reaction"); + expect(stderr).toContain("❤️"); }); it("should output JSON when --json flag is used", async () => { @@ -93,7 +93,8 @@ describe("rooms:messages:reactions:send command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const lines = stdout.trim().split("\n"); + const result = JSON.parse(lines[0]); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("reaction"); expect(result.reaction).toHaveProperty("room", "test-room"); diff --git a/test/unit/commands/rooms/messages/send.test.ts b/test/unit/commands/rooms/messages/send.test.ts index 037bfb2e..ad15b907 100644 --- a/test/unit/commands/rooms/messages/send.test.ts +++ b/test/unit/commands/rooms/messages/send.test.ts @@ -34,7 +34,7 @@ describe("rooms:messages:send command", () => { createdAt: Date.now(), }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:send", "test-room", "HelloWorld"], import.meta.url, ); @@ -42,7 +42,7 @@ describe("rooms:messages:send command", () => { expect(room.messages.send).toHaveBeenCalledWith({ text: "HelloWorld", }); - expect(stdout).toContain("Message sent to room test-room"); + expect(stderr).toContain("Message sent to room test-room"); }); it("should send message with metadata", async () => { @@ -83,7 +83,7 @@ describe("rooms:messages:send command", () => { }, ); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "rooms:messages:send", "test-room", @@ -100,7 +100,7 @@ describe("rooms:messages:send command", () => { expect(sentTexts).toContain("Message1"); expect(sentTexts).toContain("Message2"); expect(sentTexts).toContain("Message3"); - expect(stdout).toContain("3/3 messages sent to room test-room"); + expect(stderr).toContain("3/3 messages sent to room test-room"); }); it("should emit JSON envelope with type result for --json single send", async () => { diff --git a/test/unit/commands/rooms/messages/subscribe.test.ts b/test/unit/commands/rooms/messages/subscribe.test.ts index cd0e7a5e..e04e2567 100644 --- a/test/unit/commands/rooms/messages/subscribe.test.ts +++ b/test/unit/commands/rooms/messages/subscribe.test.ts @@ -33,14 +33,14 @@ describe("rooms:messages:subscribe command", () => { return { unsubscribe: vi.fn() }; }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:subscribe", "test-room"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.messages.subscribe).toHaveBeenCalled(); - expect(stdout).toContain("Subscribed to room"); + expect(stderr).toContain("Subscribed to room"); }); it("should display received messages with action and serial", async () => { diff --git a/test/unit/commands/rooms/messages/update.test.ts b/test/unit/commands/rooms/messages/update.test.ts index 3b5ccc0e..c37f5175 100644 --- a/test/unit/commands/rooms/messages/update.test.ts +++ b/test/unit/commands/rooms/messages/update.test.ts @@ -37,7 +37,7 @@ describe("rooms:messages:update command", () => { version: { serial: "version-serial-001", timestamp: new Date() }, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:messages:update", "test-room", "serial-001", "updated-text"], import.meta.url, ); @@ -47,8 +47,8 @@ describe("rooms:messages:update command", () => { { text: "updated-text" }, undefined, ); - expect(stdout).toContain("updated"); - expect(stdout).toContain("test-room"); + expect(stderr).toContain("updated"); + expect(stderr).toContain("test-room"); }); it("should pass metadata when --metadata provided", async () => { diff --git a/test/unit/commands/rooms/occupancy/get.test.ts b/test/unit/commands/rooms/occupancy/get.test.ts index 598379d5..ed580062 100644 --- a/test/unit/commands/rooms/occupancy/get.test.ts +++ b/test/unit/commands/rooms/occupancy/get.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("rooms:occupancy:get command", () => { beforeEach(() => { @@ -67,7 +68,7 @@ describe("rooms:occupancy:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("occupancy"); expect(result.occupancy).toHaveProperty("room", "test-room"); @@ -86,7 +87,7 @@ describe("rooms:occupancy:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(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 index 8deebe05..a0997b06 100644 --- a/test/unit/commands/rooms/occupancy/subscribe.test.ts +++ b/test/unit/commands/rooms/occupancy/subscribe.test.ts @@ -23,26 +23,26 @@ describe("rooms:occupancy:subscribe command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.occupancy.subscribe).toHaveBeenCalled(); - expect(stdout).toContain("Subscribed to occupancy in room"); + expect(stderr).toContain("Subscribed to occupancy in room"); }); it("should display listening message", async () => { const chatMock = getMockAblyChat(); chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:occupancy:subscribe", "test-room"], import.meta.url, ); - expect(stdout).toContain("Listening for occupancy updates"); + expect(stderr).toContain("Listening"); }); it("should subscribe and display updates", async () => { diff --git a/test/unit/commands/rooms/presence/enter.test.ts b/test/unit/commands/rooms/presence/enter.test.ts index 1b8a3ea2..b8665d38 100644 --- a/test/unit/commands/rooms/presence/enter.test.ts +++ b/test/unit/commands/rooms/presence/enter.test.ts @@ -36,13 +36,13 @@ describe("rooms:presence:enter command", () => { }); it("should show progress message", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:presence:enter", "test-room"], import.meta.url, ); - expect(stdout).toContain("Entering presence in room"); - expect(stdout).toContain("test-room"); + expect(stderr).toContain("Entering presence in room"); + expect(stderr).toContain("test-room"); }); it("should pass parsed --data to presence.enter", async () => { diff --git a/test/unit/commands/rooms/presence/get.test.ts b/test/unit/commands/rooms/presence/get.test.ts index 1886b348..afca8c54 100644 --- a/test/unit/commands/rooms/presence/get.test.ts +++ b/test/unit/commands/rooms/presence/get.test.ts @@ -9,6 +9,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; const mockPresenceMembers = [ { @@ -57,13 +58,13 @@ describe("rooms:presence:get command", () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-room::$chat"); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["rooms:presence:get", "test-room"], import.meta.url, ); expect(channel.presence.get).toHaveBeenCalledWith({ limit: 100 }); - expect(stdout).toContain("Fetching presence members"); + expect(stderr).toContain("Fetching presence members"); expect(stdout).toContain("test-room"); expect(stdout).toContain("user-1"); expect(stdout).toContain("user-2"); @@ -87,12 +88,12 @@ describe("rooms:presence:get command", () => { const channel = mock.channels._getChannel("test-room::$chat"); channel.presence.get.mockResolvedValue(createMockPaginatedResult([])); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:presence:get", "test-room"], import.meta.url, ); - expect(stdout).toContain("No members currently present"); + expect(stderr).toContain("No members currently present"); }); it("should output JSON with members array", async () => { @@ -101,7 +102,7 @@ describe("rooms:presence:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout.trim()); + const result = parseJsonOutput(stdout); expect(result.type).toBe("result"); expect(result.members).toBeDefined(); expect(result.members).toHaveLength(2); @@ -162,7 +163,7 @@ describe("rooms:presence:get command", () => { import.meta.url, ); - const result = JSON.parse(stdout.trim()); + const result = parseJsonOutput(stdout); expect(result.hasMore).toBe(true); expect(result.next).toBeDefined(); expect(result.next.hint).toContain("--limit"); diff --git a/test/unit/commands/rooms/reactions/send.test.ts b/test/unit/commands/rooms/reactions/send.test.ts index 29318dfd..86b6672d 100644 --- a/test/unit/commands/rooms/reactions/send.test.ts +++ b/test/unit/commands/rooms/reactions/send.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("rooms:reactions:send command", () => { beforeEach(() => { @@ -26,7 +27,7 @@ describe("rooms:reactions:send command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:reactions:send", "test-room", "thumbsup"], import.meta.url, ); @@ -35,8 +36,8 @@ describe("rooms:reactions:send command", () => { expect(room.reactions.send).toHaveBeenCalledWith( expect.objectContaining({ name: "thumbsup" }), ); - expect(stdout).toContain("Sent reaction"); - expect(stdout).toContain("thumbsup"); + expect(stderr).toContain("Sent reaction"); + expect(stderr).toContain("thumbsup"); }); it("should parse and forward --metadata JSON", async () => { @@ -88,7 +89,8 @@ describe("rooms:reactions:send command", () => { ); expect(room.reactions.send).toHaveBeenCalled(); - const result = JSON.parse(stdout); + const lines = stdout.trim().split("\n"); + const result = JSON.parse(lines[0]); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("reaction"); expect(result.reaction).toHaveProperty("emoji", "fire"); @@ -105,7 +107,7 @@ describe("rooms:reactions:send command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("Send failed"); diff --git a/test/unit/commands/rooms/typing/keystroke.test.ts b/test/unit/commands/rooms/typing/keystroke.test.ts index 4b56804b..ffeca2f6 100644 --- a/test/unit/commands/rooms/typing/keystroke.test.ts +++ b/test/unit/commands/rooms/typing/keystroke.test.ts @@ -6,6 +6,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../../helpers/ndjson.js"; describe("rooms:typing:keystroke command", () => { beforeEach(() => { @@ -26,28 +27,28 @@ describe("rooms:typing:keystroke command", () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:typing:keystroke", "test-room"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.typing.keystroke).toHaveBeenCalled(); - expect(stdout).toContain("Started typing"); + expect(stderr).toContain("Started typing"); }); it("should handle --auto-type flag", async () => { const chatMock = getMockAblyChat(); const room = chatMock.rooms._getRoom("test-room"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["rooms:typing:keystroke", "test-room", "--auto-type"], import.meta.url, ); expect(room.attach).toHaveBeenCalled(); expect(room.typing.keystroke).toHaveBeenCalled(); - expect(stdout).toContain("automatically"); + expect(stderr).toContain("automatically"); }); it("should handle keystroke failure", async () => { @@ -76,7 +77,7 @@ describe("rooms:typing:keystroke command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("typing"); expect(result.typing).toHaveProperty("room", "test-room"); @@ -96,8 +97,8 @@ describe("rooms:typing:keystroke command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", false); + const result = parseJsonOutput(stdout); + expect(result).toBeDefined(); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("Connection failed"); }); @@ -149,7 +150,7 @@ describe("rooms:typing:keystroke command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error.message).toContain("Channel denied access"); diff --git a/test/unit/commands/spaces/create.test.ts b/test/unit/commands/spaces/create.test.ts index 7d59458f..66483dcd 100644 --- a/test/unit/commands/spaces/create.test.ts +++ b/test/unit/commands/spaces/create.test.ts @@ -26,14 +26,14 @@ describe("spaces:create command", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:create", "test-space"], import.meta.url, ); expect(space.enter).not.toHaveBeenCalled(); - expect(stdout).toContain("initialized"); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("initialized"); + expect(stderr).toContain("test-space"); }); it("should output JSON envelope with space name", async () => { diff --git a/test/unit/commands/spaces/cursors/set.test.ts b/test/unit/commands/spaces/cursors/set.test.ts index 2babc173..95e217f3 100644 --- a/test/unit/commands/spaces/cursors/set.test.ts +++ b/test/unit/commands/spaces/cursors/set.test.ts @@ -78,7 +78,7 @@ describe("spaces:cursors:set command", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["spaces:cursors:set", "test-space", "--x", "100", "--y", "200"], import.meta.url, ); @@ -89,8 +89,8 @@ describe("spaces:cursors:set command", () => { position: { x: 100, y: 200 }, }), ); - expect(stdout).toContain("Set cursor"); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("Set cursor"); + expect(stderr).toContain("test-space"); expect(stdout).toContain("Position X:"); expect(stdout).toContain("100"); expect(stdout).toContain("Position Y:"); @@ -122,13 +122,13 @@ describe("spaces:cursors:set command", () => { const spacesMock = getMockAblySpaces(); spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:cursors:set", "test-space", "--x", "100", "--y", "200"], import.meta.url, ); - expect(stdout).toContain("Holding cursor."); - expect(stdout).toContain("Press Ctrl+C to exit."); + expect(stderr).toContain("Holding cursor."); + expect(stderr).toContain("Press Ctrl+C to exit."); }); it("should include data in simulated cursor output", async () => { @@ -209,9 +209,10 @@ describe("spaces:cursors:set command", () => { expect(cursor).toHaveProperty("clientId"); expect(cursor).toHaveProperty("connectionId"); - const status = records.find((r) => r.type === "status"); + const status = records.find( + (r) => r.type === "status" && r.status === "holding", + ); expect(status).toBeDefined(); - expect(status).toHaveProperty("status", "holding"); expect(status!.message).toContain("Holding cursor"); }); diff --git a/test/unit/commands/spaces/cursors/subscribe.test.ts b/test/unit/commands/spaces/cursors/subscribe.test.ts index 6c327adb..fdcf073c 100644 --- a/test/unit/commands/spaces/cursors/subscribe.test.ts +++ b/test/unit/commands/spaces/cursors/subscribe.test.ts @@ -45,13 +45,13 @@ describe("spaces:cursors:subscribe command", () => { const space = spacesMock._getSpace("test-space"); space.cursors.getAll.mockResolvedValue({}); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:cursors:subscribe", "test-space"], import.meta.url, ); - expect(stdout).toContain("Subscribing to cursor updates"); - expect(stdout).toContain("Listening for cursor movements"); + expect(stderr).toContain("Subscribing to cursor updates"); + expect(stderr).toContain("Listening for cursor movements"); }); }); diff --git a/test/unit/commands/spaces/list.test.ts b/test/unit/commands/spaces/list.test.ts index e94fcf7d..724a0c82 100644 --- a/test/unit/commands/spaces/list.test.ts +++ b/test/unit/commands/spaces/list.test.ts @@ -9,6 +9,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; function createMockSpaceChannelItems() { return [ @@ -111,7 +112,7 @@ describe("spaces:list command", () => { import.meta.url, ); - const json = JSON.parse(stdout); + const json = parseJsonOutput(stdout); expect(json).toHaveProperty("spaces"); expect(json).toHaveProperty("total"); expect(json).toHaveProperty("hasMore"); @@ -171,7 +172,7 @@ describe("spaces:list command", () => { statusCode: 200, }); - const { stdout } = await runCommand( + const { stdout, stderr } = await runCommand( ["spaces:list", "--limit", "10"], import.meta.url, ); @@ -181,7 +182,7 @@ describe("spaces:list command", () => { expect(stdout).toContain("space2"); expect(stdout).toContain("2 active spaces"); // Pagination warning for multi-page fetch - expect(stdout).toContain("pages"); + expect(stderr).toContain("pages"); }); describe("error handling", () => { diff --git a/test/unit/commands/spaces/locations/set.test.ts b/test/unit/commands/spaces/locations/set.test.ts index 394225de..48b140ed 100644 --- a/test/unit/commands/spaces/locations/set.test.ts +++ b/test/unit/commands/spaces/locations/set.test.ts @@ -73,7 +73,7 @@ describe("spaces:locations:set command", () => { const location = { x: 10, y: 20, sectionId: "main" }; - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "spaces:locations:set", "test-space", @@ -85,21 +85,21 @@ describe("spaces:locations:set command", () => { expect(space.enter).toHaveBeenCalled(); expect(space.locations.set).toHaveBeenCalledWith(location); - expect(stdout).toContain("Location set"); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("Location set"); + expect(stderr).toContain("test-space"); }); it("should display hold message", async () => { const spacesMock = getMockAblySpaces(); spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:locations:set", "test-space", "--location", '{"x":1}'], import.meta.url, ); - expect(stdout).toContain("Holding location."); - expect(stdout).toContain("Press Ctrl+C to exit."); + expect(stderr).toContain("Holding location."); + expect(stderr).toContain("Press Ctrl+C to exit."); }); }); @@ -127,9 +127,10 @@ describe("spaces:locations:set command", () => { expect(result!.success).toBe(true); expect(result!.location).toEqual(location); - const status = records.find((r) => r.type === "status"); + const status = records.find( + (r) => r.type === "status" && r.status === "holding", + ); expect(status).toBeDefined(); - expect(status).toHaveProperty("status", "holding"); expect(status!.message).toContain("Holding location"); }); diff --git a/test/unit/commands/spaces/locations/subscribe.test.ts b/test/unit/commands/spaces/locations/subscribe.test.ts index 20f93199..2d6d05c4 100644 --- a/test/unit/commands/spaces/locations/subscribe.test.ts +++ b/test/unit/commands/spaces/locations/subscribe.test.ts @@ -43,12 +43,12 @@ describe("spaces:locations:subscribe command", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:locations:subscribe", "test-space"], import.meta.url, ); - expect(stdout).toContain("Subscribing to location updates"); + expect(stderr).toContain("Subscribing to location updates"); expect(space.locations.getAll).not.toHaveBeenCalled(); }); diff --git a/test/unit/commands/spaces/locks/acquire.test.ts b/test/unit/commands/spaces/locks/acquire.test.ts index 24b7275c..28dc228d 100644 --- a/test/unit/commands/spaces/locks/acquire.test.ts +++ b/test/unit/commands/spaces/locks/acquire.test.ts @@ -41,15 +41,15 @@ describe("spaces:locks:acquire command", () => { reason: undefined, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:locks:acquire", "test-space", "my-lock"], 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"); + expect(stderr).toContain("Lock acquired"); + expect(stderr).toContain("my-lock"); }); it("should pass --data JSON to acquisition", async () => { @@ -71,7 +71,7 @@ describe("spaces:locks:acquire command", () => { reason: undefined, }); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "spaces:locks:acquire", "test-space", @@ -85,7 +85,7 @@ describe("spaces:locks:acquire command", () => { expect(space.locks.acquire).toHaveBeenCalledWith("my-lock", { type: "editor", }); - expect(stdout).toContain("Lock acquired"); + expect(stderr).toContain("Lock acquired"); }); it("should error on invalid --data JSON", async () => { @@ -150,9 +150,10 @@ describe("spaces:locks:acquire command", () => { expect(lock).toHaveProperty("attributes", null); expect(lock).toHaveProperty("reason", null); - const status = records.find((r) => r.type === "status"); + const status = records.find( + (r) => r.type === "status" && r.status === "holding", + ); expect(status).toBeDefined(); - expect(status).toHaveProperty("status", "holding"); expect(status!.message).toContain("Holding lock"); }); }); diff --git a/test/unit/commands/spaces/locks/subscribe.test.ts b/test/unit/commands/spaces/locks/subscribe.test.ts index 0a58d90b..ba3735f8 100644 --- a/test/unit/commands/spaces/locks/subscribe.test.ts +++ b/test/unit/commands/spaces/locks/subscribe.test.ts @@ -40,12 +40,12 @@ describe("spaces:locks:subscribe command", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:locks:subscribe", "test-space"], import.meta.url, ); - expect(stdout).toContain("Subscribing to lock events"); + expect(stderr).toContain("Subscribing to lock events"); expect(space.locks.getAll).not.toHaveBeenCalled(); }); diff --git a/test/unit/commands/spaces/members/enter.test.ts b/test/unit/commands/spaces/members/enter.test.ts index 4609a98d..f365e32e 100644 --- a/test/unit/commands/spaces/members/enter.test.ts +++ b/test/unit/commands/spaces/members/enter.test.ts @@ -28,7 +28,7 @@ describe("spaces:members:enter command", () => { const profile = { name: "User", status: "active" }; - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "spaces:members:enter", "test-space", @@ -39,8 +39,8 @@ describe("spaces:members:enter command", () => { ); expect(space.enter).toHaveBeenCalledWith(profile); - expect(stdout).toContain("Entered space"); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("Entered space"); + expect(stderr).toContain("test-space"); }); it("should enter without profile when not provided", async () => { @@ -85,9 +85,10 @@ describe("spaces:members:enter command", () => { expect(member).toHaveProperty("location", null); expect(member).toHaveProperty("lastEvent"); - const status = records.find((r) => r.type === "status"); + const status = records.find( + (r) => r.type === "status" && r.status === "holding", + ); expect(status).toBeDefined(); - expect(status).toHaveProperty("status", "holding"); expect(status!.message).toContain("Holding presence"); }); diff --git a/test/unit/commands/spaces/members/subscribe.test.ts b/test/unit/commands/spaces/members/subscribe.test.ts index 527fe649..64bde35e 100644 --- a/test/unit/commands/spaces/members/subscribe.test.ts +++ b/test/unit/commands/spaces/members/subscribe.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblySpaces } from "../../../../helpers/mock-ably-spaces.js"; import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -102,10 +103,11 @@ describe("spaces:members:subscribe command", () => { import.meta.url, ); - const result = JSON.parse(stdout); - expect(result.type).toBe("event"); - expect(result.member).toBeDefined(); - expect(result.member.clientId).toBe("user-1"); + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "event"); + expect(result).toBeDefined(); + expect(result!.member).toBeDefined(); + expect(result!.member.clientId).toBe("user-1"); }); }); diff --git a/test/unit/commands/spaces/occupancy/subscribe.test.ts b/test/unit/commands/spaces/occupancy/subscribe.test.ts index 88d2ec0c..3702e2bf 100644 --- a/test/unit/commands/spaces/occupancy/subscribe.test.ts +++ b/test/unit/commands/spaces/occupancy/subscribe.test.ts @@ -39,13 +39,13 @@ describe("spaces:occupancy:subscribe command", () => { describe("functionality", () => { it("should subscribe and show initial messages", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:occupancy:subscribe", "test-space"], import.meta.url, ); - expect(stdout).toContain("Subscribing to occupancy events on space"); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("Subscribing to occupancy events on space"); + expect(stderr).toContain("test-space"); }); it("should get channel with mapped name and occupancy params", async () => { diff --git a/test/unit/commands/spaces/spaces.test.ts b/test/unit/commands/spaces/spaces.test.ts index 0ddfd032..0905cf23 100644 --- a/test/unit/commands/spaces/spaces.test.ts +++ b/test/unit/commands/spaces/spaces.test.ts @@ -85,12 +85,12 @@ describe("spaces commands", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:members:enter", "test-space"], import.meta.url, ); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("test-space"); expect(space.enter).toHaveBeenCalled(); }); @@ -98,7 +98,7 @@ describe("spaces commands", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "spaces:members:enter", "test-space", @@ -108,7 +108,7 @@ describe("spaces commands", () => { import.meta.url, ); - expect(stdout).toContain("test-space"); + expect(stderr).toContain("test-space"); expect(space.enter).toHaveBeenCalledWith({ name: "TestUser", status: "online", @@ -184,7 +184,7 @@ describe("spaces commands", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "spaces:locations:set", "test-space", @@ -194,7 +194,7 @@ describe("spaces commands", () => { import.meta.url, ); - expect(stdout).toContain("Location set in space:"); + expect(stderr).toContain("Location set in space:"); expect(space.locations.set).toHaveBeenCalledWith({ x: 100, y: 200 }); }); }); @@ -216,7 +216,7 @@ describe("spaces commands", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( [ "spaces:locks:acquire", "test-space", @@ -227,7 +227,7 @@ describe("spaces commands", () => { import.meta.url, ); - expect(stdout).toContain("Lock acquired:"); + expect(stderr).toContain("Lock acquired:"); expect(space.locks.acquire).toHaveBeenCalledWith("my-lock", { reason: "editing", }); @@ -250,12 +250,12 @@ describe("spaces commands", () => { const spacesMock = getMockAblySpaces(); const space = spacesMock._getSpace("test-space"); - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["spaces:cursors:set", "test-space", "--x", "50", "--y", "75"], import.meta.url, ); - expect(stdout).toContain("Set cursor"); + expect(stderr).toContain("Set cursor"); expect(space.cursors.set).toHaveBeenCalledWith({ position: { x: 50, y: 75 }, }); diff --git a/test/unit/commands/status.test.ts b/test/unit/commands/status.test.ts index 5075e91e..7ae5f581 100644 --- a/test/unit/commands/status.test.ts +++ b/test/unit/commands/status.test.ts @@ -22,9 +22,9 @@ describe("status command", () => { .get("/status/up.json") .reply(200, { status: true }); - const { stdout } = await runCommand(["status"], import.meta.url); + const { stdout, stderr } = await runCommand(["status"], import.meta.url); - expect(stdout).toContain("operational"); + expect(stderr).toContain("operational"); expect(stdout).toContain("No incidents currently reported"); expect(stdout).toContain("https://status.ably.com"); }); @@ -36,10 +36,10 @@ describe("status command", () => { .get("/status/up.json") .reply(200, { status: false }); - const { stdout } = await runCommand(["status"], import.meta.url); + const { stdout, stderr } = await runCommand(["status"], import.meta.url); - expect(stdout).toContain("Incident detected"); - expect(stdout).toContain("open incidents"); + expect(stderr).toContain("Incident detected"); + expect(stderr).toContain("open incidents"); expect(stdout).toContain("https://status.ably.com"); }); }); diff --git a/test/unit/commands/support/ask.test.ts b/test/unit/commands/support/ask.test.ts index cb4b32d2..c1df94e9 100644 --- a/test/unit/commands/support/ask.test.ts +++ b/test/unit/commands/support/ask.test.ts @@ -10,6 +10,7 @@ import { standardFlagTests, standardControlApiErrorTests, } from "../../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; describe("support:ask command", () => { afterEach(() => { @@ -62,7 +63,7 @@ describe("support:ask command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "support:ask"); expect(result).toHaveProperty("success", true); diff --git a/test/unit/commands/test/wait.test.ts b/test/unit/commands/test/wait.test.ts index c8225c54..f81238b5 100644 --- a/test/unit/commands/test/wait.test.ts +++ b/test/unit/commands/test/wait.test.ts @@ -12,22 +12,22 @@ describe("test:wait command", () => { describe("functionality", () => { it("should wait for the specified duration and complete", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["test:wait", "--duration", "1"], import.meta.url, ); - expect(stdout).toContain("Waiting for 1 seconds"); - expect(stdout).toContain("Wait completed successfully"); + expect(stderr).toContain("Waiting for 1 seconds"); + expect(stderr).toContain("Wait completed successfully"); }, 5000); it("should accept -d as alias for --duration", async () => { - const { stdout } = await runCommand( + const { stderr } = await runCommand( ["test:wait", "-d", "1"], import.meta.url, ); - expect(stdout).toContain("Waiting for 1 seconds"); + expect(stderr).toContain("Waiting for 1 seconds"); }, 5000); }); diff --git a/test/unit/commands/version.test.ts b/test/unit/commands/version.test.ts index b2219f37..a8d65626 100644 --- a/test/unit/commands/version.test.ts +++ b/test/unit/commands/version.test.ts @@ -5,6 +5,7 @@ import { standardArgValidationTests, standardFlagTests, } from "../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../helpers/ndjson.js"; describe("version command", () => { standardHelpTests("version", import.meta.url); @@ -32,7 +33,7 @@ describe("version command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "version"); expect(result).toHaveProperty("success", true); From 260f807e989d958c20a31fba32a7a798f8c77702 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:46:27 +0100 Subject: [PATCH 6/9] Update E2E tests for stderr status messages E2E tests updated to check stderr instead of stdout for status/progress messages, matching the new output routing where status goes to stderr and only data goes to stdout. --- test/e2e/channels/channel-history-e2e.test.ts | 10 +-- test/e2e/channels/channels-e2e.test.ts | 22 ++--- test/e2e/connections/connections.test.ts | 16 ++-- .../e2e/control/control-api-workflows.test.ts | 85 +++++++++++++------ test/e2e/push/channels-e2e.test.ts | 11 ++- test/e2e/push/devices-e2e.test.ts | 72 +++++++++------- test/e2e/push/publish-e2e.test.ts | 35 +++++--- test/e2e/push/push-config-e2e.test.ts | 43 +++++++--- test/e2e/rooms/rooms-e2e.test.ts | 6 +- 9 files changed, 189 insertions(+), 111 deletions(-) diff --git a/test/e2e/channels/channel-history-e2e.test.ts b/test/e2e/channels/channel-history-e2e.test.ts index d95ae65e..f75796db 100644 --- a/test/e2e/channels/channel-history-e2e.test.ts +++ b/test/e2e/channels/channel-history-e2e.test.ts @@ -88,15 +88,15 @@ describe("Channel History E2E Tests", () => { expect(publishResult.exitCode).toBe(0); - // Check if publish stdout is empty and provide diagnostic info - if (!publishResult.stdout || publishResult.stdout.trim() === "") { + // Check if publish stderr is empty and provide diagnostic info + if (!publishResult.stderr || publishResult.stderr.trim() === "") { throw new Error( - `Publish command returned empty output. Exit code: ${publishResult.exitCode}, stderr: "${publishResult.stderr}", stdout: "${publishResult.stdout}"`, + `Publish command returned empty stderr. Exit code: ${publishResult.exitCode}, stderr: "${publishResult.stderr}", stdout: "${publishResult.stdout}"`, ); } - expect(publishResult.stdout).toContain(`Message published to channel`); - expect(publishResult.stdout).toContain(historyChannel); + expect(publishResult.stderr).toContain(`Message published to channel`); + expect(publishResult.stderr).toContain(historyChannel); } // Add a delay to ensure messages are stored diff --git a/test/e2e/channels/channels-e2e.test.ts b/test/e2e/channels/channels-e2e.test.ts index 0d9e65b8..19da674f 100644 --- a/test/e2e/channels/channels-e2e.test.ts +++ b/test/e2e/channels/channels-e2e.test.ts @@ -230,14 +230,14 @@ describe("Channel E2E Tests", () => { // Enhanced diagnostic error messages expect(publishResult.exitCode).toBe(0); - if (!publishResult.stdout || publishResult.stdout.trim() === "") { + if (!publishResult.stderr || publishResult.stderr.trim() === "") { throw new Error( `Publish command returned empty stderr. Exit code: ${publishResult.exitCode}, Stderr: ${publishResult.stderr}, Stdout length: ${publishResult.stdout.length}`, ); } - expect(publishResult.stdout).toContain(`Message published to channel`); - expect(publishResult.stdout).toContain(uniqueChannel); + expect(publishResult.stderr).toContain(`Message published to channel`); + expect(publishResult.stderr).toContain(uniqueChannel); // Add a delay to ensure message is stored and available in history await new Promise((resolve) => setTimeout(resolve, 2000)); @@ -406,13 +406,13 @@ describe("Channel E2E Tests", () => { // Enhanced diagnostic error messages expect(batchPublishResult.exitCode).toBe(0); - if (!batchPublishResult.stdout || batchPublishResult.stdout.trim() === "") { + if (!batchPublishResult.stderr || batchPublishResult.stderr.trim() === "") { throw new Error( `Batch publish command returned empty stderr. Exit code: ${batchPublishResult.exitCode}, Stderr: ${batchPublishResult.stderr}, Stdout length: ${batchPublishResult.stdout.length}`, ); } - expect(batchPublishResult.stdout).toContain("Batch publish successful"); + expect(batchPublishResult.stderr).toContain("Batch publish successful"); // Add a delay to ensure message is stored and available in history await new Promise((resolve) => setTimeout(resolve, 2000)); @@ -475,25 +475,25 @@ describe("Channel E2E Tests", () => { // Enhanced diagnostic error messages expect(countPublishResult.exitCode).toBe(0); - if (!countPublishResult.stdout || countPublishResult.stdout.trim() === "") { + if (!countPublishResult.stderr || countPublishResult.stderr.trim() === "") { throw new Error( `Count publish command returned empty stderr. Exit code: ${countPublishResult.exitCode}, Stderr: ${countPublishResult.stderr}, Stdout length: ${countPublishResult.stdout.length}`, ); } - expect(countPublishResult.stdout).toContain( + expect(countPublishResult.stderr).toContain( "Message 1 published to channel", ); - expect(countPublishResult.stdout).toContain( + expect(countPublishResult.stderr).toContain( "Message 2 published to channel", ); - expect(countPublishResult.stdout).toContain( + expect(countPublishResult.stderr).toContain( "Message 3 published to channel", ); - expect(countPublishResult.stdout).toContain( + expect(countPublishResult.stderr).toContain( "3/3 messages published to channel", ); - expect(countPublishResult.stdout).toContain(countChannel); + expect(countPublishResult.stderr).toContain(countChannel); // Add a delay to ensure messages are stored and available in history await new Promise((resolve) => setTimeout(resolve, 2000)); diff --git a/test/e2e/connections/connections.test.ts b/test/e2e/connections/connections.test.ts index 58ab0421..6602624c 100644 --- a/test/e2e/connections/connections.test.ts +++ b/test/e2e/connections/connections.test.ts @@ -8,6 +8,7 @@ import { expect, } from "vitest"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; import { forceExit, cleanupTrackedResources, @@ -57,7 +58,7 @@ describe("Connections E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("WebSocket connection"); + expect(result.stderr).toContain("WebSocket connection"); }, ); @@ -76,7 +77,7 @@ describe("Connections E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("HTTP connection"); + expect(result.stderr).toContain("HTTP connection"); }, ); @@ -113,13 +114,10 @@ describe("Connections E2E Tests", () => { expect(result.exitCode).toBe(0); - // Verify it's valid JSON - let jsonOutput; - try { - jsonOutput = JSON.parse(result.stdout); - } catch { - throw new Error(`Invalid JSON output: ${result.stdout}`); - } + // Parse NDJSON output — find the result record + const records = parseNdjsonLines(result.stdout); + const jsonOutput = records.find((r) => r.type === "result"); + expect(jsonOutput).toBeDefined(); // Check for expected test result structure expect(jsonOutput).toHaveProperty("success"); diff --git a/test/e2e/control/control-api-workflows.test.ts b/test/e2e/control/control-api-workflows.test.ts index ef87e0e1..f6b50e58 100644 --- a/test/e2e/control/control-api-workflows.test.ts +++ b/test/e2e/control/control-api-workflows.test.ts @@ -18,6 +18,7 @@ import { resetTestTracking, } from "../../helpers/e2e-test-helper.js"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe("Control API E2E Workflow Tests", () => { let controlApi: ControlApi; @@ -161,12 +162,17 @@ describe("Control API E2E Workflow Tests", () => { ); expect(createResult.exitCode).toBe(0); - const createOutput = JSON.parse(createResult.stdout); + const createRecords = parseNdjsonLines(createResult.stdout); + const createOutput = createRecords.find( + (r) => r.type === "result", + ) as Record; + expect(createOutput).toBeDefined(); expect(createOutput).toHaveProperty("app"); - expect(createOutput.app).toHaveProperty("id"); - expect(createOutput.app).toHaveProperty("name", appName); + const createdApp = createOutput.app as Record; + expect(createdApp).toHaveProperty("id"); + expect(createdApp).toHaveProperty("name", appName); - const appId = createOutput.app.id; + const appId = createdApp.id as string; createdResources.apps.push(appId); // 2. List apps and verify our app is included @@ -202,11 +208,20 @@ describe("Control API E2E Workflow Tests", () => { }, ); - expect(updateResult.stderr).toBe(""); - const updateOutput = JSON.parse(updateResult.stdout); + const updateRecords = parseNdjsonLines(updateResult.stdout); + const updateOutput = updateRecords.find( + (r) => r.type === "result", + ) as Record; + expect(updateOutput).toBeDefined(); expect(updateOutput).toHaveProperty("app"); - expect(updateOutput.app).toHaveProperty("name", updatedName); - expect(updateOutput.app).toHaveProperty("tlsOnly", true); + expect(updateOutput.app as Record).toHaveProperty( + "name", + updatedName, + ); + expect(updateOutput.app as Record).toHaveProperty( + "tlsOnly", + true, + ); }, ); }); @@ -226,8 +241,10 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const result = JSON.parse(createResult.stdout); - testAppId = result.app.id; + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + testAppId = (result.app as Record).id as string; }); afterAll(async () => { @@ -265,10 +282,16 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const result = JSON.parse(createResult.stdout); + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(result).toBeDefined(); expect(result).toHaveProperty("success", true); - expect(result.key).toHaveProperty("name", keyName); - expect(result.key).toHaveProperty("key"); + expect(result.key as Record).toHaveProperty( + "name", + keyName, + ); + expect(result.key as Record).toHaveProperty("key"); }); it("should list API keys", async () => { @@ -305,8 +328,10 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const result = JSON.parse(createResult.stdout); - testAppId = result.app.id; + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + testAppId = (result.app as Record).id as string; }); afterAll(async () => { @@ -394,8 +419,7 @@ describe("Control API E2E Workflow Tests", () => { }, ); - expect(deleteResult.stderr).toBe(""); - expect(deleteResult.stdout).toContain("deleted successfully"); + expect(deleteResult.stderr).toContain("deleted"); // Remove from cleanup list since we deleted it const index = createdResources.queues.indexOf(queueName); @@ -420,8 +444,10 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const result = JSON.parse(createResult.stdout); - testAppId = result.app.id; + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + testAppId = (result.app as Record).id as string; }); afterAll(async () => { @@ -504,8 +530,10 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const result = JSON.parse(createResult.stdout); - testAppId = result.app.id; + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + testAppId = (result.app as Record).id as string; }); afterAll(async () => { @@ -688,8 +716,7 @@ describe("Control API E2E Workflow Tests", () => { }, ); - expect(deleteResult.stderr).toBe(""); - expect(deleteResult.stdout).toContain("deleted successfully"); + expect(deleteResult.stderr).toContain("deleted"); // 3. Verify the rule is gone by listing const listResult = await runCommand( @@ -780,8 +807,10 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const appOutput = JSON.parse(createAppResult.stdout); - const appId = appOutput.app.id; + const appOutput = parseNdjsonLines(createAppResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const appId = (appOutput.app as Record).id as string; createdResources.apps.push(appId); // 2. Create API key @@ -801,8 +830,10 @@ describe("Control API E2E Workflow Tests", () => { }, ); - const keyOutput = JSON.parse(createKeyResult.stdout); - const keyId = keyOutput.key.id; + const keyOutput = parseNdjsonLines(createKeyResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const keyId = (keyOutput.key as Record).id as string; createdResources.keys.push(keyId); // 3. Create queue diff --git a/test/e2e/push/channels-e2e.test.ts b/test/e2e/push/channels-e2e.test.ts index ea59f17a..92ed85f9 100644 --- a/test/e2e/push/channels-e2e.test.ts +++ b/test/e2e/push/channels-e2e.test.ts @@ -18,6 +18,7 @@ import { createAblyClient, } from "../../helpers/e2e-test-helper.js"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Push Channel Subscriptions E2E Tests", () => { let testDeviceIdBase: string; @@ -131,7 +132,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Channel Subscriptions E2E Tests", () => { expect(result.exitCode).toBe(0); // Output is either "Found N channels." or "No channels with push subscriptions found." expect( - result.stdout.includes("Found") || + result.stderr.includes("Found") || result.stdout.includes("No channels"), ).toBe(true); }); @@ -147,9 +148,11 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Channel Subscriptions E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.channels).toBeInstanceOf(Array); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect(json!.success).toBe(true); + expect(json!.channels).toBeInstanceOf(Array); }); }); diff --git a/test/e2e/push/devices-e2e.test.ts b/test/e2e/push/devices-e2e.test.ts index 3927c293..86aba4c1 100644 --- a/test/e2e/push/devices-e2e.test.ts +++ b/test/e2e/push/devices-e2e.test.ts @@ -18,6 +18,7 @@ import { createAblyClient, } from "../../helpers/e2e-test-helper.js"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { let testDeviceIdBase: string; @@ -78,8 +79,8 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Device registration saved"); - expect(result.stdout).toContain(deviceId); + expect(result.stderr).toContain("Device registration saved"); + expect(result.stderr).toContain(deviceId); // Verify with SDK const device = await client.push.admin.deviceRegistrations.get(deviceId); @@ -119,7 +120,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Device registration saved"); + expect(result.stderr).toContain("Device registration saved"); // Cleanup await client.push.admin.deviceRegistrations.remove(deviceId); @@ -154,11 +155,15 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.device).toBeDefined(); - expect(json.device.id).toBe(deviceId); - expect(json.device.platform).toBe("android"); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect(json!.success).toBe(true); + expect(json!.device as Record).toBeDefined(); + expect((json!.device as Record).id).toBe(deviceId); + expect((json!.device as Record).platform).toBe( + "android", + ); // Cleanup await client.push.admin.deviceRegistrations.remove(deviceId); @@ -222,12 +227,15 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.device).toBeDefined(); - expect(json.device.id).toBe(testDeviceId); - expect(json.device.platform).toBe("android"); - expect(json.device.clientId).toBe("e2e-get-test-user"); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect(json!.success).toBe(true); + const device = json!.device as Record; + expect(device).toBeDefined(); + expect(device.id).toBe(testDeviceId); + expect(device.platform).toBe("android"); + expect(device.clientId).toBe("e2e-get-test-user"); }); it("should handle non-existent device", async () => { @@ -289,8 +297,8 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { }); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Found"); - expect(result.stdout).toContain("device"); + expect(result.stderr).toContain("Found"); + expect(result.stderr).toContain("device"); }); it("should filter by client ID", async () => { @@ -327,10 +335,12 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.devices).toBeInstanceOf(Array); - expect(json.devices.length).toBeGreaterThanOrEqual(3); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect(json!.success).toBe(true); + expect(json!.devices).toBeInstanceOf(Array); + expect((json!.devices as unknown[]).length).toBeGreaterThanOrEqual(3); }); it("should respect --limit flag", async () => { @@ -353,8 +363,10 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.devices.length).toBeLessThanOrEqual(2); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect((json!.devices as unknown[]).length).toBeLessThanOrEqual(2); }); }); @@ -385,8 +397,8 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("removed"); - expect(result.stdout).toContain(deviceId); + expect(result.stderr).toContain("removed"); + expect(result.stderr).toContain(deviceId); // Verify device is gone await expect( @@ -420,10 +432,12 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.device.removed).toBe(true); - expect(json.device.id).toBe(deviceId); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect(json!.success).toBe(true); + expect((json!.device as Record).removed).toBe(true); + expect((json!.device as Record).id).toBe(deviceId); }); }); @@ -468,7 +482,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Devices E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("removed"); + expect(result.stderr).toContain("removed"); // Verify all devices are gone for (const deviceId of deviceIds) { diff --git a/test/e2e/push/publish-e2e.test.ts b/test/e2e/push/publish-e2e.test.ts index fcfc1458..25a14dec 100644 --- a/test/e2e/push/publish-e2e.test.ts +++ b/test/e2e/push/publish-e2e.test.ts @@ -18,6 +18,7 @@ import { createAblyClient, } from "../../helpers/e2e-test-helper.js"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { let testDeviceId: string; @@ -87,7 +88,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("published"); + expect(result.stderr).toContain("published"); }); it("should publish with JSON output", async () => { @@ -111,10 +112,16 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.notification.published).toBe(true); - expect(json.notification.recipient.deviceId).toBe(testDeviceId); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect(json!.success).toBe(true); + expect((json!.notification as Record).published).toBe( + true, + ); + expect((json!.notification as Record).recipient).toEqual( + expect.objectContaining({ deviceId: testDeviceId }), + ); }); it("should publish with custom data payload", async () => { @@ -138,7 +145,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("published"); + expect(result.stderr).toContain("published"); }); it("should publish with full payload", async () => { @@ -161,7 +168,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("published"); + expect(result.stderr).toContain("published"); }); it("should publish to a client ID", async () => { @@ -183,7 +190,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("published"); + expect(result.stderr).toContain("published"); }); it("should error when neither device-id nor client-id provided", async () => { @@ -257,7 +264,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("published"); + expect(result.stderr).toContain("published"); }); it("should batch publish with JSON output", async () => { @@ -278,9 +285,13 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.publish.total).toBe(1); - expect(json.publish.succeeded).toBeDefined(); + const records = parseNdjsonLines(result.stdout); + const json = records.find((r) => r.type === "result"); + expect(json).toBeDefined(); + expect((json!.publish as Record).total).toBe(1); + expect( + (json!.publish as Record).succeeded, + ).toBeDefined(); }); it("should error with invalid batch payload format", async () => { diff --git a/test/e2e/push/push-config-e2e.test.ts b/test/e2e/push/push-config-e2e.test.ts index a360e9e3..dc565988 100644 --- a/test/e2e/push/push-config-e2e.test.ts +++ b/test/e2e/push/push-config-e2e.test.ts @@ -17,6 +17,7 @@ import { resetTestTracking, } from "../../helpers/e2e-test-helper.js"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; import { resolve } from "node:path"; describe("Push Config E2E Tests", () => { @@ -51,8 +52,10 @@ describe("Push Config E2E Tests", () => { }, ); - const result = JSON.parse(createResult.stdout); - testAppId = result.app.id; + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + )!; + testAppId = (result.app as Record).id as string; console.log(`Created test app for push config: ${testAppId}`); }); @@ -97,7 +100,9 @@ describe("Push Config E2E Tests", () => { ); expect(result.exitCode).toBe(0); - const output = JSON.parse(result.stdout); + const output = parseNdjsonLines(result.stdout).find( + (r) => r.type === "result", + )!; expect(output).toHaveProperty("success", true); expect(output).toHaveProperty("appId", testAppId); expect(output.apns).toHaveProperty("configured", false); @@ -166,7 +171,9 @@ describe("Push Config E2E Tests", () => { ); expect(setResult.exitCode).toBe(0); - const setOutput = JSON.parse(setResult.stdout); + const setOutput = parseNdjsonLines(setResult.stdout).find( + (r) => r.type === "result", + )!; expect(setOutput).toHaveProperty("success", true); expect(setOutput).toHaveProperty("method", "p8"); @@ -181,7 +188,9 @@ describe("Push Config E2E Tests", () => { ); expect(showResult.exitCode).toBe(0); - const showOutput = JSON.parse(showResult.stdout); + const showOutput = parseNdjsonLines(showResult.stdout).find( + (r) => r.type === "result", + )!; expect(showOutput.apns).toHaveProperty("configured", true); expect(showOutput.apns).toHaveProperty("hasP8Key", true); expect(showOutput.apns).toHaveProperty("useSandbox", true); @@ -234,7 +243,9 @@ describe("Push Config E2E Tests", () => { ); expect(clearResult.exitCode).toBe(0); - const clearOutput = JSON.parse(clearResult.stdout); + const clearOutput = parseNdjsonLines(clearResult.stdout).find( + (r) => r.type === "result", + )!; expect(clearOutput).toHaveProperty("success", true); expect(clearOutput).toHaveProperty("cleared", "apns"); @@ -249,7 +260,9 @@ describe("Push Config E2E Tests", () => { ); expect(showResult.exitCode).toBe(0); - const showOutput = JSON.parse(showResult.stdout); + const showOutput = parseNdjsonLines(showResult.stdout).find( + (r) => r.type === "result", + )!; expect(showOutput.apns).toHaveProperty("configured", false); expect(showOutput.apns).toHaveProperty("hasP8Key", false); }); @@ -286,7 +299,9 @@ describe("Push Config E2E Tests", () => { ); expect(setResult.exitCode).toBe(0); - const setOutput = JSON.parse(setResult.stdout); + const setOutput = parseNdjsonLines(setResult.stdout).find( + (r) => r.type === "result", + )!; expect(setOutput).toHaveProperty("success", true); // 2. Verify config was set @@ -300,7 +315,9 @@ describe("Push Config E2E Tests", () => { ); expect(showResult.exitCode).toBe(0); - const showOutput = JSON.parse(showResult.stdout); + const showOutput = parseNdjsonLines(showResult.stdout).find( + (r) => r.type === "result", + )!; expect(showOutput.fcm).toHaveProperty("configured", true); }, ); @@ -338,7 +355,9 @@ describe("Push Config E2E Tests", () => { ); expect(clearResult.exitCode).toBe(0); - const clearOutput = JSON.parse(clearResult.stdout); + const clearOutput = parseNdjsonLines(clearResult.stdout).find( + (r) => r.type === "result", + )!; expect(clearOutput).toHaveProperty("success", true); expect(clearOutput).toHaveProperty("cleared", "fcm"); @@ -353,7 +372,9 @@ describe("Push Config E2E Tests", () => { ); expect(showResult.exitCode).toBe(0); - const showOutput = JSON.parse(showResult.stdout); + const showOutput = parseNdjsonLines(showResult.stdout).find( + (r) => r.type === "result", + )!; expect(showOutput.fcm).toHaveProperty("configured", false); }); }); diff --git a/test/e2e/rooms/rooms-e2e.test.ts b/test/e2e/rooms/rooms-e2e.test.ts index a1b4a07c..22f7bcf3 100644 --- a/test/e2e/rooms/rooms-e2e.test.ts +++ b/test/e2e/rooms/rooms-e2e.test.ts @@ -292,9 +292,9 @@ describe("Rooms E2E Tests", () => { // Check for success - either exit code 0 or successful output (even if process was killed after success) const isSuccessful = sendResult.exitCode === 0 || - sendResult.stdout.includes("Message sent to room"); + sendResult.stderr.includes("Message sent to room"); expect(isSuccessful).toBe(true); - expect(sendResult.stdout).toContain("Message sent to room"); + expect(sendResult.stderr).toContain("Message sent to room"); // Wait for the message to be received by the subscriber await waitForOutput( @@ -332,7 +332,7 @@ describe("Rooms E2E Tests", () => { // Check for success - either exit code 0 or successful output (even if process was killed after success) const isSecondSuccessful = sendResult2.exitCode === 0 || - sendResult2.stdout.includes("Message sent to room"); + sendResult2.stderr.includes("Message sent to room"); expect(isSecondSuccessful).toBe(true); // Wait for the second message to be received From 1ed697d9d5302e13ff32793bb701a7c83550ddf2 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 31 Mar 2026 23:46:37 +0100 Subject: [PATCH 7/9] Update documentation and skills for new logging helpers - AGENTS.md: document logging helpers, completed signal, JSON hold status - Skills: update patterns, review checks, and code examples to reflect new logProgress/logSuccessMessage/logListening/logHolding/logWarning helpers instead of old manual formatX + shouldOutputJson pattern - README.md: regenerated from command metadata --- .claude/skills/ably-codebase-review/SKILL.md | 10 +-- .claude/skills/ably-new-command/SKILL.md | 27 +++--- .../ably-new-command/references/patterns.md | 84 ++++++++++--------- .../ably-new-command/references/testing.md | 8 +- .claude/skills/ably-review/SKILL.md | 4 +- AGENTS.md | 18 ++-- README.md | 28 +++---- 7 files changed, 93 insertions(+), 86 deletions(-) diff --git a/.claude/skills/ably-codebase-review/SKILL.md b/.claude/skills/ably-codebase-review/SKILL.md index 0f73f7ba..b5b14140 100644 --- a/.claude/skills/ably-codebase-review/SKILL.md +++ b/.claude/skills/ably-codebase-review/SKILL.md @@ -94,14 +94,14 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses ### Agent 3: Output Formatting Sweep -**Goal:** Verify all human output uses the correct format helpers and is JSON-guarded. +**Goal:** Verify all human output uses the correct format helpers and correct output method (stderr for status, stdout for data). **Method (grep/read — text patterns):** 1. **Grep** for `chalk\.cyan\(` in command files — should use `formatResource()` instead 2. **Grep** for `formatProgress(` and check matches for manual `...` appended 3. **Grep** for `formatSuccess(` and read the lines to check they end with `.` -4. **Grep** for `shouldOutputJson` to find all JSON-aware commands -5. **Read** command files and look for unguarded `this.log()` calls (not inside `if (!this.shouldOutputJson(flags))`) +4. **Grep** for `this\.log(formatProgress\|this\.log(formatSuccess\|this\.log(formatListening\|this\.log(formatWarning` and `this\.logToStderr(formatProgress\|this\.logToStderr(formatSuccess\|this\.logToStderr(formatListening\|this\.logToStderr(formatWarning` — these must use the base command helpers instead: `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)`. In non-JSON mode all helpers emit to stderr. In JSON mode: `logProgress` and `logSuccessMessage` are **silent** (no-ops), while `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout. `logSuccessMessage` should be inside the `else` block after `logJsonResult`. Also check these helpers are NOT inside `shouldOutputJson` guards — they don't need them. +5. **Grep** for `shouldOutputJson` to find all JSON-aware commands and verify data output is properly branched 6. **Grep** for quoted resource names patterns like `"${` or `'${` near `channel`, `name`, `app` variables — should use `formatResource()` **Method (grep — structured output format):** @@ -155,7 +155,7 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses 4. Cross-reference: every leaf command should appear in both the `logJsonResult`/`logJsonEvent` list and the `shouldOutputJson` list 5. **Read** streaming commands to verify they use `logJsonEvent`, one-shot commands use `logJsonResult` 6. **Read** each `logJsonResult`/`logJsonEvent` call and verify data is nested under a domain key — singular for events/single items (e.g., `{message: ...}`, `{cursor: ...}`), plural for collections (e.g., `{cursors: [...]}`, `{rules: [...]}`). Top-level envelope fields are `type`, `command`, `success` only. Metadata like `total`, `timestamp`, `appId` may sit alongside the domain key. -7. **Check** hold commands (set, enter, acquire) emit `logJsonStatus("holding", ...)` after `logJsonResult` — this signals to JSON consumers that the command is alive and waiting for Ctrl+C / `--duration` +7. **Check** hold commands (set, enter, acquire) emit `this.logHolding(...)` after `logJsonResult` — this emits `status: "holding"` in JSON mode, signaling to consumers that the command is alive and waiting for Ctrl+C / `--duration`. For passive subscribe commands, check for `this.logListening(...)` instead (emits `status: "listening"`) **Reasoning guidance:** - Commands that ONLY have human output (no JSON path) are deviations @@ -163,7 +163,7 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses - Topic index commands (showing help) don't need JSON output - Data spread at the top level without a domain key is a deviation — nest under a singular or plural domain noun - Metadata fields (`total`, `timestamp`, `hasMore`, `appId`) alongside the domain key are acceptable — they describe the result, not the domain objects -- Hold commands missing `logJsonStatus` after `logJsonResult` are deviations — JSON consumers need the hold signal +- Hold commands missing `logHolding` after `logJsonResult` are deviations — JSON consumers need the hold signal ### Agent 6: Test Pattern Sweep diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index 99883c1d..65c79874 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -210,19 +210,15 @@ export default class TopicAction extends AblyBaseCommand { ### Output patterns -The CLI has specific output helpers in `src/utils/output.ts`. All human-readable output must be wrapped in a JSON guard: +The base command provides five logging helpers: `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)`. These do NOT need `shouldOutputJson` guards. In non-JSON mode they all emit formatted text on stderr. In JSON mode: `logProgress` and `logSuccessMessage` are **silent** (no-ops — the JSON result/event records already convey progress and success), while `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout. `logSuccessMessage` should be placed inside the `else` block after `logJsonResult` for readability. Only human-readable **data output** (field labels, headings, record blocks) needs the `if/else` guard: ```typescript -// JSON guard — all human output goes through this -if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Attaching to channel: " + formatResource(channelName))); -} +// Progress/success/listening — no guard needed, helpers handle both modes +this.logProgress("Attaching to channel: " + formatResource(channelName), flags); // After success: -if (!this.shouldOutputJson(flags)) { - this.log(formatSuccess("Subscribed to channel: " + formatResource(channelName) + ".")); - this.log(formatListening("Listening for messages.")); -} +this.logSuccessMessage("Subscribed to channel: " + formatResource(channelName) + ".", flags); +this.logListening("Listening for messages.", flags); // JSON output — nest data under a domain key, not spread at top level. // Envelope provides top-level: type, command, success. @@ -276,7 +272,11 @@ Rules: - `formatHeading(text)` — bold, for record headings in lists - `formatIndex(n)` — dim bracketed number, for history ordering - Use `this.fail()` for all errors (see Error handling below), never `this.log(chalk.red(...))` -- Never use `console.log` or `console.error` — always `this.log()` or `this.logToStderr()` +- Never use `console.log` or `console.error` — always `this.log()` for data or the logging helpers for status messages +- Use `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)` for status messages — no `shouldOutputJson` guard needed. In non-JSON mode all emit to stderr. In JSON mode: `logProgress` and `logSuccessMessage` are silent; `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout +- Do NOT use the old pattern `this.logToStderr(formatProgress/Success/Listening/Warning(...))` — use the helpers instead +- `formatPaginationLog()` output still uses `this.logToStderr()` directly (not a helper yet) +- `formatLabel()`, `formatHeading()`, `formatIndex()`, `formatTimestamp()`, `formatClientId()`, `formatEventType()` → `this.log()` inside the non-JSON branch ### JSON envelope — reserved keys @@ -454,9 +454,10 @@ See the "Keeping Skills Up to Date" section in `CLAUDE.md` for the full list of - [ ] Correct base class (`AblyBaseCommand`, `ControlBaseCommand`, `ChatBaseCommand`, `SpacesBaseCommand`, or `StatsBaseCommand`) - [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`) - [ ] `clientIdFlag` only if command needs client identity -- [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))` -- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatWarning`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`) -- [ ] `formatSuccess()` messages end with `.` (period) +- [ ] Human data output wrapped in `if (!this.shouldOutputJson(flags))` — but `logProgress`, `logSuccessMessage`, `logListening`, `logWarning` helpers do NOT need guards +- [ ] Status messages use base command helpers (`this.logProgress`, `this.logSuccessMessage`, `this.logListening`, `this.logWarning`) — NOT `this.logToStderr(formatProgress/Success/Listening/Warning(...))` +- [ ] Output formatters used correctly for data display (`formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`) +- [ ] `logSuccessMessage()` messages end with `.` (period) - [ ] Non-JSON data output uses multi-line labeled blocks (see `patterns.md` "Human-Readable Output Format"), not tables or custom grids - [ ] Non-JSON output exposes all available SDK fields (same data as JSON mode, omitting only null/empty values) - [ ] SDK types imported directly (`import type { CursorUpdate } from "@ably/spaces"`) — no local interface redefinitions of SDK types (display interfaces in `src/utils/` are fine) diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index ecae904f..440e5860 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -44,15 +44,11 @@ async run(): Promise { // Returns a cleanup function, but cleanup is handled automatically by base command. this.setupChannelStateLogging(channel, flags); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Attaching to channel: " + formatResource(args.channel))); - } + this.logProgress("Attaching to channel: " + formatResource(args.channel), flags); channel.once("attached", () => { - if (!this.shouldOutputJson(flags)) { - this.log(formatSuccess("Attached to channel: " + formatResource(args.channel) + ".")); - this.log(formatListening("Listening for events.")); - } + this.logSuccessMessage("Attached to channel: " + formatResource(args.channel) + ".", flags); + this.logListening("Listening for events.", flags); }); let sequenceCounter = 0; @@ -115,9 +111,7 @@ async run(): Promise { const channel = rest.channels.get(args.channel); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Publishing to channel: " + formatResource(args.channel))); - } + this.logProgress("Publishing to channel: " + formatResource(args.channel), flags); try { const message: Partial = { @@ -131,9 +125,9 @@ async run(): Promise { // Nest data under a domain key. Don't use "success" as a data key // for batch summaries — it overrides the envelope's success field. Use "allSucceeded". this.logJsonResult({ message: { channel: args.channel, name: message.name, data: message.data } }, flags); - } else { - this.log(formatSuccess("Message published to channel: " + formatResource(args.channel) + ".")); } + + this.logSuccessMessage("Message published to channel: " + formatResource(args.channel) + ".", flags); } catch (error) { this.fail(error, flags, "publish", { channel: args.channel }); } @@ -179,17 +173,15 @@ async run(): Promise { // NO room.attach() — update/delete/annotate are REST calls const room = await chatClient.rooms.get(args.room); - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Updating message " + formatResource(args.serial) + " in room " + formatResource(args.room))); - } + this.logProgress("Updating message " + formatResource(args.serial) + " in room " + formatResource(args.room), flags); const result = await room.messages.update(args.serial, updateParams, details); if (this.shouldOutputJson(flags)) { this.logJsonResult({ room: args.room, serial: args.serial, versionSerial: result.version.serial }, flags); - } else { - this.log(formatSuccess(`Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`)); } + + this.logSuccessMessage(`Message ${formatResource(args.serial)} updated in room ${formatResource(args.room)}.`, flags); } catch (error) { this.fail(error, flags, "roomMessageUpdate", { room: args.room, serial: args.serial }); } @@ -227,15 +219,18 @@ async run(): Promise { const { items: messages, hasMore, pagesConsumed } = await collectPaginatedResults(history, flags.limit); const paginationWarning = formatPaginationLog(pagesConsumed, messages.length); - if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + if (paginationWarning) { + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { // Plural domain key for collections, optional metadata alongside this.logJsonResult({ messages, hasMore, total: messages.length }, flags); - } else { - this.log(formatSuccess(`Found ${messages.length} messages.`)); + } + + this.logSuccessMessage(`Found ${messages.length} messages.`, flags); + + if (!this.shouldOutputJson(flags)) { // Display each message using multi-line labeled blocks if (hasMore) { @@ -328,9 +323,7 @@ async run(): Promise { } } - if (!this.shouldOutputJson(flags)) { - this.log(formatProgress("Entering presence on channel: " + formatResource(args.channel))); - } + this.logProgress("Entering presence on channel: " + formatResource(args.channel), flags); // Optionally subscribe to other members' events before entering if (flags["show-others"]) { @@ -342,10 +335,8 @@ async run(): Promise { await channel.presence.enter(presenceData); - if (!this.shouldOutputJson(flags)) { - this.log(formatSuccess("Entered presence on channel: " + formatResource(args.channel) + ".")); - this.log(formatListening("Present on channel.")); - } + this.logSuccessMessage("Entered presence on channel: " + formatResource(args.channel) + ".", flags); + this.logListening("Present on channel.", flags); await this.waitAndTrackCleanup(flags, "presence", flags.duration); } @@ -434,8 +425,8 @@ async run(): Promise { const { items, hasMore, pagesConsumed } = await collectPaginatedResults(firstPage, flags.limit); const paginationWarning = formatPaginationLog(pagesConsumed, items.length); - if (paginationWarning && !this.shouldOutputJson(flags)) { - this.log(paginationWarning); + if (paginationWarning) { + this.logToStderr(paginationWarning); } if (this.shouldOutputJson(flags)) { @@ -486,8 +477,11 @@ async run(): Promise { if (this.shouldOutputJson(flags)) { // Singular domain key for single-item results this.logJsonResult({ resource: result }, flags); - } else { - this.log(formatSuccess("Resource created: " + formatResource(result.id) + ".")); + } + + this.logSuccessMessage("Resource created: " + formatResource(result.id) + ".", flags); + + if (!this.shouldOutputJson(flags)) { // Display additional fields using formatLabel this.log(`${formatLabel("Status")} ${result.status}`); this.log(`${formatLabel("Created")} ${new Date(result.createdAt).toISOString()}`); @@ -669,7 +663,7 @@ Commands must behave strictly according to their documented purpose — no unint **Set / enter / acquire commands** — hold state until Ctrl+C / `--duration`: - Enter space (manual: `enterSpace: false` + `space.enter()` + `markAsEntered()`), perform operation, output confirmation, then hold with `waitAndTrackCleanup` -- Emit `formatListening("Holding .")` (human) and `logJsonStatus("holding", ...)` (JSON) +- Emit `this.logHolding("Holding . Press Ctrl+C to exit.", flags)` — in non-JSON mode this shows dim text on stderr; in JSON mode it emits `status: "holding"` - **NOT subscribe** to other events — that is what subscribe commands are for **Side-effect rules:** @@ -706,8 +700,7 @@ await this.space!.enter(); this.markAsEntered(); await this.space!.locations.set(location); // output result... -this.log(formatListening("Holding location.")); -this.logJsonStatus("holding", "Holding location. Press Ctrl+C to exit.", flags); +this.logHolding("Holding location. Press Ctrl+C to exit.", flags); await this.waitAndTrackCleanup(flags, "location", flags.duration); ``` @@ -752,22 +745,21 @@ this.logJsonResult({ channels: items, total, hasMore }, flags); // channel Metadata fields (`total`, `timestamp`, `hasMore`, `appId`) may sit alongside the collection key since they describe the result, not the domain objects. -### Hold status for long-running commands (logJsonStatus) +### Hold status for long-running commands (logHolding) -Long-running commands that hold state (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must emit a status line after the result so JSON consumers know the command is alive and waiting: +Long-running commands that hold state (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must call `this.logHolding(...)` after the result so JSON consumers know the command is alive and waiting. For passive subscribe/stream commands, use `this.logListening(...)` instead. ```typescript // After the result output: if (this.shouldOutputJson(flags)) { this.logJsonResult({ member: formatMemberOutput(self!) }, flags); } else { - this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); + this.logSuccessMessage(`Entered space: ${formatResource(spaceName)}.`, flags); // ... labels ... - this.log(formatListening("Holding presence.")); } -// logJsonStatus has built-in shouldOutputJson guard — no outer if needed -this.logJsonStatus("holding", "Holding presence. Press Ctrl+C to exit.", flags); +// logHolding: in non-JSON mode emits dim text on stderr; in JSON mode emits status: "holding" +this.logHolding("Holding presence. Press Ctrl+C to exit.", flags); await this.waitAndTrackCleanup(flags, "member", flags.duration); ``` @@ -778,6 +770,16 @@ This emits two NDJSON lines in `--json` mode: {"type":"status","command":"spaces:members:enter","status":"holding","message":"Holding presence. Press Ctrl+C to exit."} ``` +**Behavior matrix for logging helpers:** + +| Helper | Non-JSON | JSON | +|--------|----------|------| +| `logProgress` | stderr | silent (no-op) | +| `logSuccessMessage` | stderr | silent (no-op) | +| `logWarning` | stderr | stdout (status: "warning") | +| `logListening` | stderr | stdout (status: "listening") | +| `logHolding` | stderr | stdout (status: "holding") | + ### Choosing the domain key name | Scenario | Key | Example | diff --git a/.claude/skills/ably-new-command/references/testing.md b/.claude/skills/ably-new-command/references/testing.md index a0d110ad..1d8a80d9 100644 --- a/.claude/skills/ably-new-command/references/testing.md +++ b/.claude/skills/ably-new-command/references/testing.md @@ -22,7 +22,7 @@ For subscribe/realtime commands. Uses `getMockAblyRealtime()` and standard test import { describe, it, expect, beforeEach, vi } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; -import { captureJsonLogs } from "../../../helpers/ndjson.js"; +import { captureJsonLogs, parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -152,6 +152,7 @@ For history/get commands. Uses `getMockAblyRest()` and standard test helpers. import { describe, it, expect, beforeEach } from "vitest"; import { runCommand } from "@oclif/test"; import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -205,7 +206,7 @@ describe("topic:action command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "topic:action"); expect(result).toHaveProperty("success", true); @@ -244,6 +245,7 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; +import { parseJsonOutput } from "../../../helpers/ndjson.js"; import { standardHelpTests, standardArgValidationTests, @@ -292,7 +294,7 @@ describe("topic:action command", () => { import.meta.url, ); - const result = JSON.parse(stdout); + const result = parseJsonOutput(stdout); expect(result).toHaveProperty("type", "result"); expect(result).toHaveProperty("command", "topic:action"); expect(result).toHaveProperty("success", true); diff --git a/.claude/skills/ably-review/SKILL.md b/.claude/skills/ably-review/SKILL.md index 1fad1e1a..c226391d 100644 --- a/.claude/skills/ably-review/SKILL.md +++ b/.claude/skills/ably-review/SKILL.md @@ -97,7 +97,7 @@ For each changed command file, run the relevant checks. Spawn agents for paralle 2. **Grep** for `chalk\.yellow\(` — should use `formatWarning()` instead 3. **Grep** for `formatProgress(` and check for manual `...` appended 4. **Grep** for `formatSuccess(` and check lines end with `.` -5. **Read** the file and look for unguarded `this.log()` calls (not inside `if (!this.shouldOutputJson(flags))`) +5. **Grep** for `this\.log(formatProgress\|this\.log(formatSuccess\|this\.log(formatListening\|this\.log(formatWarning` and `this\.logToStderr(formatProgress\|this\.logToStderr(formatSuccess\|this\.logToStderr(formatListening\|this\.logToStderr(formatWarning` — these must use the base command helpers instead: `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)`. In non-JSON mode all helpers emit to stderr. In JSON mode: `logProgress` and `logSuccessMessage` are **silent** (no-ops), while `logListening` (status: "listening"), `logHolding` (status: "holding"), and `logWarning` (status: "warning") emit structured JSON on stdout. `logSuccessMessage` should be inside the `else` block after `logJsonResult`. Also check these helpers are NOT inside `shouldOutputJson` guards — they don't need them. 6. Look for quoted resource names instead of `formatResource(name)` 7. **Grep** for box-drawing characters (`┌`, `┬`, `├`, `└`, `─.*─`, `│`) — non-JSON output must use multi-line labeled blocks, not ASCII tables or grids 8. **Read** the file and check that non-JSON data output uses `formatLabel()` for field labels in multi-line blocks, not inline or single-line formatting @@ -120,7 +120,7 @@ For each changed command file, run the relevant checks. Spawn agents for paralle 3. **Grep** for `shouldOutputJson` — verify human output is guarded 4. **Read** the file to verify streaming commands use `logJsonEvent` and one-shot commands use `logJsonResult` 5. **Read** `logJsonResult`/`logJsonEvent` call sites and check data is nested under a domain key (singular for events/single items, plural for collections) — not spread at top level. Top-level envelope fields are `type`, `command`, `success` only. Metadata like `total`, `timestamp`, `appId` may sit alongside the domain key. -6. **Check** hold commands (set, enter, acquire) emit `logJsonStatus("holding", ...)` after `logJsonResult` — this signals to JSON consumers that the command is alive and waiting for Ctrl+C / `--duration` +6. **Check** hold commands (set, enter, acquire) emit `this.logHolding(...)` after `logJsonResult` — this emits `status: "holding"` in JSON mode, signaling to consumers that the command is alive and waiting for Ctrl+C / `--duration`. For passive subscribe commands, check for `this.logListening(...)` instead (emits `status: "listening"`) **Control API helper check (grep — for Control API commands only):** 1. **Grep** for `resolveAppId` — should use `requireAppId` instead (encapsulates null check and `fail()`) diff --git a/AGENTS.md b/AGENTS.md index 573f8d38..5e5be7b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ This is the Ably CLI npm package (`@ably/cli`), built with the [oclif framework] 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. +8. **`console.log` / `console.error`** - In commands, always use `this.log()` (stdout) for data/results and the logging helpers (`this.logProgress()`, `this.logSuccessMessage()`, `this.logListening()`, `this.logHolding()`, `this.logWarning()`) for status messages. `console.*` bypasses oclif and can't be captured by tests. ## Correct Practices @@ -99,7 +99,7 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v - **`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 where the user might want to control which client identity performs the operation. This includes: commands that create a realtime connection (subscribe, presence enter/subscribe, spaces, etc.), publish, and REST mutations where permissions may depend on the client (update, delete, append). Do NOT add globally. +- **`clientIdFlag`** — `--client-id`. Add to commands where client identity affects the operation: subscribe, publish, enter, set, acquire, update, delete, append. Do NOT add to read-only queries (get, get-all, occupancy get) — Ably capabilities are operation-based, not clientId-based, so client identity is irrelevant for pure reads. Do NOT add globally. - **`durationFlag`** — `--duration` / `-D`. Use for long-running subscribe/stream commands that auto-exit after N seconds. - **`rewindFlag`** — `--rewind`. Use for subscribe commands that support message replay (default: 0). - **`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"`). @@ -205,10 +205,11 @@ pnpm test test/unit/commands/foo.test.ts # Specific test All output helpers use the `format` prefix and are exported from `src/utils/output.ts`: -- **Progress**: `formatProgress("Attaching to channel: " + formatResource(name))` — no color on action text, appends `...` automatically. Never manually write `"Doing something..."` — always use `formatProgress("Doing something")`. -- **Success**: `formatSuccess("Message published to channel " + formatResource(name) + ".")` — green checkmark, **must** end with `.` (not `!`). Never use `chalk.green(...)` directly — always use `formatSuccess()`. -- **Warnings**: `formatWarning("Message text here.")` — yellow `⚠` symbol. Never use `chalk.yellow("Warning: ...")` directly — always use `formatWarning()`. Don't include "Warning:" prefix in the message — the symbol conveys it. -- **Listening**: `formatListening("Listening for messages.")` — dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `formatSuccess()` call — use a separate `formatListening()` call. +- **Progress**: `this.logProgress("Attaching to channel: " + formatResource(name), flags)` — no color on action text, appends `...` automatically. Silent in JSON mode (structured events convey the same info). Never manually write `"Doing something..."` — always use `logProgress`. +- **Success**: `this.logSuccessMessage("Message published to channel " + formatResource(name) + ".", flags)` — green checkmark, **must** end with `.` (not `!`). Silent in JSON mode (the result record's `success: true` already conveys this). Never use `chalk.green(...)` directly — always use `logSuccessMessage`. Place inside `else` block after `logJsonResult`. +- **Warnings**: `this.logWarning("Message text here.", flags)` — yellow `⚠` symbol. Emits structured JSON in JSON mode (agents need actionable warnings). Never use `chalk.yellow("Warning: ...")` directly — always use `logWarning`. Don't include "Warning:" prefix in the message — the symbol conveys it. +- **Listening**: `this.logListening("Listening for messages.", flags)` — dim, includes "Press Ctrl+C to exit." Emits `status: "listening"` in JSON mode. Use for passive subscribe/stream commands. Don't combine listening text inside a `logSuccessMessage()` call. +- **Holding**: `this.logHolding("Holding presence. Press Ctrl+C to exit.", flags)` — same visual as listening for humans. Emits `status: "holding"` in JSON mode. Use for commands that hold state (enter, set, acquire). - **Resource names**: Always `formatResource(name)` (cyan), never quoted — including in `logCliEvent` messages. - **Timestamps**: `formatTimestamp(ts)` — dim `[timestamp]` for event streams. `formatMessageTimestamp(message.timestamp)` — converts Ably message timestamp (number|undefined) to ISO string. - **Labels**: `formatLabel("Field Name")` — dim with colon appended, for field names in structured output. @@ -222,9 +223,10 @@ All output helpers use the `format` prefix and are exported from `src/utils/outp - **Filtered pagination**: `collectFilteredPaginatedResults(firstPage, limit, filter, maxPages?)` — same as above but applies a client-side filter. Use for rooms/spaces list where channels need prefix filtering. `maxPages` (default: 20) prevents runaway requests. - **Pagination warning**: `formatPaginationLog(pagesConsumed, itemCount, isBillable?)` — shows "Fetched N pages" when `pagesConsumed > 1`. Pass `isBillable: true` for history commands (each message retrieved counts as a billable message). Guard with `!this.shouldOutputJson(flags)`. - **Pagination next hint**: `buildPaginationNext(hasMore, lastTimestamp?)` — returns `{ hint, start? }` for JSON output when `hasMore` is true. Pass `lastTimestamp` only for history commands (which have `--start`). -- **JSON guard**: All human-readable output (progress, success, listening messages) must be wrapped in `if (!this.shouldOutputJson(flags))` so it doesn't pollute `--json` output. Only JSON payloads should be emitted when `--json` is active. +- **Logging helpers**: The base command provides five helpers: `this.logProgress(msg, flags)`, `this.logSuccessMessage(msg, flags)`, `this.logListening(msg, flags)`, `this.logHolding(msg, flags)`, `this.logWarning(msg, flags)`. These do NOT need `shouldOutputJson` guards. In non-JSON mode they all emit formatted text on stderr. In JSON mode: `logProgress` and `logSuccessMessage` are **silent** (no-ops), while `logListening`, `logHolding`, and `logWarning` emit structured JSON on stdout. `logSuccessMessage` should be inside the `else` block after `logJsonResult` for readability. Only human-readable **data output** (field labels, headings, record blocks) still needs the `if/else` pattern with `shouldOutputJson` to switch between JSON and human-readable formats. `formatPaginationLog()` output still uses `this.logToStderr(paginationWarning)` directly (not a helper yet). - **JSON envelope**: Use `this.logJsonResult(data, flags)` for one-shot results, `this.logJsonEvent(data, flags)` for streaming events, and `this.logJsonStatus(status, message, flags)` for hold/status signals in long-running commands. The envelope adds top-level fields (`type`, `command`, `success?`). Nest domain data under a **domain key** (see "JSON data nesting convention" below). Do NOT add ad-hoc `success: true/false` — the envelope handles it. `--json` produces compact single-line output (NDJSON for streaming). `--pretty-json` is unchanged. -- **JSON hold status**: Long-running hold commands (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must emit a `logJsonStatus("holding", "Holding . Press Ctrl+C to exit.", flags)` line after the result. This tells LLM agents and scripts that the command is alive and waiting. `logJsonStatus` has a built-in `shouldOutputJson` guard — no outer `if` needed. +- **JSON hold status**: Long-running hold commands (e.g. `spaces members enter`, `spaces locations set`, `spaces locks acquire`, `spaces cursors set`) must call `this.logHolding("Holding . Press Ctrl+C to exit.", flags)` after the result. This emits `status: "holding"` in JSON mode, telling agents the command is alive and waiting. For passive subscribe commands, use `this.logListening(...)` instead (emits `status: "listening"`). +- **JSON completed signal**: Every command automatically emits a `{"type":"status","status":"completed","exitCode":0|1}` line as the very last JSON output when the command finishes. This is emitted in `finally()` in `AblyBaseCommand` — commands do NOT need to emit it manually. It tells LLM agents and scripts that the command is finished and there will be no more output. Exit code 0 = success, 1 = error. The completed signal respects `--pretty-json`. - **JSON errors**: Use `this.fail(error, flags, component, context?)` as the single error funnel in command `run()` methods. It logs the CLI event, preserves structured error data (Ably codes, HTTP status), emits JSON error envelope when `--json` is active, and calls `this.error()` for human-readable output. Returns `never` — no `return;` needed after calling it. Do NOT call `this.error()` directly — it is an internal implementation detail of `fail`. The JSON error envelope nests error details under an `error` object: `{ error: { message, code?, statusCode?, hint? }, ...context }`. - **Inline error extraction**: For commands that report per-item errors inline (e.g., batch publish, connections test), use `extractErrorInfo(error)` from `src/utils/errors.ts` to produce a structured `{ message, code?, statusCode?, href? }` object. This is for embedding error data in result objects — not for fatal errors (use `this.fail()` for those). - **History output**: Use `[index] [timestamp]` on the same line as a heading: `` `${formatIndex(index + 1)} ${formatTimestamp(timestamp)}` ``, then fields indented below. This is distinct from **get-all output** which uses `[index]` alone on its own line. See `references/patterns.md` "History results" and "One-shot results" for both patterns. diff --git a/README.md b/README.md index 0f6f00b9..ca11d84d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ npm install -g @ably/cli $ ably COMMAND running command... $ ably (--version) -@ably/cli/0.17.0 darwin-arm64 node-v22.14.0 +@ably/cli/0.17.0 darwin-arm64 node-v24.4.1 $ ably --help [COMMAND] USAGE $ ably COMMAND @@ -361,7 +361,7 @@ ARGUMENTS ALIAS Alias of the account to log out from (defaults to current account) FLAGS - -f, --force Force logout without confirmation + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --json Output in JSON format --pretty-json Output in colorized JSON format @@ -516,7 +516,7 @@ ARGUMENTS APPID App ID to delete (uses current app if not specified) FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format @@ -659,7 +659,7 @@ ARGUMENTS NAMEORID Name or ID of the rule to delete FLAGS - -f, --force Force deletion without confirmation + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format @@ -1114,15 +1114,15 @@ Revoke an API key (permanently disables the key) ``` USAGE - $ ably auth keys revoke KEYNAME [-v] [--json | --pretty-json] [--app ] [--force] + $ ably auth keys revoke KEYNAME [-v] [--json | --pretty-json] [--app ] [-f] ARGUMENTS KEYNAME Key name (APP_ID.KEY_ID) of the key to revoke FLAGS + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) - --force Skip confirmation prompt --json Output in JSON format --pretty-json Output in colorized JSON format @@ -2427,7 +2427,7 @@ ARGUMENTS INTEGRATIONID The integration ID to delete FLAGS - -f, --force Force deletion without confirmation + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format @@ -3121,7 +3121,7 @@ USAGE [-f] FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --channel= (required) Channel name to unsubscribe from --client-id= Client ID to unsubscribe @@ -3152,7 +3152,7 @@ USAGE [-f] FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --channel= (required) Channel name to filter by --client-id= Filter by client ID @@ -3239,7 +3239,7 @@ USAGE $ ably push config clear-apns [-v] [--json | --pretty-json] [--app ] [-f] FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format @@ -3267,7 +3267,7 @@ USAGE $ ably push config clear-fcm [-v] [--json | --pretty-json] [--app ] [-f] FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format @@ -3479,7 +3479,7 @@ ARGUMENTS DEVICE-ID The device ID to remove FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --json Output in JSON format --pretty-json Output in colorized JSON format @@ -3506,7 +3506,7 @@ USAGE $ ably push devices remove-where [-v] [--json | --pretty-json] [--device-id ] [--client-id ] [-f] FLAGS - -f, --force Skip confirmation prompt + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --client-id= Filter by client ID --device-id= Filter by device ID @@ -3711,7 +3711,7 @@ ARGUMENTS QUEUEID ID of the queue to delete FLAGS - -f, --force Force deletion without confirmation + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format From f96f30da01e7acb7d8e2d871670fea227d648c42 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 1 Apr 2026 12:19:34 +0100 Subject: [PATCH 8/9] Fix JSON mode bugs: guard interactive prompts, route warnings to stderr, enforce --force - Respect --json with --token-only in auth token commands - Auto-remove invalid/revoked keys in JSON mode instead of hanging on prompts - Enforce --force flag for push publish commands in JSON mode - Route formatLimitWarning output to stderr (19 commands) - Migrate this.warn() to this.logWarning() for structured JSON warnings - Fix premature logListening emission before subscription is ready - Fix shouldOutputJson({}) dead code in bench commands --- README.md | 5 ++-- src/base-command.ts | 28 ++++++++++------- src/commands/accounts/current.ts | 5 ++-- src/commands/accounts/switch.ts | 2 +- src/commands/apps/current.ts | 3 +- src/commands/apps/list.ts | 2 +- src/commands/apps/rules/list.ts | 2 +- src/commands/auth/issue-ably-token.ts | 6 +++- src/commands/auth/issue-jwt-token.ts | 6 +++- src/commands/auth/keys/list.ts | 2 +- src/commands/auth/keys/revoke.ts | 18 ++++++----- src/commands/auth/revoke-token.ts | 15 ++++++---- src/commands/bench/publisher.ts | 4 ++- src/commands/bench/subscriber.ts | 4 ++- src/commands/channels/annotations/get.ts | 2 +- src/commands/channels/history.ts | 2 +- src/commands/channels/list.ts | 2 +- src/commands/channels/presence/get.ts | 2 +- src/commands/integrations/list.ts | 2 +- .../logs/connection-lifecycle/history.ts | 2 +- src/commands/logs/history.ts | 2 +- src/commands/logs/push/history.ts | 2 +- src/commands/push/batch-publish.ts | 30 +++++++++++++------ src/commands/push/channels/list-channels.ts | 2 +- src/commands/push/channels/list.ts | 2 +- src/commands/push/devices/list.ts | 2 +- src/commands/push/publish.ts | 18 ++++++----- src/commands/queues/list.ts | 2 +- src/commands/rooms/list.ts | 2 +- src/commands/rooms/messages/history.ts | 2 +- src/commands/rooms/presence/get.ts | 2 +- src/commands/spaces/list.ts | 2 +- src/commands/spaces/locations/subscribe.ts | 4 +-- src/commands/spaces/members/subscribe.ts | 4 +-- src/stats-base-command.ts | 7 +++-- test/unit/commands/logs/history.test.ts | 6 ++-- test/unit/commands/push/publish.test.ts | 10 ++++++- 37 files changed, 133 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index ca11d84d..7ca84f6c 100644 --- a/README.md +++ b/README.md @@ -2970,7 +2970,7 @@ ARGUMENTS key. Items with "channels" are routed via channel batch publish with the payload wrapped in extras.push FLAGS - -f, --force Skip confirmation prompt when publishing to channels (confirmation is also skipped in --json mode) + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --json Output in JSON format --pretty-json Output in colorized JSON format @@ -3585,8 +3585,7 @@ USAGE [--web ] [-f] FLAGS - -f, --force Skip confirmation prompt when publishing to a channel (confirmation is also skipped in - --json mode) + -f, --force Skip confirmation prompt (required with --json) -v, --verbose Output verbose logs --apns= APNs-specific override as JSON --badge= Notification badge count diff --git a/src/base-command.ts b/src/base-command.ts index af4ee72e..e825422c 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -1008,10 +1008,9 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // When using token auth, we don't set the clientId as it may conflict // with any clientId embedded in the token if (flags["client-id"] && !this.shouldSuppressOutput(flags)) { - this.logToStderr( - chalk.yellow( - "Warning: clientId is ignored when using token authentication as the clientId is embedded in the token", - ), + this.logWarning( + "clientId is ignored when using token authentication as the clientId is embedded in the token.", + flags, ); } } else if (flags["api-key"]) { @@ -1337,15 +1336,22 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { const appId = flags.app || this.configManager.getCurrentAppId(); if (appId) { - this.log("The configured API key appears to be invalid or revoked."); + if (this.shouldOutputJson(flags)) { + // In JSON mode, auto-remove the invalid key — it can't be used anyway + this.configManager.removeApiKey(appId); + } else { + this.logToStderr( + "The configured API key appears to be invalid or revoked.", + ); - const shouldRemove = await this.interactiveHelper.confirm( - "Would you like to remove this invalid key from your configuration?", - ); + const shouldRemove = await this.interactiveHelper.confirm( + "Would you like to remove this invalid key from your configuration?", + ); - if (shouldRemove) { - this.configManager.removeApiKey(appId); - this.log("Invalid key removed from configuration."); + if (shouldRemove) { + this.configManager.removeApiKey(appId); + this.logToStderr("Invalid key removed from configuration."); + } } } } diff --git a/src/commands/accounts/current.ts b/src/commands/accounts/current.ts index 361c6861..59663085 100644 --- a/src/commands/accounts/current.ts +++ b/src/commands/accounts/current.ts @@ -132,8 +132,9 @@ export default class AccountsCurrent extends ControlBaseCommand { flags, ); } else { - this.warn( + this.logWarning( "Unable to verify account information. Your access token may have expired.", + flags, ); this.log( chalk.yellow( @@ -232,7 +233,7 @@ export default class AccountsCurrent extends ControlBaseCommand { flags, ); } else { - this.warn(errorMessage(error)); + this.logWarning(errorMessage(error), flags); this.log( `${formatLabel("Info")} Your access token may have expired or is invalid.`, ); diff --git a/src/commands/accounts/switch.ts b/src/commands/accounts/switch.ts index 026524c9..e45ed3c2 100644 --- a/src/commands/accounts/switch.ts +++ b/src/commands/accounts/switch.ts @@ -167,7 +167,7 @@ export default class AccountsSwitch extends ControlBaseCommand { flags, ); } else { - this.warn(warningMessage); + this.logWarning(warningMessage, flags); } } } diff --git a/src/commands/apps/current.ts b/src/commands/apps/current.ts index 4743967d..c9ea1b08 100644 --- a/src/commands/apps/current.ts +++ b/src/commands/apps/current.ts @@ -205,8 +205,9 @@ export default class AppsCurrent extends ControlBaseCommand { `${formatLabel("App")} ${chalk.green.bold("Unknown")} ${chalk.gray(`(${appId})`)}`, ); this.log(`${formatLabel("API Key")} ${chalk.yellow.bold(keyId)}`); - this.warn( + this.logWarning( `Could not fetch additional app details: ${errorMessage(error)}`, + flags, ); this.log( `${formatLabel("Mode")} ${chalk.magenta.bold("Web CLI")} ${chalk.dim("(using environment variables)")}`, diff --git a/src/commands/apps/list.ts b/src/commands/apps/list.ts index 187c8520..8a88f134 100644 --- a/src/commands/apps/list.ts +++ b/src/commands/apps/list.ts @@ -91,7 +91,7 @@ export default class AppsList extends ControlBaseCommand { if (hasMore) { const warning = formatLimitWarning(apps.length, flags.limit, "apps"); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } }, "Error listing apps", diff --git a/src/commands/apps/rules/list.ts b/src/commands/apps/rules/list.ts index e17612cc..cd3e3b1f 100644 --- a/src/commands/apps/rules/list.ts +++ b/src/commands/apps/rules/list.ts @@ -118,7 +118,7 @@ export default class RulesListCommand extends ControlBaseCommand { flags.limit, "rules", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/auth/issue-ably-token.ts b/src/commands/auth/issue-ably-token.ts index bf790e7b..ac782eb4 100644 --- a/src/commands/auth/issue-ably-token.ts +++ b/src/commands/auth/issue-ably-token.ts @@ -112,7 +112,11 @@ export default class IssueAblyTokenCommand extends AblyBaseCommand { // If token-only flag is set, output just the token string if (flags["token-only"]) { - this.log(tokenDetails.token); + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ token: { value: tokenDetails.token } }, flags); + } else { + this.log(tokenDetails.token); + } return; } diff --git a/src/commands/auth/issue-jwt-token.ts b/src/commands/auth/issue-jwt-token.ts index 5ab5bb66..334ffac0 100644 --- a/src/commands/auth/issue-jwt-token.ts +++ b/src/commands/auth/issue-jwt-token.ts @@ -128,7 +128,11 @@ export default class IssueJwtTokenCommand extends AblyBaseCommand { // If token-only flag is set, output just the token string if (flags["token-only"]) { - this.log(token); + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ token: { value: token } }, flags); + } else { + this.log(token); + } return; } diff --git a/src/commands/auth/keys/list.ts b/src/commands/auth/keys/list.ts index 3dda5492..0de77084 100644 --- a/src/commands/auth/keys/list.ts +++ b/src/commands/auth/keys/list.ts @@ -114,7 +114,7 @@ export default class KeysListCommand extends ControlBaseCommand { if (hasMore) { const warning = formatLimitWarning(keys.length, flags.limit, "keys"); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/auth/keys/revoke.ts b/src/commands/auth/keys/revoke.ts index 791f10d8..a50cc551 100644 --- a/src/commands/auth/keys/revoke.ts +++ b/src/commands/auth/keys/revoke.ts @@ -107,14 +107,18 @@ export default class KeysRevokeCommand extends ControlBaseCommand { // Check if the revoked key is the current key for this app const currentKey = this.configManager.getApiKey(appId); if (currentKey === key.key) { - // Ask to delete the key from the config - const shouldRemove = await this.interactiveHelper.confirm( - "The revoked key was your current key for this app. Remove it from configuration?", - ); - - if (shouldRemove) { + if (this.shouldOutputJson(flags)) { + // Auto-remove in JSON mode — key is already revoked, can't be used this.configManager.removeApiKey(appId); - this.logSuccessMessage("Key removed from configuration.", flags); + } else { + const shouldRemove = await this.interactiveHelper.confirm( + "The revoked key was your current key for this app. Remove it from configuration?", + ); + + if (shouldRemove) { + this.configManager.removeApiKey(appId); + this.logSuccessMessage("Key removed from configuration.", flags); + } } } } catch (error) { diff --git a/src/commands/auth/revoke-token.ts b/src/commands/auth/revoke-token.ts index 982d3538..696233d7 100644 --- a/src/commands/auth/revoke-token.ts +++ b/src/commands/auth/revoke-token.ts @@ -62,13 +62,18 @@ export default class RevokeTokenCommand extends AblyBaseCommand { if (!flags["client-id"]) { // We need to warn the user that we're using the token as a client ID - this.warn( - "Revoking a specific token is only possible if it has a client ID or revocation key", + this.logWarning( + "Revoking a specific token is only possible if it has a client ID or revocation key.", + flags, + ); + this.logWarning( + "For advanced token revocation options, see: https://ably.com/docs/auth/revocation.", + flags, ); - this.warn( - "For advanced token revocation options, see: https://ably.com/docs/auth/revocation", + this.logWarning( + "Using the token argument as a client ID for this operation.", + flags, ); - this.warn("Using the token argument as a client ID for this operation"); } // Extract the keyName (appId.keyId) from the API key diff --git a/src/commands/bench/publisher.ts b/src/commands/bench/publisher.ts index de20d700..56b24657 100644 --- a/src/commands/bench/publisher.ts +++ b/src/commands/bench/publisher.ts @@ -101,6 +101,7 @@ export default class BenchPublisher extends AblyBaseCommand { // Helper function for delays private delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + private _flags: PublisherFlags | null = null; private intervalId: NodeJS.Timeout | null = null; private readonly MAX_LOG_LINES = 10; // Buffer for the last 10 logs private messageLogBuffer: string[] = []; @@ -121,6 +122,7 @@ export default class BenchPublisher extends AblyBaseCommand { async run(): Promise { const { args, flags } = await this.parse(BenchPublisher); + this._flags = flags; // Validate max values const messageCount = Math.min(flags.messages, 10_000); @@ -291,7 +293,7 @@ export default class BenchPublisher extends AblyBaseCommand { // --- Original Private Methods --- private addLogToBuffer(logMessage: string): void { - if (this.shouldOutputJson({})) return; // Don't buffer in JSON mode + if (this.shouldOutputJson(this._flags ?? {})) return; // Don't buffer in JSON mode this.messageLogBuffer.push( `[${new Date().toLocaleTimeString()}] ${logMessage}`, ); diff --git a/src/commands/bench/subscriber.ts b/src/commands/bench/subscriber.ts index 2e9d5b03..c3a4041c 100644 --- a/src/commands/bench/subscriber.ts +++ b/src/commands/bench/subscriber.ts @@ -40,6 +40,7 @@ export default class BenchSubscriber extends AblyBaseCommand { ...durationFlag, }; + private _flags: Record | null = null; private receivedEchoCount = 0; private checkPublisherIntervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null; @@ -70,6 +71,7 @@ export default class BenchSubscriber extends AblyBaseCommand { async run(): Promise { const { args, flags } = await this.parse(BenchSubscriber); + this._flags = flags; this.realtime = await this.setupClient(flags); if (!this.realtime) return; // Exit if client setup failed @@ -741,7 +743,7 @@ export default class BenchSubscriber extends AblyBaseCommand { displayTable: InstanceType | null, metrics: TestMetrics, ): void { - if (this.shouldOutputJson({})) return; + if (this.shouldOutputJson(this._flags ?? {})) return; // Fallback to the command's stored table reference if none provided const tableRef = displayTable ?? this.displayTable; diff --git a/src/commands/channels/annotations/get.ts b/src/commands/channels/annotations/get.ts index 1d60340a..3535e74e 100644 --- a/src/commands/channels/annotations/get.ts +++ b/src/commands/channels/annotations/get.ts @@ -118,7 +118,7 @@ export default class ChannelsAnnotationsGet extends AblyBaseCommand { flags.limit, "annotations", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } catch (error) { this.fail(error, flags, "annotationGet", { diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 2d3b8426..50845b9a 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -175,7 +175,7 @@ export default class ChannelsHistory extends AblyBaseCommand { flags.limit, "messages", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 61e94afd..12732675 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -138,7 +138,7 @@ export default class ChannelsList extends AblyBaseCommand { flags.limit, "channels", ); - if (warning) this.log(`\n${warning}`); + if (warning) this.logToStderr(`\n${warning}`); } } } catch (error) { diff --git a/src/commands/channels/presence/get.ts b/src/commands/channels/presence/get.ts index 477d459e..7ee8015e 100644 --- a/src/commands/channels/presence/get.ts +++ b/src/commands/channels/presence/get.ts @@ -143,7 +143,7 @@ export default class ChannelsPresenceGet extends AblyBaseCommand { flags.limit, "members", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/integrations/list.ts b/src/commands/integrations/list.ts index a315cfef..19451ab7 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -95,7 +95,7 @@ export default class IntegrationsListCommand extends ControlBaseCommand { flags.limit, "integrations", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/logs/connection-lifecycle/history.ts b/src/commands/logs/connection-lifecycle/history.ts index cc7ca481..73daf7d5 100644 --- a/src/commands/logs/connection-lifecycle/history.ts +++ b/src/commands/logs/connection-lifecycle/history.ts @@ -160,7 +160,7 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { flags.limit, "logs", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/logs/history.ts b/src/commands/logs/history.ts index 8104bb95..c414311b 100644 --- a/src/commands/logs/history.ts +++ b/src/commands/logs/history.ts @@ -139,7 +139,7 @@ export default class LogsHistory extends AblyBaseCommand { flags.limit, "logs", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index 54975919..655f6aff 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -172,7 +172,7 @@ export default class LogsPushHistory extends AblyBaseCommand { flags.limit, "logs", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/push/batch-publish.ts b/src/commands/push/batch-publish.ts index e119e5a9..db0e2a35 100644 --- a/src/commands/push/batch-publish.ts +++ b/src/commands/push/batch-publish.ts @@ -1,10 +1,10 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import * as fs from "node:fs"; import * as path from "node:path"; import { AblyBaseCommand } from "../../base-command.js"; import { CommandError } from "../../errors/command-error.js"; -import { productApiFlags } from "../../flags.js"; +import { forceFlag, productApiFlags } from "../../flags.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import { BaseFlags } from "../../types/cli.js"; import { formatCountLabel, formatResource } from "../../utils/output.js"; @@ -79,11 +79,7 @@ export default class PushBatchPublish extends AblyBaseCommand { static override flags = { ...productApiFlags, - force: Flags.boolean({ - char: "f", - description: - "Skip confirmation prompt when publishing to channels (confirmation is also skipped in --json mode)", - }), + ...forceFlag, }; async run(): Promise { @@ -222,12 +218,20 @@ export default class PushBatchPublish extends AblyBaseCommand { // Recipient-based push: route to /push/batch/publish if (recipientItems.length > 0) { + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm publishing", + flags, + "pushBatchPublish", + ); + } + if (!this.shouldOutputJson(flags) && !flags.force) { const confirmed = await promptForConfirmation( `This will send push notifications to ${formatCountLabel(recipientItems.length, "recipient")}. Continue?`, ); if (!confirmed) { - this.log("Publish cancelled."); + this.logWarning("Publish cancelled.", flags); return; } } @@ -295,6 +299,14 @@ export default class PushBatchPublish extends AblyBaseCommand { }, })); + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm publishing", + flags, + "pushBatchPublish", + ); + } + if (!this.shouldOutputJson(flags) && !flags.force) { const allChannels = channelItems.flatMap(({ entry }) => Array.isArray(entry.channels) @@ -309,7 +321,7 @@ export default class PushBatchPublish extends AblyBaseCommand { `This will send a push notification to all devices subscribed to ${formatCountLabel(uniqueChannels.length, "channel")} (${channelList}). Continue?`, ); if (!confirmed) { - this.log("Publish cancelled."); + this.logWarning("Publish cancelled.", flags); return; } } diff --git a/src/commands/push/channels/list-channels.ts b/src/commands/push/channels/list-channels.ts index fae5f279..b9ddbf83 100644 --- a/src/commands/push/channels/list-channels.ts +++ b/src/commands/push/channels/list-channels.ts @@ -86,7 +86,7 @@ export default class PushChannelsListChannels extends AblyBaseCommand { flags.limit, "channels", ); - if (limitWarning) this.log(limitWarning); + if (limitWarning) this.logToStderr(limitWarning); } } catch (error) { this.fail(error, flags as BaseFlags, "pushChannelListChannels"); diff --git a/src/commands/push/channels/list.ts b/src/commands/push/channels/list.ts index 08d65e26..a1c9b259 100644 --- a/src/commands/push/channels/list.ts +++ b/src/commands/push/channels/list.ts @@ -119,7 +119,7 @@ export default class PushChannelsList extends AblyBaseCommand { flags.limit, "subscriptions", ); - if (limitWarning) this.log(limitWarning); + if (limitWarning) this.logToStderr(limitWarning); } } catch (error) { this.fail(error, flags as BaseFlags, "pushChannelList"); diff --git a/src/commands/push/devices/list.ts b/src/commands/push/devices/list.ts index 06cd9e6b..853191a2 100644 --- a/src/commands/push/devices/list.ts +++ b/src/commands/push/devices/list.ts @@ -125,7 +125,7 @@ export default class PushDevicesList extends AblyBaseCommand { flags.limit, "device registrations", ); - if (limitWarning) this.log(limitWarning); + if (limitWarning) this.logToStderr(limitWarning); } } catch (error) { this.fail(error, flags as BaseFlags, "pushDeviceList"); diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index c7e26aca..f22d57bb 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { AblyBaseCommand } from "../../base-command.js"; -import { productApiFlags } from "../../flags.js"; +import { forceFlag, productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; import { formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; @@ -82,11 +82,7 @@ export default class PushPublish extends AblyBaseCommand { web: Flags.string({ description: "Web push-specific override as JSON", }), - force: Flags.boolean({ - char: "f", - description: - "Skip confirmation prompt when publishing to a channel (confirmation is also skipped in --json mode)", - }), + ...forceFlag, }; async run(): Promise { @@ -243,12 +239,20 @@ export default class PushPublish extends AblyBaseCommand { } else { const channelName = flags.channel!; + if (!flags.force && this.shouldOutputJson(flags)) { + this.fail( + "The --force flag is required when using --json to confirm publishing", + flags, + "pushPublish", + ); + } + if (!this.shouldOutputJson(flags) && !flags.force) { const confirmed = await promptForConfirmation( `This will send a push notification to all devices subscribed to channel ${formatResource(channelName)}. Continue?`, ); if (!confirmed) { - this.log("Publish cancelled."); + this.logWarning("Publish cancelled.", flags); return; } } diff --git a/src/commands/queues/list.ts b/src/commands/queues/list.ts index 07512973..2224dd8a 100644 --- a/src/commands/queues/list.ts +++ b/src/commands/queues/list.ts @@ -162,7 +162,7 @@ export default class QueuesListCommand extends ControlBaseCommand { flags.limit, "queues", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index b2e36333..adbdccc1 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -161,7 +161,7 @@ export default class RoomsList extends ChatBaseCommand { flags.limit, "rooms", ); - if (warning) this.log(`\n${warning}`); + if (warning) this.logToStderr(`\n${warning}`); } } } catch (error) { diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index 8c5dd575..efeaa55f 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -210,7 +210,7 @@ export default class MessagesHistory extends ChatBaseCommand { flags.limit, "messages", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/rooms/presence/get.ts b/src/commands/rooms/presence/get.ts index ede94a0a..0a84305e 100644 --- a/src/commands/rooms/presence/get.ts +++ b/src/commands/rooms/presence/get.ts @@ -148,7 +148,7 @@ export default class RoomsPresenceGet extends AblyBaseCommand { flags.limit, "members", ); - if (warning) this.log(warning); + if (warning) this.logToStderr(warning); } } } catch (error) { diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 604f1246..8e255a8e 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -150,7 +150,7 @@ export default class SpacesList extends SpacesBaseCommand { flags.limit, "spaces", ); - if (warning) this.log(`\n${warning}`); + if (warning) this.logToStderr(`\n${warning}`); } } } catch (error) { diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index 2d760bea..1c68bf3e 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -39,8 +39,6 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: false }); - this.logListening("Listening for location updates.", flags); - this.logCliEvent( flags, "location", @@ -92,6 +90,8 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { this.space!.locations.subscribe("update", locationHandler); + this.logListening("Listening for location updates.", flags); + this.logCliEvent( flags, "location", diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 3d295c13..978a8092 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -52,8 +52,6 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: false }); - this.logListening("Listening for member events.", flags); - // Subscribe to member presence events this.logCliEvent( flags, @@ -119,6 +117,8 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { // Subscribe using the listener this.space!.members.subscribe("update", memberListener); + this.logListening("Listening for member events.", flags); + this.logCliEvent( flags, "member", diff --git a/src/stats-base-command.ts b/src/stats-base-command.ts index af17f2e1..1c3edbbc 100644 --- a/src/stats-base-command.ts +++ b/src/stats-base-command.ts @@ -60,8 +60,9 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { controlApi: ControlApi, ): Promise { if (flags.live && flags.unit !== "minute") { - this.warn( + this.logWarning( "Live stats only support minute intervals. Using minute interval.", + flags, ); flags.unit = "minute"; } @@ -115,7 +116,7 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { try { this.isPolling = true; if (flags.debug) { - this.log( + this.logToStderr( chalk.dim(`\n[${new Date().toISOString()}] Polling for new stats...`), ); } @@ -162,7 +163,7 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { if (!this.isPolling) { void this.pollStats(flags, controlApi); } else if (flags.debug) { - this.log( + this.logToStderr( chalk.yellow( "Skipping poll - previous request still in progress", ), diff --git a/test/unit/commands/logs/history.test.ts b/test/unit/commands/logs/history.test.ts index 6ad0ae98..7c0d34c9 100644 --- a/test/unit/commands/logs/history.test.ts +++ b/test/unit/commands/logs/history.test.ts @@ -129,13 +129,13 @@ describe("logs:history command", () => { createMockPaginatedResult(messages, [{ id: "more" }]), ); - const { stdout } = await runCommand( + const { stderr } = 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"); + expect(stderr).toContain("Showing maximum of 10 logs"); + expect(stderr).toContain("Use --limit to show more"); }); it("should show 'No application logs found' on empty results", async () => { diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index a04939da..5a552d6e 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -178,7 +178,15 @@ describe("push:publish command", () => { it("should output JSON with channel when publishing via channel", async () => { const { stdout } = await runCommand( - ["push:publish", "--channel", "my-channel", "--title", "Hi", "--json"], + [ + "push:publish", + "--channel", + "my-channel", + "--title", + "Hi", + "--json", + "--force", + ], import.meta.url, ); From 572225bd955d0dde85b9f9113edc1ef711a0aab5 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 1 Apr 2026 13:06:50 +0100 Subject: [PATCH 9/9] Fix E2E tests for stderr status messages and NDJSON output - Check stderr (not stdout) for status messages like "Entered presence", "Location set in space:", and "Waiting for" - Parse NDJSON correctly: find type "result" line for history, first line for list (completed status signal is appended as last line) --- test/e2e/channels/channel-presence-e2e.test.ts | 11 ++++++----- test/e2e/channels/channels-e2e.test.ts | 14 ++++++++++---- test/e2e/interactive/ctrl-c-behavior.test.ts | 8 ++++---- test/e2e/spaces/spaces-e2e.test.ts | 6 ++++-- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/test/e2e/channels/channel-presence-e2e.test.ts b/test/e2e/channels/channel-presence-e2e.test.ts index 9cda3954..b0f5a754 100644 --- a/test/e2e/channels/channel-presence-e2e.test.ts +++ b/test/e2e/channels/channel-presence-e2e.test.ts @@ -82,11 +82,12 @@ describe("Channel Presence E2E Tests", () => { }, ); - console.log(`Presence enter output: ${enterResult.stdout}`); + console.log(`Presence enter stdout: ${enterResult.stdout}`); + console.log(`Presence enter stderr: ${enterResult.stderr}`); expect(enterResult.exitCode).toBe(0); - expect(enterResult.stdout).toContain("Entered presence on channel"); - expect(enterResult.stdout).toContain( - "Duration elapsed – command finished cleanly", - ); + // Status messages go to stderr + const allOutput = enterResult.stdout + enterResult.stderr; + expect(allOutput).toContain("Entered presence on channel"); + expect(allOutput).toContain("Duration elapsed – command finished cleanly"); }); }); diff --git a/test/e2e/channels/channels-e2e.test.ts b/test/e2e/channels/channels-e2e.test.ts index 19da674f..90c88138 100644 --- a/test/e2e/channels/channels-e2e.test.ts +++ b/test/e2e/channels/channels-e2e.test.ts @@ -181,7 +181,10 @@ describe("Channel E2E Tests", () => { let result; try { - result = JSON.parse(listResult.stdout); + // JSON output may contain multiple NDJSON lines (result + completed status). + // Parse the first non-empty line which contains the result data. + const line = listResult.stdout.trim().split("\n").find(Boolean); + result = JSON.parse(line); } catch (parseError) { throw new Error( `Failed to parse JSON output. Parse error: ${String(parseError)}. Exit code: ${listResult.exitCode}, Stderr: ${listResult.stderr}, Stdout: ${listResult.stdout}`, @@ -339,10 +342,13 @@ describe("Channel E2E Tests", () => { let result; try { - // The --json output is NDJSON (one event line + one result line). - // Parse the last non-empty line which contains the result. + // The --json output is NDJSON: event lines, then a result line, then a completed status. + // Find the line with type "result" which contains the history data. const lines = historyResult.stdout.trim().split("\n").filter(Boolean); - result = JSON.parse(lines.at(-1)); + const resultLine = lines + .map((l) => JSON.parse(l)) + .find((obj) => obj.type === "result"); + result = resultLine; } catch (parseError) { throw new Error( `Failed to parse JSON history output. Parse error: ${String(parseError)}. Exit code: ${historyResult.exitCode}, Stderr: ${historyResult.stderr}, Stdout: ${historyResult.stdout}`, diff --git a/test/e2e/interactive/ctrl-c-behavior.test.ts b/test/e2e/interactive/ctrl-c-behavior.test.ts index c3d7f30f..4a227f0f 100644 --- a/test/e2e/interactive/ctrl-c-behavior.test.ts +++ b/test/e2e/interactive/ctrl-c-behavior.test.ts @@ -357,10 +357,10 @@ describe("E2E: Interactive Mode - Ctrl+C Behavior", () => { }, 100); }); - // Wait for command to start + // Wait for command to start (status messages go to stderr) await new Promise((resolve) => { const checkForWaiting = setInterval(() => { - if (output.includes("Waiting for")) { + if (errorOutput.includes("Waiting for")) { clearInterval(checkForWaiting); resolve(); } @@ -435,10 +435,10 @@ describe("E2E: Interactive Mode - Ctrl+C Behavior", () => { }, 100); }); - // Wait for command to start + // Wait for command to start (status messages go to stderr) await new Promise((resolve) => { const checkInterval = setInterval(() => { - if (output.includes("Waiting for")) { + if (errorOutput.includes("Waiting for")) { clearInterval(checkInterval); resolve(); } diff --git a/test/e2e/spaces/spaces-e2e.test.ts b/test/e2e/spaces/spaces-e2e.test.ts index e145d4ad..c7ce884e 100644 --- a/test/e2e/spaces/spaces-e2e.test.ts +++ b/test/e2e/spaces/spaces-e2e.test.ts @@ -217,11 +217,13 @@ describe("Spaces E2E Tests", () => { ); // Check for success - either exit code 0 or successful output (even if process was killed after success) + // Status messages go to stderr + const allOutput = setLocationResult.stdout + setLocationResult.stderr; const isLocationSetSuccessful = setLocationResult.exitCode === 0 || - setLocationResult.stdout.includes("Location set in space:"); + allOutput.includes("Location set in space:"); expect(isLocationSetSuccessful).toBe(true); - expect(setLocationResult.stdout).toContain("Location set in space:"); + expect(allOutput).toContain("Location set in space:"); // Wait for location update to be received by client1 let locationUpdateReceived = false;