diff --git a/src/base-command.ts b/src/base-command.ts index a1824233..586b5c20 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -10,6 +10,7 @@ import { } from "./services/config-manager.js"; import { ControlApi } from "./services/control-api.js"; import { CommandError } from "./errors/command-error.js"; +import { getFriendlyAblyErrorHint } from "./utils/errors.js"; import { coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; import { BaseFlags, CommandConfig } from "./types/cli.js"; @@ -939,9 +940,10 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { logData, ); } else if (level <= 1) { - // Standard Non-JSON: Log only SDK ERRORS (level <= 1) clearly - // Use a format similar to logCliEvent's non-JSON output - this.log(`${chalk.red.bold(`[AblySDK Error]`)} ${message}`); + // SDK errors are handled by setupChannelStateLogging() and fail() + // Only show raw SDK errors in verbose mode (handled above) + // In non-verbose mode, log to stderr for debugging without polluting stdout + this.logToStderr(`${chalk.red.bold(`[AblySDK Error]`)} ${message}`); } // If not verbose non-JSON and level > 1, suppress non-error SDK logs } @@ -1515,12 +1517,27 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { }, ); + const friendlyHint = getFriendlyAblyErrorHint( + cmdError.code ?? + (typeof cmdError.context.errorCode === "number" + ? cmdError.context.errorCode + : undefined), + ); + if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonRecord("error", cmdError.toJsonData(), flags)); + const jsonData = cmdError.toJsonData(); + if (friendlyHint) { + jsonData.hint = friendlyHint; + } + this.log(this.formatJsonRecord("error", jsonData, flags)); this.exit(1); } let humanMessage = cmdError.message; + if (friendlyHint) { + humanMessage += `\n${friendlyHint}`; + } + const code = cmdError.code ?? cmdError.context.errorCode; if (code !== undefined) { const helpUrl = cmdError.context.helpUrl; diff --git a/src/commands/bench/publisher.ts b/src/commands/bench/publisher.ts index c895bed1..4361d2ce 100644 --- a/src/commands/bench/publisher.ts +++ b/src/commands/bench/publisher.ts @@ -313,58 +313,64 @@ export default class BenchPublisher extends AblyBaseCommand { "waitingForSubscribers", "Waiting for subscribers...", ); - await new Promise((resolve) => { - const subscriberCheck = (member: Ably.PresenceMessage) => { - if ( - member.data && - typeof member.data === "object" && - "role" in member.data && - member.data.role === "subscriber" - ) { - this.logCliEvent( - flags, - "benchmark", - "subscriberDetected", - `Subscriber detected: ${member.clientId}`, - { clientId: member.clientId }, - ); - channel.presence.unsubscribe("enter", subscriberCheck); - resolve(); - } - }; - - channel.presence.subscribe("enter", subscriberCheck); - channel.presence - .get() - .then((members) => { - const subscribers = members.filter( - (m) => - m.data && - typeof m.data === "object" && - "role" in m.data && - m.data.role === "subscriber", - ); - if (subscribers.length > 0) { - this.logCliEvent( - flags, - "benchmark", - "subscribersFound", - `Found ${subscribers.length} subscribers already present`, - ); - channel.presence.unsubscribe("enter", subscriberCheck); - resolve(); - } - }) - .catch((error) => { - this.logCliEvent( - flags, - "presence", - "getPresenceError", - `Error getting initial presence: ${errorMessage(error)}`, - ); - // Continue waiting - }); + let foundSubscriber: () => void; + const subscriberPromise = new Promise((resolve) => { + foundSubscriber = resolve; }); + + const subscriberCheck = (member: Ably.PresenceMessage) => { + if ( + member.data && + typeof member.data === "object" && + "role" in member.data && + member.data.role === "subscriber" + ) { + this.logCliEvent( + flags, + "benchmark", + "subscriberDetected", + `Subscriber detected: ${member.clientId}`, + { clientId: member.clientId }, + ); + channel.presence.unsubscribe("enter", subscriberCheck); + foundSubscriber(); + } + }; + + await channel.presence.subscribe("enter", subscriberCheck); + + // Check if subscribers are already present + try { + const members = await channel.presence.get(); + const subscribers = members.filter( + (m) => + m.data && + typeof m.data === "object" && + "role" in m.data && + m.data.role === "subscriber", + ); + if (subscribers.length > 0) { + this.logCliEvent( + flags, + "benchmark", + "subscribersFound", + `Found ${subscribers.length} subscribers already present`, + ); + channel.presence.unsubscribe("enter", subscriberCheck); + // Already found, no need to wait + } else { + await subscriberPromise; + } + } catch (error) { + this.logCliEvent( + flags, + "presence", + "getPresenceError", + `Error getting initial presence: ${errorMessage(error)}`, + ); + // Continue waiting for subscribe callback + await subscriberPromise; + } } else { const members = await channel.presence.get(); const subscribers = members.filter( @@ -583,33 +589,42 @@ export default class BenchPublisher extends AblyBaseCommand { testId, }; - channel.presence.subscribe("enter", (member: Ably.PresenceMessage) => { - this.logCliEvent( - flags, - "presence", - "memberEntered", - `Member entered presence: ${member.clientId}`, - { clientId: member.clientId, data: member.data }, - ); - }); - channel.presence.subscribe("leave", (member: Ably.PresenceMessage) => { - this.logCliEvent( - flags, - "presence", - "memberLeft", - `Member left presence: ${member.clientId}`, - { clientId: member.clientId }, - ); - }); - channel.presence.subscribe("update", (member: Ably.PresenceMessage) => { - this.logCliEvent( - flags, - "presence", - "memberUpdated", - `Member updated presence: ${member.clientId}`, - { clientId: member.clientId, data: member.data }, - ); - }); + await channel.presence.subscribe( + "enter", + (member: Ably.PresenceMessage) => { + this.logCliEvent( + flags, + "presence", + "memberEntered", + `Member entered presence: ${member.clientId}`, + { clientId: member.clientId, data: member.data }, + ); + }, + ); + await channel.presence.subscribe( + "leave", + (member: Ably.PresenceMessage) => { + this.logCliEvent( + flags, + "presence", + "memberLeft", + `Member left presence: ${member.clientId}`, + { clientId: member.clientId }, + ); + }, + ); + await channel.presence.subscribe( + "update", + (member: Ably.PresenceMessage) => { + this.logCliEvent( + flags, + "presence", + "memberUpdated", + `Member updated presence: ${member.clientId}`, + { clientId: member.clientId, data: member.data }, + ); + }, + ); this.logCliEvent( flags, diff --git a/src/commands/bench/subscriber.ts b/src/commands/bench/subscriber.ts index bf664e3c..e49b0b55 100644 --- a/src/commands/bench/subscriber.ts +++ b/src/commands/bench/subscriber.ts @@ -105,7 +105,7 @@ export default class BenchSubscriber extends AblyBaseCommand { await this.handlePresence(channel, metrics, flags); - this.subscribeToMessages(channel, metrics, flags); + await this.subscribeToMessages(channel, metrics, flags); await this.checkInitialPresence(channel, metrics, flags); @@ -402,109 +402,115 @@ export default class BenchSubscriber extends AblyBaseCommand { ); // --- Presence Enter Handler --- - channel.presence.subscribe("enter", (member: Ably.PresenceMessage) => { - const { clientId, data } = member; // Destructure member - this.logCliEvent( - flags, - "presence", - "memberEntered", - `Member entered presence: ${clientId}`, - { clientId, data }, - ); - - if ( - data && - typeof data === "object" && - "role" in data && - data.role === "publisher" && - "testDetails" in data && - "testId" in data - ) { - const { testDetails, testId } = data as { - testDetails: Record; - testId: string; - }; // Destructure data + await channel.presence.subscribe( + "enter", + (member: Ably.PresenceMessage) => { + const { clientId, data } = member; // Destructure member this.logCliEvent( flags, - "benchmark", - "publisherDetected", - `Publisher detected with test ID: ${testId}`, - { testDetails, testId }, + "presence", + "memberEntered", + `Member entered presence: ${clientId}`, + { clientId, data }, ); - metrics.testDetails = testDetails; - 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 ${testDetails.messageCount} messages at ${testDetails.messageRate} msg/sec using ${testDetails.transport} transport`, + + if ( + data && + typeof data === "object" && + "role" in data && + data.role === "publisher" && + "testDetails" in data && + "testId" in data + ) { + const { testDetails, testId } = data as { + testDetails: Record; + testId: string; + }; // Destructure data + this.logCliEvent( + flags, + "benchmark", + "publisherDetected", + `Publisher detected with test ID: ${testId}`, + { testDetails, testId }, ); + metrics.testDetails = testDetails; + 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 ${testDetails.messageCount} messages at ${testDetails.messageRate} msg/sec using ${testDetails.transport} transport`, + ); + } } - } - }); + }, + ); // --- Presence Leave Handler --- - channel.presence.subscribe("leave", (member: Ably.PresenceMessage) => { - const { clientId, data } = member; // Destructure member - this.logCliEvent( - flags, - "presence", - "memberLeft", - `Member left presence: ${clientId}`, - { clientId }, - ); - - if ( - data && - typeof data === "object" && - "role" in data && - data.role === "publisher" - ) { - const { testId } = (data as { testId?: string }) || {}; - - // Only finish the test if the leaving publisher matches the current test (or we don't know yet) - if (metrics.testId && testId && testId !== metrics.testId) { - return; // different test, ignore - } + await channel.presence.subscribe( + "leave", + (member: Ably.PresenceMessage) => { + const { clientId, data } = member; // Destructure member this.logCliEvent( flags, - "benchmark", - "publisherLeft", - `Publisher has left. Finishing test.`, - { testId }, + "presence", + "memberLeft", + `Member left presence: ${clientId}`, + { clientId }, ); - metrics.publisherActive = false; - this.finishTest(flags, metrics); - this.testInProgress = false; - - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - if (this.checkPublisherIntervalId) { - clearInterval(this.checkPublisherIntervalId); - this.checkPublisherIntervalId = null; - } + if ( + data && + typeof data === "object" && + "role" in data && + data.role === "publisher" + ) { + const { testId } = (data as { testId?: string }) || {}; - if (this.shouldOutputJson(flags)) { + // Only finish the test if the leaving publisher matches the current test (or we don't know yet) + if (metrics.testId && testId && testId !== metrics.testId) { + return; // different test, ignore + } this.logCliEvent( flags, "benchmark", - "waitingForTest", - "Waiting for a new benchmark test to start...", + "publisherLeft", + `Publisher has left. Finishing test.`, + { testId }, ); - } else { - this.log("\nWaiting for a new benchmark test to start..."); - this.displayTable = this.createStatusDisplay(null); - this.log(this.displayTable.toString()); - } + metrics.publisherActive = false; + this.finishTest(flags, metrics); + this.testInProgress = false; - this.displayTable = null; - this.testInProgress = false; - } - }); + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + if (this.checkPublisherIntervalId) { + clearInterval(this.checkPublisherIntervalId); + 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.displayTable = this.createStatusDisplay(null); + this.log(this.displayTable.toString()); + } + + this.displayTable = null; + this.testInProgress = false; + } + }, + ); } private resetDisplay(displayTable: InstanceType): void { @@ -655,12 +661,12 @@ export default class BenchSubscriber extends AblyBaseCommand { }, 1000); } - private subscribeToMessages( + private async subscribeToMessages( channel: Ably.RealtimeChannel, metrics: TestMetrics, flags: Record, - ): void { - channel.subscribe((message: Ably.Message) => { + ): Promise { + await channel.subscribe((message: Ably.Message) => { const currentTime = Date.now(); // Check if this message is the start of a new test diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index 67f56f0e..7e393e16 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -86,7 +86,7 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { ); } - channel.subscribe(occupancyEventName, (message: Ably.Message) => { + await channel.subscribe(occupancyEventName, (message: Ably.Message) => { const timestamp = formatMessageTimestamp(message.timestamp); const event = { channel: channelName, diff --git a/src/commands/channels/presence/enter.ts b/src/commands/channels/presence/enter.ts index 7635040f..b5288646 100644 --- a/src/commands/channels/presence/enter.ts +++ b/src/commands/channels/presence/enter.ts @@ -104,7 +104,7 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { // Subscribe to presence events before entering (if show-others is enabled) if (flags["show-others"]) { - this.channel.presence.subscribe((presenceMessage) => { + await this.channel.presence.subscribe((presenceMessage) => { // Filter out own presence events if (presenceMessage.clientId === client.auth.clientId) { return; diff --git a/src/commands/channels/presence/subscribe.ts b/src/commands/channels/presence/subscribe.ts index dfb23259..91c408dd 100644 --- a/src/commands/channels/presence/subscribe.ts +++ b/src/commands/channels/presence/subscribe.ts @@ -79,41 +79,43 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { ); } - channel.presence.subscribe((presenceMessage: Ably.PresenceMessage) => { - const timestamp = formatMessageTimestamp(presenceMessage.timestamp); - const presenceData = { - id: presenceMessage.id, - timestamp, - action: presenceMessage.action, - channel: channelName, - clientId: presenceMessage.clientId, - connectionId: presenceMessage.connectionId, - data: presenceMessage.data, - }; - this.logCliEvent( - flags, - "presence", - presenceMessage.action!, - `Presence event: ${presenceMessage.action} by ${presenceMessage.clientId}`, - presenceData, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonEvent({ presenceMessage: presenceData }, flags); - } else { - const displayFields: PresenceDisplayFields = { + await channel.presence.subscribe( + (presenceMessage: Ably.PresenceMessage) => { + const timestamp = formatMessageTimestamp(presenceMessage.timestamp); + const presenceData = { id: presenceMessage.id, - timestamp: presenceMessage.timestamp ?? Date.now(), - action: presenceMessage.action || "unknown", + timestamp, + action: presenceMessage.action, channel: channelName, clientId: presenceMessage.clientId, connectionId: presenceMessage.connectionId, data: presenceMessage.data, }; - this.log(formatPresenceOutput([displayFields])); - this.log(""); // Empty line for better readability - } - }); + this.logCliEvent( + flags, + "presence", + presenceMessage.action!, + `Presence event: ${presenceMessage.action} by ${presenceMessage.clientId}`, + presenceData, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent({ presenceMessage: presenceData }, flags); + } else { + const displayFields: PresenceDisplayFields = { + id: presenceMessage.id, + timestamp: presenceMessage.timestamp ?? Date.now(), + action: presenceMessage.action || "unknown", + channel: channelName, + clientId: presenceMessage.clientId, + connectionId: presenceMessage.connectionId, + data: presenceMessage.data, + }; + this.log(formatPresenceOutput([displayFields])); + this.log(""); // Empty line for better readability + } + }, + ); if (!this.shouldOutputJson(flags)) { this.log( diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index 9c899a18..7990b87a 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -154,7 +154,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand { }); // Subscribe to messages on all channels - const attachPromises: Promise[] = []; + const subscribePromises: Promise[] = []; for (const channel of channels) { this.logCliEvent( @@ -177,19 +177,8 @@ export default class ChannelsSubscribe extends AblyBaseCommand { includeUserFriendlyMessages: true, }); - // Track attachment promise - const attachPromise = new Promise((resolve) => { - const checkAttached = () => { - if (channel.state === "attached") { - resolve(); - } - }; - channel.once("attached", checkAttached); - checkAttached(); // Check if already attached - }); - attachPromises.push(attachPromise); - - channel.subscribe((message: Ably.Message) => { + // Subscribe and collect promise (rejects on capability/auth errors) + const subscribePromise = channel.subscribe((message: Ably.Message) => { this.sequenceCounter++; const timestamp = formatMessageTimestamp(message.timestamp); const messageData = { @@ -251,10 +240,11 @@ export default class ChannelsSubscribe extends AblyBaseCommand { this.log(""); // Empty line for readability between messages } }); + subscribePromises.push(subscribePromise); } - // Wait for all channels to attach - await Promise.all(attachPromises); + // Wait for all channels to attach via subscribe + await Promise.all(subscribePromises); // Log the ready signal for E2E tests if (channelNames.length === 1 && !this.shouldOutputJson(flags)) { diff --git a/src/commands/logs/channel-lifecycle/subscribe.ts b/src/commands/logs/channel-lifecycle/subscribe.ts index 1731068b..eba343e0 100644 --- a/src/commands/logs/channel-lifecycle/subscribe.ts +++ b/src/commands/logs/channel-lifecycle/subscribe.ts @@ -79,16 +79,8 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { "subscribing", `Subscribing to ${channelName}...`, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess(`Subscribed to ${formatResource(channelName)}.`), - ); - this.log(formatListening("Listening for channel lifecycle logs.")); - this.log(""); - } - // Subscribe to the channel - channel.subscribe((message) => { + await channel.subscribe((message) => { const timestamp = formatMessageTimestamp(message.timestamp); const event = message.name || "unknown"; const logEvent = { @@ -136,6 +128,15 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { this.log(""); // Empty line for better readability }); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess(`Subscribed to ${formatResource(channelName)}.`), + ); + this.log(formatListening("Listening for channel lifecycle logs.")); + this.log(""); + } + this.logCliEvent( flags, "logs", diff --git a/src/commands/logs/connection-lifecycle/subscribe.ts b/src/commands/logs/connection-lifecycle/subscribe.ts index c77d4d3e..47770f9c 100644 --- a/src/commands/logs/connection-lifecycle/subscribe.ts +++ b/src/commands/logs/connection-lifecycle/subscribe.ts @@ -80,12 +80,8 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { { channel: logsChannelName }, ); - if (!this.shouldOutputJson(flags)) { - this.log(formatSuccess("Subscribed to connection lifecycle logs.")); - } - // Subscribe to connection lifecycle logs - channel.subscribe((message: Ably.Message) => { + await channel.subscribe((message: Ably.Message) => { const timestamp = formatMessageTimestamp(message.timestamp); const event = { timestamp, @@ -118,6 +114,10 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { } }); + if (!this.shouldOutputJson(flags)) { + this.log(formatSuccess("Subscribed to connection lifecycle logs.")); + } + this.logCliEvent( flags, "logs", diff --git a/src/commands/logs/push/subscribe.ts b/src/commands/logs/push/subscribe.ts index 2e63b50a..b7eb86ce 100644 --- a/src/commands/logs/push/subscribe.ts +++ b/src/commands/logs/push/subscribe.ts @@ -79,7 +79,7 @@ export default class LogsPushSubscribe extends AblyBaseCommand { ); // Subscribe to the channel - channel.subscribe((message) => { + await channel.subscribe((message) => { const timestamp = formatMessageTimestamp(message.timestamp); const event = message.name || "unknown"; const logEvent = { diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index 7b2ba873..7cd74560 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -111,48 +111,53 @@ export default class LogsSubscribe extends AblyBaseCommand { { logTypes, channel: logsChannelName }, ); - if (!this.shouldOutputJson(flags)) { - this.log( - formatSuccess( - `Subscribed to app logs: ${formatResource(logTypes.join(", "))}.`, - ), - ); - } - // Subscribe to specified log types + const subscribePromises: Promise[] = []; for (const logType of logTypes) { - channel.subscribe(logType, (message: Ably.Message) => { - const timestamp = formatMessageTimestamp(message.timestamp); - const event = { - logType, - timestamp, - data: message.data, - id: message.id, - }; - this.logCliEvent( - flags, - "logs", - "logReceived", - `Log received: ${logType}`, - event, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonEvent(event, flags); - } else { - this.log( - `${formatTimestamp(timestamp)} Type: ${formatEventType(logType)}`, + subscribePromises.push( + channel.subscribe(logType, (message: Ably.Message) => { + const timestamp = formatMessageTimestamp(message.timestamp); + const event = { + logType, + timestamp, + data: message.data, + id: message.id, + }; + this.logCliEvent( + flags, + "logs", + "logReceived", + `Log received: ${logType}`, + event, ); - if (message.data !== null && message.data !== undefined) { + if (this.shouldOutputJson(flags)) { + this.logJsonEvent(event, flags); + } else { this.log( - `${formatLabel("Data")} ${JSON.stringify(message.data, null, 2)}`, + `${formatTimestamp(timestamp)} Type: ${formatEventType(logType)}`, ); + + if (message.data !== null && message.data !== undefined) { + this.log( + `${formatLabel("Data")} ${JSON.stringify(message.data, null, 2)}`, + ); + } + + this.log(""); // Empty line for better readability } + }), + ); + } + + await Promise.all(subscribePromises); - this.log(""); // Empty line for better readability - } - }); + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess( + `Subscribed to app logs: ${formatResource(logTypes.join(", "))}.`, + ), + ); } this.logCliEvent( diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 2b963077..757f9a50 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -4,3 +4,30 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +/** + * Return a friendly, actionable hint for known Ably error codes. + * Returns undefined for unknown codes. + */ +const hints: Record = { + 40101: + 'The credentials provided are not valid. Check your API key or token, or re-authenticate with "ably login".', + 40103: 'The token has expired. Please re-authenticate with "ably login".', + 40110: + 'Unable to authorize. Check your authentication configuration or re-authenticate with "ably login".', + 40160: + 'Run "ably auth keys list" to check your key\'s capabilities for this resource, or update them in the Ably dashboard.', + 40161: + 'Run "ably auth keys list" to check your key\'s publish capability, or update it in the Ably dashboard.', + 40171: + 'Run "ably auth keys list" to check your key\'s capabilities, or update them in the Ably dashboard.', + 40300: + "This application has been disabled. Check the app status in the Ably dashboard at https://ably.com/dashboard", + 80003: + "The connection was lost. Check your network connection and try again.", +}; + +export function getFriendlyAblyErrorHint(code?: number): string | undefined { + if (code === undefined) return undefined; + return hints[code]; +} diff --git a/test/unit/commands/channels/occupancy/subscribe.test.ts b/test/unit/commands/channels/occupancy/subscribe.test.ts index 032a210d..0351567d 100644 --- a/test/unit/commands/channels/occupancy/subscribe.test.ts +++ b/test/unit/commands/channels/occupancy/subscribe.test.ts @@ -118,6 +118,32 @@ describe("channels:occupancy:subscribe command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/No mock|client/i); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["channels:occupancy:subscribe", "test-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + expect(error?.message).toContain("capability"); + expect(error?.message).toContain("Ably dashboard"); + }); }); describe("output formats", () => { diff --git a/test/unit/commands/channels/presence/enter.test.ts b/test/unit/commands/channels/presence/enter.test.ts index 576114dc..2d3e16a4 100644 --- a/test/unit/commands/channels/presence/enter.test.ts +++ b/test/unit/commands/channels/presence/enter.test.ts @@ -171,5 +171,35 @@ describe("channels:presence:enter command", () => { expect(error).toBeDefined(); }); + + it("should handle capability error with --show-others gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + channel.presence.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + [ + "channels:presence:enter", + "test-channel", + "--client-id", + "test-client", + "--show-others", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + }); }); }); diff --git a/test/unit/commands/channels/presence/subscribe.test.ts b/test/unit/commands/channels/presence/subscribe.test.ts index a1e240f5..0d20210f 100644 --- a/test/unit/commands/channels/presence/subscribe.test.ts +++ b/test/unit/commands/channels/presence/subscribe.test.ts @@ -183,5 +183,31 @@ describe("channels:presence:subscribe command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/No mock|client/i); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + channel.presence.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["channels:presence:subscribe", "test-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + expect(error?.message).toContain("capability"); + expect(error?.message).toContain("Ably dashboard"); + }); }); }); diff --git a/test/unit/commands/channels/subscribe.test.ts b/test/unit/commands/channels/subscribe.test.ts index a3fa5b20..e66f2ab3 100644 --- a/test/unit/commands/channels/subscribe.test.ts +++ b/test/unit/commands/channels/subscribe.test.ts @@ -239,5 +239,67 @@ describe("channels:subscribe command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/No mock|client/i); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["channels:subscribe", "test-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + expect(error?.message).toContain("capability"); + expect(error?.message).toContain("Ably dashboard"); + }); + + it("should include hint in JSON error output for capability errors", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error, stdout } = await runCommand( + ["channels:subscribe", "test-channel", "--json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + const jsonLines = stdout + .split("\n") + .filter((l: string) => l.trim().startsWith("{")); + const errorLine = jsonLines.find((l: string) => + l.includes('"type":"error"'), + ); + expect(errorLine).toBeDefined(); + const parsed = JSON.parse(errorLine!); + expect(parsed.type).toBe("error"); + expect(parsed.success).toBe(false); + expect(parsed.code).toBe(40160); + expect(parsed.hint).toBeDefined(); + expect(parsed.hint).toContain("Ably dashboard"); + }); }); }); diff --git a/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts b/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts index 0877aead..7cd155bb 100644 --- a/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts +++ b/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts @@ -138,5 +138,29 @@ describe("logs:channel-lifecycle:subscribe command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/No mock|client/i); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]channel.lifecycle"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["logs:channel-lifecycle:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + }); }); }); diff --git a/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts b/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts index 3f6a8eb4..7690aaec 100644 --- a/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts +++ b/test/unit/commands/logs/connection-lifecycle/subscribe.test.ts @@ -356,5 +356,29 @@ describe("LogsConnectionLifecycleSubscribe", function () { expect(error).toBeDefined(); expect(error?.message).toContain("Channel subscribe failed"); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]connection.lifecycle"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["logs:connection-lifecycle:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + }); }); }); diff --git a/test/unit/commands/logs/push/subscribe.test.ts b/test/unit/commands/logs/push/subscribe.test.ts index bbbff023..24f79e3e 100644 --- a/test/unit/commands/logs/push/subscribe.test.ts +++ b/test/unit/commands/logs/push/subscribe.test.ts @@ -52,6 +52,30 @@ describe("logs:push:subscribe command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/No mock|client/i); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]log:push"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand( + ["logs:push:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + }); }); describe("functionality", () => { diff --git a/test/unit/commands/logs/subscribe.test.ts b/test/unit/commands/logs/subscribe.test.ts index 0372f9cf..3b5ef3b0 100644 --- a/test/unit/commands/logs/subscribe.test.ts +++ b/test/unit/commands/logs/subscribe.test.ts @@ -193,5 +193,26 @@ describe("logs:subscribe command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch(/No mock|client/i); }); + + it("should handle capability error gracefully", async () => { + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("[meta]log"); + + channel.subscribe.mockRejectedValue( + Object.assign( + new Error("Channel denied access based on given capability"), + { + code: 40160, + statusCode: 401, + href: "https://help.ably.io/error/40160", + }, + ), + ); + + const { error } = await runCommand(["logs:subscribe"], import.meta.url); + + expect(error).toBeDefined(); + expect(error?.message).toContain("Channel denied access"); + }); }); }); diff --git a/test/unit/utils/errors.test.ts b/test/unit/utils/errors.test.ts new file mode 100644 index 00000000..2212ad44 --- /dev/null +++ b/test/unit/utils/errors.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { + errorMessage, + getFriendlyAblyErrorHint, +} from "../../../src/utils/errors.js"; + +describe("errorMessage", () => { + it("should extract message from Error instances", () => { + expect(errorMessage(new Error("test error"))).toBe("test error"); + }); + + it("should stringify non-Error values", () => { + expect(errorMessage("string error")).toBe("string error"); + expect(errorMessage(42)).toBe("42"); + }); +}); + +describe("getFriendlyAblyErrorHint", () => { + it("should return capability hint for code 40160", () => { + const hint = getFriendlyAblyErrorHint(40160); + expect(hint).toContain("ably auth keys list"); + expect(hint).toContain("capabilities"); + }); + + it("should return publish capability hint for code 40161", () => { + const hint = getFriendlyAblyErrorHint(40161); + expect(hint).toContain("ably auth keys list"); + expect(hint).toContain("publish capability"); + }); + + it("should return capabilities hint for code 40171", () => { + const hint = getFriendlyAblyErrorHint(40171); + expect(hint).toContain("ably auth keys list"); + expect(hint).toContain("capabilities"); + }); + + it("should return invalid credentials hint for code 40101", () => { + const hint = getFriendlyAblyErrorHint(40101); + expect(hint).toContain("not valid"); + expect(hint).toContain("ably login"); + }); + + it("should return token expired hint for code 40103", () => { + const hint = getFriendlyAblyErrorHint(40103); + expect(hint).toContain("expired"); + expect(hint).toContain("ably login"); + }); + + it("should return unable to authorize hint for code 40110", () => { + const hint = getFriendlyAblyErrorHint(40110); + expect(hint).toContain("Unable to authorize"); + }); + + it("should return undefined for unknown error codes", () => { + expect(getFriendlyAblyErrorHint(99999)).toBeUndefined(); + }); + + it("should return undefined when code is not provided", () => { + expect(getFriendlyAblyErrorHint()).toBeUndefined(); + }); + + it("should return app disabled hint for code 40300", () => { + const hint = getFriendlyAblyErrorHint(40300); + expect(hint).toContain("disabled"); + expect(hint).toContain("dashboard"); + }); + + it("should return disconnected hint for code 80003", () => { + const hint = getFriendlyAblyErrorHint(80003); + expect(hint).toContain("connection was lost"); + }); +});