diff --git a/package.json b/package.json index 0bd30d29..62af78e1 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", - "ably": "^2.14.0", + "ably": "^2.18.0", "chalk": "5", "cli-table3": "^0.6.5", "color-json": "^3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc94f3f..f899f3e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,10 +15,10 @@ importers: dependencies: '@ably/chat': specifier: ^1.0.0 - version: 1.0.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.0(ably@2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ably/spaces': specifier: ^0.4.0 - version: 0.4.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.4.0(ably@2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@inquirer/prompts': specifier: ^5.1.3 version: 5.5.0 @@ -41,8 +41,8 @@ importers: specifier: ^5.5.0 version: 5.5.0 ably: - specifier: ^2.14.0 - version: 2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^2.18.0 + version: 2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) chalk: specifier: '5' version: 5.4.1 @@ -1654,133 +1654,111 @@ packages: resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.40.2': resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.0': resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.0': resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.0': resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.0': resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loongarch64-gnu@4.40.2': resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.0': resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.0': resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.40.2': resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.0': resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.0': resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.0': resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.0': resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} @@ -2103,28 +2081,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.5': resolution: {integrity: sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.5': resolution: {integrity: sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.5': resolution: {integrity: sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.5': resolution: {integrity: sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==} @@ -2482,43 +2456,36 @@ packages: resolution: {integrity: sha512-FX2FV7vpLE/+Z0NZX9/1pwWud5Wocm/2PgpUXbT5aSV3QEB10kBPJAzssOQylvdj8mOHoKl5pVkXpbCwww/T2g==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.5.0': resolution: {integrity: sha512-+gF97xst1BZb28T3nwwzEtq2ewCoMDGKsenYsZuvpmNrW0019G1iUAunZN+FG55L21y+uP7zsGX06OXDQ/viKw==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.5.0': resolution: {integrity: sha512-5bEmVcQw9js8JYM2LkUBw5SeELSIxX+qKf9bFrfFINKAp4noZ//hUxLpbF7u/3gTBN1GsER6xOzIZlw/VTdXtA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.5.0': resolution: {integrity: sha512-GGk/8TPUsf1Q99F+lzMdjE6sGL26uJCwQ9TlvBs8zR3cLQNw/MIumPN7zrs3GFGySjnwXc8gA6J3HKbejywmqA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-s390x-gnu@1.5.0': resolution: {integrity: sha512-5uRkFYYVNAeVaA4W/CwugjFN3iDOHCPqsBLCCOoJiMfFMMz4evBRsg+498OFa9w6VcTn2bD5aI+RRayaIgk2Sw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.5.0': resolution: {integrity: sha512-j905CZH3nehYy6NimNqC2B14pxn4Ltd7guKMyPTzKehbFXTUgihQS/ZfHQTdojkMzbSwBOSgq1dOrY+IpgxDsA==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.5.0': resolution: {integrity: sha512-dmLevQTuzQRwu5A+mvj54R5aye5I4PVKiWqGxg8tTaYP2k2oTs/3Mo8mgnhPk28VoYCi0fdFYpgzCd4AJndQvQ==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.5.0': resolution: {integrity: sha512-LtJMhwu7avhoi+kKfAZOKN773RtzLBVVF90YJbB0wyMpUj9yQPeA+mteVUI9P70OG/opH47FeV5AWeaNWWgqJg==} @@ -2654,8 +2621,8 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} - ably@2.14.0: - resolution: {integrity: sha512-GWNza+URnh/W5IuoJX7nXJpQCs2Dxby6t5A20vL3PBqGIJceA94/1xje4HOZbqFtMEPkRVsYHBIEuQRWL+CuvQ==} + ably@2.18.0: + resolution: {integrity: sha512-KVA9Y8GUaOyC6HTck3zzVkztdBKkHjIdsJB98eZ+sPmjbm3DQ6fgfqsTepiWudCaBC7984g1e/rHQ4q2thg9CQ==} engines: {node: '>=16'} peerDependencies: react: '>=16.8.0' @@ -4613,28 +4580,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} @@ -6348,9 +6311,9 @@ packages: snapshots: - '@ably/chat@1.0.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@ably/chat@1.0.0(ably@2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - ably: 2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ably: 2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) async-mutex: 0.5.0 dequal: 2.0.3 lodash.clonedeep: 4.5.0 @@ -6364,9 +6327,9 @@ snapshots: dependencies: bops: 1.0.1 - '@ably/spaces@0.4.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@ably/spaces@0.4.0(ably@2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - ably: 2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ably: 2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nanoid: 3.3.11 optionalDependencies: react: 18.3.1 @@ -9080,7 +9043,7 @@ snapshots: abbrev@3.0.1: {} - ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + ably@2.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@ably/msgpack-js': 0.4.0 dequal: 2.0.3 diff --git a/src/commands/channels/publish.ts b/src/commands/channels/publish.ts index bde8e7ca..b48365d8 100644 --- a/src/commands/channels/publish.ts +++ b/src/commands/channels/publish.ts @@ -225,7 +225,7 @@ export default class ChannelsPublish extends AblyBaseCommand { private async publishMessages( args: Record, flags: Record, - publisher: (msg: Ably.Message) => Promise, + publisher: (msg: Ably.Message) => Promise, ): Promise { // Validate count and delay const count = Math.max(1, flags.count as number); diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index bbefec8c..4bac2ff2 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -29,6 +29,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand { "$ ably channels subscribe my-channel --json", "$ ably channels subscribe my-channel --pretty-json", "$ ably channels subscribe my-channel --duration 30", + "$ ably channels subscribe --stream my-channel", ]; static override flags = { @@ -66,6 +67,11 @@ export default class ChannelsSubscribe extends AblyBaseCommand { default: false, description: "Include sequence numbers in output", }), + stream: Flags.boolean({ + default: false, + description: + "Stream mode: concatenates message.append data for the same serial, rewriting output in-place", + }), }; static override strict = false; @@ -74,6 +80,10 @@ export default class ChannelsSubscribe extends AblyBaseCommand { private client: Ably.Realtime | null = null; private sequenceCounter = 0; + // Stream mode state + private streamCurrentSerial: string | null = null; + private streamAppendCount = 0; + async run(): Promise { const { flags } = await this.parse(ChannelsSubscribe); const _args = await this.parse(ChannelsSubscribe); @@ -209,6 +219,9 @@ export default class ChannelsSubscribe extends AblyBaseCommand { event: message.name || "(none)", id: message.id, timestamp, + ...(flags.stream + ? { action: message.action, serial: message.serial } + : {}), ...(flags["sequence-numbers"] ? { sequence: this.sequenceCounter } : {}), @@ -223,6 +236,8 @@ export default class ChannelsSubscribe extends AblyBaseCommand { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(messageEvent, flags)); + } else if (flags.stream && message.serial) { + this.handleStreamMessage(message, channel.name, timestamp); } else { const name = message.name || "(none)"; const sequencePrefix = flags["sequence-numbers"] @@ -281,6 +296,12 @@ export default class ChannelsSubscribe extends AblyBaseCommand { // Wait until the user interrupts or the optional duration elapses const exitReason = await waitUntilInterruptedOrTimeout(flags.duration); + + // Finalize any in-progress stream output + if (flags.stream && this.streamCurrentSerial !== null) { + this.finalizeStream(); + } + this.logCliEvent(flags, "subscribe", "runComplete", "Exiting wait loop", { exitReason, }); @@ -305,4 +326,62 @@ export default class ChannelsSubscribe extends AblyBaseCommand { } } } + + private handleStreamMessage( + message: Ably.Message, + channelName: string, + timestamp: string, + ): void { + const action = message.action; + const serial = message.serial!; + const data = + typeof message.data === "string" + ? message.data + : JSON.stringify(message.data); + + // If we see a new serial, finalize the previous stream and start a new one + if (serial !== this.streamCurrentSerial) { + if (this.streamCurrentSerial !== null) { + this.finalizeStream(); + } + + this.streamCurrentSerial = serial; + this.streamAppendCount = 0; + } + + if (action === "message.create" || action === "message.append") { + this.streamAppendCount++; + const header = `${chalk.gray(`[${timestamp}]`)} ${chalk.cyan(channelName)}`; + + if (this.shouldUseTerminalUpdates()) { + // TTY: stream tokens inline — each append just writes its delta + if (action === "message.create") { + process.stdout.write(`${header} ${data}`); + } else { + process.stdout.write(data); + } + } else { + // Non-TTY / test: log each delta on its own line (captured by test runner) + this.log(`${header} ${data}`); + } + } else { + // For other actions (message.update, message.delete, etc.), display normally + const name = message.name || "(none)"; + this.log( + `${chalk.gray(`[${timestamp}]`)} ${chalk.cyan(`Channel: ${channelName}`)} | ${chalk.yellow(`Event: ${name}`)} | ${chalk.dim(`Action: ${action}`)}`, + ); + this.log(`${chalk.blue("Data:")} ${data}`); + this.log(""); + } + } + + private finalizeStream(): void { + const countLabel = `${this.streamAppendCount} msg${this.streamAppendCount === 1 ? "" : "s"}`; + if (this.shouldUseTerminalUpdates()) { + process.stdout.write(`\n${chalk.dim(`[${countLabel}]`)}\n\n`); + } else { + this.log(chalk.dim(`[${countLabel}]`)); + this.log(""); + } + } } diff --git a/test/unit/commands/channels/subscribe.test.ts b/test/unit/commands/channels/subscribe.test.ts index d2481f3f..f0a40da0 100644 --- a/test/unit/commands/channels/subscribe.test.ts +++ b/test/unit/commands/channels/subscribe.test.ts @@ -233,5 +233,217 @@ describe("channels:subscribe command", () => { }), ); }); + + it("should accept --stream flag", async () => { + const { stdout } = await runCommand( + ["channels:subscribe", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("--stream"); + }); + }); + + describe("stream mode", () => { + it("should display message.create data in stream mode", async () => { + const commandPromise = runCommand( + [ + "channels:subscribe", + "test-channel", + "--api-key", + "app.key:secret", + "--stream", + ], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockSubscribeCallback).not.toBeNull(); + }); + + mockSubscribeCallback!({ + name: "test-event", + data: "Hello", + timestamp: Date.now(), + id: "msg-1", + clientId: "client-1", + connectionId: "conn-1", + action: "message.create", + serial: "serial-001", + }); + + const { stdout } = await commandPromise; + + expect(stdout).toContain("Hello"); + expect(stdout).toContain("test-channel"); + expect(stdout).toContain("1 msg]"); + }); + + it("should stream message.append data for the same serial", async () => { + const commandPromise = runCommand( + [ + "channels:subscribe", + "test-channel", + "--api-key", + "app.key:secret", + "--stream", + ], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockSubscribeCallback).not.toBeNull(); + }); + + mockSubscribeCallback!({ + name: "test-event", + data: "Hello", + timestamp: Date.now(), + id: "msg-1", + clientId: "client-1", + connectionId: "conn-1", + action: "message.create", + serial: "serial-001", + }); + + mockSubscribeCallback!({ + name: "test-event", + data: " World", + timestamp: Date.now(), + id: "msg-2", + clientId: "client-1", + connectionId: "conn-1", + action: "message.append", + serial: "serial-001", + }); + + const { stdout } = await commandPromise; + + // In non-TTY test mode, each token is logged on its own line + expect(stdout).toContain("Hello"); + expect(stdout).toContain(" World"); + expect(stdout).toContain("2 msgs]"); + }); + + it("should include action and serial in JSON output with --stream", async () => { + const commandPromise = runCommand( + [ + "channels:subscribe", + "test-channel", + "--api-key", + "app.key:secret", + "--stream", + "--json", + ], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockSubscribeCallback).not.toBeNull(); + }); + + mockSubscribeCallback!({ + name: "test-event", + data: "Hello", + timestamp: Date.now(), + id: "msg-1", + clientId: "client-1", + connectionId: "conn-1", + action: "message.create", + serial: "serial-001", + }); + + mockSubscribeCallback!({ + name: "test-event", + data: " World", + timestamp: Date.now(), + id: "msg-2", + clientId: "client-1", + connectionId: "conn-1", + action: "message.append", + serial: "serial-001", + }); + + const { stdout } = await commandPromise; + + expect(stdout).toContain("message.create"); + expect(stdout).toContain("message.append"); + expect(stdout).toContain("serial-001"); + }); + + it("should reset accumulation when serial changes", async () => { + const commandPromise = runCommand( + [ + "channels:subscribe", + "test-channel", + "--api-key", + "app.key:secret", + "--stream", + ], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockSubscribeCallback).not.toBeNull(); + }); + + mockSubscribeCallback!({ + name: "event", + data: "First", + timestamp: Date.now(), + id: "msg-1", + clientId: "c1", + connectionId: "conn-1", + action: "message.create", + serial: "serial-001", + }); + + mockSubscribeCallback!({ + name: "event", + data: "Second", + timestamp: Date.now(), + id: "msg-2", + clientId: "c1", + connectionId: "conn-1", + action: "message.create", + serial: "serial-002", + }); + + const { stdout } = await commandPromise; + + expect(stdout).toContain("First"); + expect(stdout).toContain("Second"); + }); + + it("should display messages without serial normally in stream mode", async () => { + const commandPromise = runCommand( + [ + "channels:subscribe", + "test-channel", + "--api-key", + "app.key:secret", + "--stream", + ], + import.meta.url, + ); + + await vi.waitFor(() => { + expect(mockSubscribeCallback).not.toBeNull(); + }); + + mockSubscribeCallback!({ + name: "test-event", + data: "plain message", + timestamp: Date.now(), + id: "msg-1", + clientId: "client-1", + connectionId: "conn-1", + }); + + const { stdout } = await commandPromise; + + expect(stdout).toContain("plain message"); + expect(stdout).toContain("Event: test-event"); + }); }); });