diff --git a/src/base-command.ts b/src/base-command.ts index d3e39ae7..b0225234 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -796,7 +796,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } protected formatJsonOutput( - data: Record, + data: Record | Record[], flags: BaseFlags, ): string { if (this.isPrettyJsonOutput(flags)) { diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 8944a928..2eca1677 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -1,19 +1,18 @@ import { Args, Flags } from "@oclif/core"; import * as Ably from "ably"; -import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags, timeRangeFlags } from "../../flags.js"; -import { formatMessageData } from "../../utils/json-formatter.js"; import { buildHistoryParams } from "../../utils/history.js"; import { errorMessage } from "../../utils/errors.js"; import { - countLabel, + formatMessagesOutput, formatTimestamp, formatMessageTimestamp, limitWarning, - resource, + toMessageJson, } from "../../utils/output.js"; +import type { MessageDisplayFields } from "../../utils/output.js"; export default class ChannelsHistory extends AblyBaseCommand { static override args = { @@ -85,41 +84,32 @@ export default class ChannelsHistory extends AblyBaseCommand { const history = await channel.history(historyParams); const messages = history.items; + // Build display fields from history results + const displayMessages: MessageDisplayFields[] = messages.map( + (message, index) => ({ + channel: channelName, + clientId: message.clientId, + data: message.data, + event: message.name || "(none)", + id: message.id, + indexPrefix: `[${index + 1}] ${formatTimestamp(formatMessageTimestamp(message.timestamp))}`, + serial: message.serial, + timestamp: message.timestamp ?? Date.now(), + version: message.version, + annotations: message.annotations, + }), + ); + // Display results based on format if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonOutput({ messages }, flags)); - } else { - if (messages.length === 0) { - this.log("No messages found in the channel history."); - return; - } - this.log( - `Found ${countLabel(messages.length, "message")} in the history of channel: ${resource(channelName)}`, + this.formatJsonOutput( + displayMessages.map((msg) => toMessageJson(msg)), + flags, + ), ); - this.log(""); - - for (const [index, message] of messages.entries()) { - const timestampDisplay = message.timestamp - ? formatTimestamp(formatMessageTimestamp(message.timestamp)) - : chalk.dim("[Unknown timestamp]"); - - this.log(`${chalk.dim(`[${index + 1}]`)} ${timestampDisplay}`); - this.log( - `${chalk.dim("Event:")} ${chalk.yellow(message.name || "(none)")}`, - ); - - if (message.clientId) { - this.log( - `${chalk.dim("Client ID:")} ${chalk.blue(message.clientId)}`, - ); - } - - this.log(chalk.dim("Data:")); - this.log(formatMessageData(message.data)); - - this.log(""); - } + } else { + this.log(formatMessagesOutput(displayMessages)); const warning = limitWarning(messages.length, flags.limit, "messages"); if (warning) this.log(warning); diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index 22e7c5b9..e6d3850c 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -9,15 +9,15 @@ import { productApiFlags, rewindFlag, } from "../../flags.js"; -import { formatMessageData } from "../../utils/json-formatter.js"; import { + formatMessagesOutput, listening, progress, resource, success, - formatTimestamp, - formatMessageTimestamp, + toMessageJson, } from "../../utils/output.js"; +import type { MessageDisplayFields } from "../../utils/output.js"; export default class ChannelsSubscribe extends AblyBaseCommand { static override args = { @@ -195,46 +195,32 @@ export default class ChannelsSubscribe extends AblyBaseCommand { channel.subscribe((message: Ably.Message) => { this.sequenceCounter++; - const timestamp = formatMessageTimestamp(message.timestamp); - const messageEvent = { + + const msgFields: MessageDisplayFields = { channel: channel.name, clientId: message.clientId, - connectionId: message.connectionId, data: message.data, - encoding: message.encoding, event: message.name || "(none)", id: message.id, - timestamp, + serial: message.serial, + timestamp: message.timestamp ?? Date.now(), + version: message.version, + annotations: message.annotations, ...(flags["sequence-numbers"] - ? { sequence: this.sequenceCounter } + ? { sequencePrefix: `${chalk.dim(`[${this.sequenceCounter}]`)} ` } : {}), }; - this.logCliEvent( - flags, - "subscribe", - "messageReceived", - `Received message on channel ${channel.name}`, - messageEvent, - ); if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonOutput(messageEvent, flags)); - } else { - const name = message.name || "(none)"; - const sequencePrefix = flags["sequence-numbers"] - ? `${chalk.dim(`[${this.sequenceCounter}]`)}` - : ""; - - // Message header with timestamp and channel info - this.log( - `${formatTimestamp(timestamp)}${sequencePrefix} ${chalk.cyan(`Channel: ${channel.name}`)} | ${chalk.yellow(`Event: ${name}`)}`, - ); - - // Message data with consistent formatting - this.log(chalk.dim("Data:")); - this.log(formatMessageData(message.data)); + const jsonMsg = toMessageJson(msgFields); + if (flags["sequence-numbers"]) { + jsonMsg.sequence = this.sequenceCounter; + } - this.log(""); // Empty line for better readability + this.log(this.formatJsonOutput(jsonMsg, flags)); + } else { + this.log(formatMessagesOutput([msgFields])); + this.log(""); // Empty line for better readability between messages } }); } @@ -258,6 +244,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand { } this.log(listening("Listening for messages.")); + this.log(""); } this.logCliEvent( diff --git a/src/utils/output.ts b/src/utils/output.ts index 6cd22839..ecfbc038 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,4 +1,6 @@ import chalk, { type ChalkInstance } from "chalk"; +import type * as Ably from "ably"; +import { formatMessageData, isJsonData } from "./json-formatter.js"; export function progress(message: string): string { return `${message}...`; @@ -63,6 +65,129 @@ export function limitWarning( return null; } +/** + * Fields for consistent message display across subscribe and history commands. + * All fields use the same names and format for both human-readable and JSON output. + * Timestamp is raw milliseconds (Unix epoch) — not converted to ISO string. + */ +export interface MessageDisplayFields { + channel: string; + clientId?: string; + data: unknown; + event: string; + id?: string; + indexPrefix?: string; + sequencePrefix?: string; + serial?: string; + timestamp: number; + version?: Ably.MessageVersion; + annotations?: Ably.MessageAnnotations; +} + +/** + * Format an array of messages for human-readable console output. + * Each message shows all fields on separate lines, messages separated by blank lines. + * Returns "No messages found." for empty arrays. + */ +export function formatMessagesOutput(messages: MessageDisplayFields[]): string { + if (messages.length === 0) { + return "No messages found."; + } + + const formatted = messages.map((msg) => { + const lines: string[] = []; + + if (msg.indexPrefix) { + lines.push(chalk.dim(msg.indexPrefix)); + } + + const timestampLine = `${chalk.dim("Timestamp:")} ${msg.timestamp}`; + lines.push( + msg.sequencePrefix + ? `${msg.sequencePrefix}${timestampLine}` + : timestampLine, + `${chalk.dim("Channel:")} ${resource(msg.channel)}`, + `${chalk.dim("Event:")} ${chalk.yellow(msg.event)}`, + ); + + if (msg.id) { + lines.push(`${chalk.dim("ID:")} ${msg.id}`); + } + + if (msg.clientId) { + lines.push(`${chalk.dim("Client ID:")} ${chalk.blue(msg.clientId)}`); + } + + if (msg.serial) { + lines.push(`${chalk.dim("Serial:")} ${msg.serial}`); + } + + if ( + msg.version && + Object.keys(msg.version).length > 0 && + msg.version.serial && + msg.version.serial !== msg.serial + ) { + lines.push(`${chalk.dim("Version:")}`); + if (msg.version.serial) { + lines.push(` ${chalk.dim("Serial:")} ${msg.version.serial}`); + } + if (msg.version.timestamp !== undefined) { + lines.push(` ${chalk.dim("Timestamp:")} ${msg.version.timestamp}`); + } + if (msg.version.clientId) { + lines.push( + ` ${chalk.dim("Client ID:")} ${chalk.blue(msg.version.clientId)}`, + ); + } + } + + if (msg.annotations && Object.keys(msg.annotations.summary).length > 0) { + lines.push(`${chalk.dim("Annotations:")}`); + for (const [type, entry] of Object.entries(msg.annotations.summary)) { + lines.push( + ` ${chalk.dim(`${type}:`)}`, + ` ${formatMessageData(entry)}`, + ); + } + } + + if (isJsonData(msg.data)) { + lines.push(`${chalk.dim("Data:")}\n${formatMessageData(msg.data)}`); + } else { + lines.push(`${chalk.dim("Data:")} ${String(msg.data)}`); + } + + return lines.join("\n"); + }); + + return formatted.join("\n\n"); +} + +/** + * Convert a single MessageDisplayFields to a plain object for JSON output. + * Includes all required fields, omits undefined optional fields. + * + * Usage: + * Single message (subscribe): toMessageJson(msg) + * Array of messages (history): messages.map(toMessageJson) + */ +export function toMessageJson( + msg: MessageDisplayFields, +): Record { + return { + timestamp: msg.timestamp, + channel: msg.channel, + event: msg.event, + ...(msg.id ? { id: msg.id } : {}), + ...(msg.clientId ? { clientId: msg.clientId } : {}), + ...(msg.serial ? { serial: msg.serial } : {}), + ...(msg.version ? { version: msg.version } : {}), + ...(msg.annotations ? { annotations: msg.annotations } : {}), + data: msg.data, + }; +} + export function formatPresenceAction(action: string): { symbol: string; color: ChalkInstance; diff --git a/test/e2e/channels/channels-e2e.test.ts b/test/e2e/channels/channels-e2e.test.ts index e5535938..9a937e40 100644 --- a/test/e2e/channels/channels-e2e.test.ts +++ b/test/e2e/channels/channels-e2e.test.ts @@ -291,7 +291,8 @@ describe("Channel E2E Tests", () => { ); } - expect(historyResult.stdout).toContain("Found"); + // New format outputs messages directly with field labels (no "Found" prefix) + expect(historyResult.stdout).toContain("Data:"); expect(historyResult.stdout).toContain("E2E History Test"); // Now verify with SDK in a separate step outside of Oclif's callback @@ -345,11 +346,11 @@ describe("Channel E2E Tests", () => { `Failed to parse JSON history output. Parse error: ${parseError}. Exit code: ${historyResult.exitCode}, Stderr: ${historyResult.stderr}, Stdout: ${historyResult.stdout}`, ); } - expect(result).toHaveProperty("messages"); - expect(Array.isArray(result.messages)).toBe(true); - expect(result.messages.length).toBeGreaterThanOrEqual(1); + // JSON output is now a plain array of message objects + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThanOrEqual(1); - const testMsg = result.messages.find( + const testMsg = result.find( (msg: any) => msg.data && typeof msg.data === "object" && diff --git a/test/unit/commands/channels/history.test.ts b/test/unit/commands/channels/history.test.ts index 8cdf49fe..2e892129 100644 --- a/test/unit/commands/channels/history.test.ts +++ b/test/unit/commands/channels/history.test.ts @@ -16,6 +16,22 @@ describe("channels:history command", () => { timestamp: 1700000000000, clientId: "client-1", connectionId: "conn-1", + version: { + serial: "v1-serial", + timestamp: 1700000000000, + clientId: "updater-1", + }, + annotations: { + summary: { + "reaction:distinct.v1": { + "👍": { + total: 3, + clientIds: ["c1", "c2", "c3"], + clipped: false, + }, + }, + }, + }, }, { id: "msg-2", @@ -71,9 +87,12 @@ describe("channels:history command", () => { import.meta.url, ); - expect(stdout).toContain("Found"); - expect(stdout).toContain("2"); - expect(stdout).toContain("messages"); + // Should show message fields in the new format + expect(stdout).toContain("Timestamp:"); + expect(stdout).toContain("Channel:"); + expect(stdout).toContain("test-channel"); + expect(stdout).toContain("[1]"); + expect(stdout).toContain("[2]"); expect(channel.history).toHaveBeenCalled(); }); @@ -83,9 +102,33 @@ describe("channels:history command", () => { import.meta.url, ); - expect(stdout).toContain("test-event"); + expect(stdout).toContain("Event: test-event"); expect(stdout).toContain("Hello world"); + expect(stdout).toContain("Client ID:"); expect(stdout).toContain("client-1"); + expect(stdout).toContain("ID:"); + expect(stdout).toContain("msg-1"); + }); + + it("should display version fields when present", async () => { + const { stdout } = await runCommand( + ["channels:history", "test-channel"], + import.meta.url, + ); + + expect(stdout).toContain("Version:"); + expect(stdout).toContain("v1-serial"); + expect(stdout).toContain("updater-1"); + }); + + it("should display annotations summary when present", async () => { + const { stdout } = await runCommand( + ["channels:history", "test-channel"], + import.meta.url, + ); + + expect(stdout).toContain("Annotations:"); + expect(stdout).toContain("reaction:distinct.v1:"); }); it("should handle empty history", async () => { @@ -108,12 +151,53 @@ describe("channels:history command", () => { ); const result = JSON.parse(stdout); - expect(result).toHaveProperty("messages"); - expect(result.messages).toHaveLength(2); - expect(result.messages[0]).toHaveProperty("id", "msg-1"); - expect(result.messages[0]).toHaveProperty("name", "test-event"); - expect(result.messages[0]).toHaveProperty("data"); - expect(result.messages[0].data).toEqual({ text: "Hello world" }); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty("id", "msg-1"); + expect(result[0]).toHaveProperty("event", "test-event"); + expect(result[0]).toHaveProperty("channel", "test-channel"); + expect(result[0]).toHaveProperty("timestamp"); + expect(result[0]).toHaveProperty("data"); + expect(result[0].data).toEqual({ text: "Hello world" }); + }); + + it("should include version in JSON output when present", async () => { + const { stdout } = await runCommand( + ["channels:history", "test-channel", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result[0]).toHaveProperty("version"); + expect(result[0].version).toEqual({ + serial: "v1-serial", + timestamp: 1700000000000, + clientId: "updater-1", + }); + // Second message has no version + expect(result[1]).not.toHaveProperty("version"); + }); + + it("should include annotations in JSON output when present", async () => { + const { stdout } = await runCommand( + ["channels:history", "test-channel", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result[0]).toHaveProperty("annotations"); + expect(result[0].annotations.summary).toHaveProperty( + "reaction:distinct.v1", + ); + expect( + result[0].annotations.summary["reaction:distinct.v1"]["👍"], + ).toEqual({ + total: 3, + clientIds: ["c1", "c2", "c3"], + clipped: false, + }); + // Second message has no annotations + expect(result[1]).not.toHaveProperty("annotations"); }); it("should respect --limit flag", async () => { diff --git a/test/unit/commands/channels/subscribe.test.ts b/test/unit/commands/channels/subscribe.test.ts index 81279070..42543d9d 100644 --- a/test/unit/commands/channels/subscribe.test.ts +++ b/test/unit/commands/channels/subscribe.test.ts @@ -121,14 +121,40 @@ describe("channels:subscribe command", () => { id: "msg-123", clientId: "publisher-client", connectionId: "conn-456", + version: { + serial: "ver-serial-1", + timestamp: Date.now(), + clientId: "version-client", + }, + annotations: { + summary: { + "reaction:distinct.v1": { + "👍": { total: 2, clientIds: ["c1", "c2"], clipped: false }, + }, + }, + }, }); const { stdout } = await commandPromise; - // Should have received and displayed the message with channel, event, and data + // Should have received and displayed the message with all fields + expect(stdout).toContain("Timestamp:"); + expect(stdout).toContain("Channel:"); expect(stdout).toContain("test-channel"); expect(stdout).toContain("Event: test-event"); + expect(stdout).toContain("ID:"); + expect(stdout).toContain("msg-123"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("publisher-client"); + expect(stdout).toContain("Data:"); expect(stdout).toContain("hello world"); + // Version fields + expect(stdout).toContain("Version:"); + expect(stdout).toContain("ver-serial-1"); + expect(stdout).toContain("version-client"); + // Annotations + expect(stdout).toContain("Annotations:"); + expect(stdout).toContain("reaction:distinct.v1:"); }); it("should run with --json flag without errors", async () => {