From b982870dcc76405b23c1fa4d89e4b80319fe411f Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 1 Apr 2026 13:37:03 +0100 Subject: [PATCH 1/5] Adds more happy path E2E tests, now covering most commands --- src/base-command.ts | 9 + src/commands/logs/subscribe.ts | 9 - src/commands/rooms/typing/keystroke.ts | 28 +- src/commands/spaces/cursors/get.ts | 1 + test/e2e/accounts/accounts-e2e.test.ts | 68 +++++ test/e2e/auth/auth-keys-e2e.test.ts | 224 ++++++++++++++ test/e2e/auth/auth-tokens-e2e.test.ts | 154 ++++++++++ .../channels/channel-annotations-e2e.test.ts | 272 +++++++++++++++++ .../channel-batch-publish-e2e.test.ts | 96 ++++++ .../channels/channel-message-ops-e2e.test.ts | 146 +++++++++ .../channel-occupancy-get-e2e.test.ts | 96 ++++++ .../channel-presence-subscribe-e2e.test.ts | 104 +++++++ test/e2e/config/config-e2e.test.ts | 96 ++++++ .../e2e/integrations/integrations-e2e.test.ts | 150 ++++++++++ test/e2e/logs/logs-e2e.test.ts | 205 +++++++++++++ test/e2e/push/push-config-e2e.test.ts | 4 - test/e2e/rooms/rooms-list-e2e.test.ts | 64 ++++ test/e2e/rooms/rooms-messages-e2e.test.ts | 226 ++++++++++++++ .../rooms-messages-reactions-e2e.test.ts | 280 ++++++++++++++++++ .../rooms-messages-subscribe-e2e.test.ts | 112 +++++++ test/e2e/rooms/rooms-occupancy-e2e.test.ts | 149 ++++++++++ test/e2e/rooms/rooms-presence-e2e.test.ts | 170 +++++++++++ .../rooms-presence-subscribe-e2e.test.ts | 107 +++++++ test/e2e/rooms/rooms-reactions-e2e.test.ts | 112 +++++++ test/e2e/rooms/rooms-typing-e2e.test.ts | 104 +++++++ test/e2e/spaces/spaces-crud-e2e.test.ts | 122 ++++++++ test/e2e/spaces/spaces-locations-e2e.test.ts | 236 +++++++++++++++ test/e2e/spaces/spaces-occupancy-e2e.test.ts | 95 ++++++ test/e2e/spaces/spaces-subscribe-e2e.test.ts | 106 +++++++ test/e2e/stats/stats.test.ts | 4 - test/e2e/status/status-e2e.test.ts | 68 +++++ test/e2e/support/support-e2e.test.ts | 48 +++ test/e2e/web-cli/terminal-ui.test.ts | 4 +- test/helpers/e2e-mutable-messages.ts | 125 ++++++++ test/helpers/e2e-test-helper.ts | 2 + test/unit/base/base-command.test.ts | 22 +- 36 files changed, 3769 insertions(+), 49 deletions(-) create mode 100644 test/e2e/accounts/accounts-e2e.test.ts create mode 100644 test/e2e/auth/auth-keys-e2e.test.ts create mode 100644 test/e2e/auth/auth-tokens-e2e.test.ts create mode 100644 test/e2e/channels/channel-annotations-e2e.test.ts create mode 100644 test/e2e/channels/channel-batch-publish-e2e.test.ts create mode 100644 test/e2e/channels/channel-message-ops-e2e.test.ts create mode 100644 test/e2e/channels/channel-occupancy-get-e2e.test.ts create mode 100644 test/e2e/channels/channel-presence-subscribe-e2e.test.ts create mode 100644 test/e2e/config/config-e2e.test.ts create mode 100644 test/e2e/integrations/integrations-e2e.test.ts create mode 100644 test/e2e/logs/logs-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-list-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-messages-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-messages-reactions-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-messages-subscribe-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-occupancy-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-presence-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-presence-subscribe-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-reactions-e2e.test.ts create mode 100644 test/e2e/rooms/rooms-typing-e2e.test.ts create mode 100644 test/e2e/spaces/spaces-crud-e2e.test.ts create mode 100644 test/e2e/spaces/spaces-locations-e2e.test.ts create mode 100644 test/e2e/spaces/spaces-occupancy-e2e.test.ts create mode 100644 test/e2e/spaces/spaces-subscribe-e2e.test.ts create mode 100644 test/e2e/status/status-e2e.test.ts create mode 100644 test/e2e/support/support-e2e.test.ts create mode 100644 test/helpers/e2e-mutable-messages.ts diff --git a/src/base-command.ts b/src/base-command.ts index e825422c..f112780f 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -751,6 +751,15 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { return { apiKey, appId }; } + // Fall back to ABLY_API_KEY environment variable (for CI/scripting) + const envApiKey = process.env.ABLY_API_KEY; + if (envApiKey) { + const envAppId = envApiKey.split(".")[0] || ""; + if (envAppId) { + return { apiKey: envApiKey, appId: envAppId }; + } + } + // Get access token for control API const accessToken = process.env.ABLY_ACCESS_TOKEN || this.configManager.getAccessToken(); diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index e4008225..9c174cca 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -62,15 +62,6 @@ export default class LogsSubscribe extends AblyBaseCommand { includeUserFriendlyMessages: true, }); - // Get the logs channel - const appConfig = await this.ensureAppAndKey(flags); - if (!appConfig) { - this.fail( - "Unable to determine app configuration", - flags, - "logSubscribe", - ); - } const logsChannelName = `[meta]log`; // Configure channel options for rewind if specified diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index 6d48348a..9de82318 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -160,18 +160,24 @@ export default class TypingKeystroke extends ChatBaseCommand { }, KEYSTROKE_INTERVAL); } - this.logCliEvent( - flags, - "typing", - "listening", - "Maintaining typing status...", - ); + // If auto-type is enabled, keep the command running to maintain typing state + if (flags["auto-type"]) { + this.logCliEvent( + flags, + "typing", + "listening", + "Maintaining typing status...", + ); - // Wait until the user interrupts, duration elapses, or the room fails - await Promise.race([ - this.waitAndTrackCleanup(flags, "typing", flags.duration), - failurePromise, - ]); + // Wait until the user interrupts, duration elapses, or the room fails + await Promise.race([ + this.waitAndTrackCleanup(flags, "typing", flags.duration), + failurePromise, + ]); + } else { + // Suppress unhandled rejection if room fails during cleanup + failurePromise.catch(() => {}); + } } catch (error) { this.fail(error, flags, "roomTypingKeystroke", { room: args.room }); } diff --git a/src/commands/spaces/cursors/get.ts b/src/commands/spaces/cursors/get.ts index f3177bf9..9d7f9a8d 100644 --- a/src/commands/spaces/cursors/get.ts +++ b/src/commands/spaces/cursors/get.ts @@ -49,6 +49,7 @@ export default class SpacesCursorsGet extends SpacesBaseCommand { flags, ); + await this.waitForCursorsChannelAttachment(flags); const allCursors = await this.space!.cursors.getAll(); const cursors: CursorUpdate[] = Object.values(allCursors).filter( diff --git a/test/e2e/accounts/accounts-e2e.test.ts b/test/e2e/accounts/accounts-e2e.test.ts new file mode 100644 index 00000000..9c2c02ce --- /dev/null +++ b/test/e2e/accounts/accounts-e2e.test.ts @@ -0,0 +1,68 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_ACCESS_TOKEN, + SHOULD_SKIP_CONTROL_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Accounts E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + it( + "should list locally configured accounts", + { timeout: 15000 }, + async () => { + setupTestFailureHandler("should list locally configured accounts"); + + // accounts list reads from local config, not the API directly. + // In E2E environment, there may or may not be configured accounts. + // We just verify the command runs without crashing. + const listResult = await runCommand(["accounts", "list"], { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }); + + // The command may exit 0 (accounts found) or non-zero (no accounts configured). + // Either way, it should produce output and not crash. + const combinedOutput = listResult.stdout + listResult.stderr; + expect(combinedOutput.length).toBeGreaterThan(0); + }, + ); + + it("should show help for accounts current", { timeout: 10000 }, async () => { + setupTestFailureHandler("should show help for accounts current"); + + const helpResult = await runCommand(["accounts", "current", "--help"], { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }); + + expect(helpResult.exitCode).toBe(0); + const output = helpResult.stdout + helpResult.stderr; + expect(output).toContain("USAGE"); + }); +}); diff --git a/test/e2e/auth/auth-keys-e2e.test.ts b/test/e2e/auth/auth-keys-e2e.test.ts new file mode 100644 index 00000000..07400b1d --- /dev/null +++ b/test/e2e/auth/auth-keys-e2e.test.ts @@ -0,0 +1,224 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_ACCESS_TOKEN, + SHOULD_SKIP_CONTROL_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Auth Keys E2E Tests", () => { + let testAppId: string; + + beforeAll(async () => { + process.on("SIGINT", forceExit); + + // Create a test app for key operations + const createResult = await runCommand( + ["apps", "create", "--name", `e2e-keys-test-${Date.now()}`, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + if (createResult.exitCode !== 0) { + throw new Error(`Failed to create test app: ${createResult.stderr}`); + } + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const app = result.app as Record; + testAppId = (app.id ?? app.appId) as string; + if (!testAppId) { + throw new Error(`No app ID found in result: ${JSON.stringify(result)}`); + } + }); + + afterAll(async () => { + if (testAppId) { + try { + await runCommand(["apps", "delete", testAppId, "--force"], { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }); + } catch { + // Ignore cleanup errors — the app may already be deleted + } + } + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + it("should list API keys for an app", { timeout: 15000 }, async () => { + setupTestFailureHandler("should list API keys for an app"); + + const listResult = await runCommand( + ["auth", "keys", "list", "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(listResult.exitCode).toBe(0); + const records = parseNdjsonLines(listResult.stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("success", true); + expect(Array.isArray(result!.keys)).toBe(true); + // Every app has at least one default key + expect((result!.keys as unknown[]).length).toBeGreaterThan(0); + }); + + it("should create a new API key", { timeout: 15000 }, async () => { + setupTestFailureHandler("should create a new API key"); + + const keyName = `e2e-test-key-${Date.now()}`; + const createResult = await runCommand( + [ + "auth", + "keys", + "create", + "--app", + testAppId, + "--name", + keyName, + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(createResult.exitCode).toBe(0); + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(result).toBeDefined(); + expect(result).toHaveProperty("success", true); + const key = result.key as Record; + expect(key).toHaveProperty("name", keyName); + expect(key).toHaveProperty("key"); + expect(key).toHaveProperty("keyName"); + }); + + it("should get details for a specific key", { timeout: 20000 }, async () => { + setupTestFailureHandler("should get details for a specific key"); + + // First create a key to get + const keyName = `e2e-get-key-${Date.now()}`; + const createResult = await runCommand( + [ + "auth", + "keys", + "create", + "--app", + testAppId, + "--name", + keyName, + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + const createRecord = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const createdKey = createRecord.key as Record; + const keyFullName = createdKey.keyName as string; + + // Now get that key by its name + const getResult = await runCommand( + ["auth", "keys", "get", keyFullName, "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(getResult.exitCode).toBe(0); + const getRecord = parseNdjsonLines(getResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(getRecord).toBeDefined(); + expect(getRecord).toHaveProperty("success", true); + const fetchedKey = getRecord.key as Record; + expect(fetchedKey).toHaveProperty("name", keyName); + expect(fetchedKey).toHaveProperty("keyName", keyFullName); + }); + + it("should update a key name", { timeout: 20000 }, async () => { + setupTestFailureHandler("should update a key name"); + + // First create a key to update + const originalName = `e2e-update-key-${Date.now()}`; + const createResult = await runCommand( + [ + "auth", + "keys", + "create", + "--app", + testAppId, + "--name", + originalName, + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + const createRecord = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const createdKey = createRecord.key as Record; + const keyFullName = createdKey.keyName as string; + + // Update the key name + const updatedName = `updated-key-${Date.now()}`; + const updateResult = await runCommand( + [ + "auth", + "keys", + "update", + keyFullName, + "--app", + testAppId, + "--name", + updatedName, + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(updateResult.exitCode).toBe(0); + const updateRecord = parseNdjsonLines(updateResult.stdout).find( + (r) => r.type === "result", + ) as Record; + expect(updateRecord).toBeDefined(); + expect(updateRecord).toHaveProperty("success", true); + const updatedKey = updateRecord.key as Record; + const nameChange = updatedKey.name as Record; + expect(nameChange).toHaveProperty("before", originalName); + expect(nameChange).toHaveProperty("after", updatedName); + }); +}); diff --git a/test/e2e/auth/auth-tokens-e2e.test.ts b/test/e2e/auth/auth-tokens-e2e.test.ts new file mode 100644 index 00000000..f1333fd3 --- /dev/null +++ b/test/e2e/auth/auth-tokens-e2e.test.ts @@ -0,0 +1,154 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Auth Tokens E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("auth issue-ably-token", () => { + it("should issue an Ably token with --json", async () => { + setupTestFailureHandler("should issue an Ably token with --json"); + + const result = await runCommand(["auth", "issue-ably-token", "--json"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }); + + expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + + const token = resultRecord!.token as Record; + expect(token).toBeDefined(); + expect(token.value).toBeDefined(); + expect(typeof token.value).toBe("string"); + expect((token.value as string).length).toBeGreaterThan(0); + expect(token.issuedAt).toBeDefined(); + expect(token.expiresAt).toBeDefined(); + expect(token.capability).toBeDefined(); + }); + }); + + describe("auth issue-jwt-token", () => { + it("should issue a JWT token with --json", async () => { + setupTestFailureHandler("should issue a JWT token with --json"); + + const result = await runCommand(["auth", "issue-jwt-token", "--json"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }); + + expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + + const token = resultRecord!.token as Record; + expect(token).toBeDefined(); + expect(token.value).toBeDefined(); + expect(typeof token.value).toBe("string"); + + // JWT tokens have three dot-separated parts (header.payload.signature) + const jwtValue = token.value as string; + const parts = jwtValue.split("."); + expect(parts).toHaveLength(3); + + expect(token.tokenType).toBe("jwt"); + expect(token.appId).toBeDefined(); + expect(token.keyId).toBeDefined(); + }); + }); + + describe("auth revoke-token", () => { + it("should issue a token and then revoke it", async () => { + setupTestFailureHandler("should issue a token and then revoke it"); + + // Step 1: Issue an Ably token with a known client ID + const issueResult = await runCommand( + [ + "auth", + "issue-ably-token", + "--json", + "--client-id", + "e2e-revoke-test", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(issueResult.exitCode).toBe(0); + + const issueRecords = parseNdjsonLines(issueResult.stdout); + const issueResultRecord = issueRecords.find((r) => r.type === "result"); + + expect(issueResultRecord).toBeDefined(); + const issuedToken = issueResultRecord!.token as Record; + expect(issuedToken.value).toBeDefined(); + + const tokenValue = issuedToken.value as string; + + // Step 2: Revoke the token using its client ID + const revokeResult = await runCommand( + [ + "auth", + "revoke-token", + tokenValue, + "--client-id", + "e2e-revoke-test", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(revokeResult.exitCode).toBe(0); + + const revokeRecords = parseNdjsonLines(revokeResult.stdout); + const revokeResultRecord = revokeRecords.find((r) => r.type === "result"); + + expect(revokeResultRecord).toBeDefined(); + expect(revokeResultRecord!.success).toBe(true); + }); + }); +}); diff --git a/test/e2e/channels/channel-annotations-e2e.test.ts b/test/e2e/channels/channel-annotations-e2e.test.ts new file mode 100644 index 00000000..a62346c6 --- /dev/null +++ b/test/e2e/channels/channel-annotations-e2e.test.ts @@ -0,0 +1,272 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; +import { + checkMutableMessagesSupport, + publishAndGetSerial, +} from "../../helpers/e2e-mutable-messages.js"; + +function findResult(stdout: string): Record { + const records = parseNdjsonLines(stdout); + return records.find((r) => r.type === "result") ?? records.at(-1) ?? {}; +} + +// Check if the E2E test app supports mutable messages (required for annotations) +const mutableMessagesSupported = SHOULD_SKIP_E2E + ? false + : await checkMutableMessagesSupport(); + +describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( + "Channel Annotations E2E Tests", + () => { + let channelName: string; + let messageSerial: string; + + beforeAll(async () => { + process.on("SIGINT", forceExit); + + // Publish a test message and get its serial for use in all tests + channelName = getUniqueChannelName("annotations"); + messageSerial = await publishAndGetSerial(channelName, "annotate-me"); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + it( + "should publish an annotation on a message", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should publish an annotation on a message"); + + const result = await runCommand( + [ + "channels", + "annotations", + "publish", + channelName, + messageSerial, + "reactions:like.v1", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const parsed = findResult(result.stdout); + expect(parsed.success).toBe(true); + expect(parsed.annotation).toBeDefined(); + + const annotation = parsed.annotation as { + channel: string; + serial: string; + type: string; + }; + expect(annotation.channel).toBe(channelName); + expect(annotation.serial).toBe(messageSerial); + expect(annotation.type).toBe("reactions:like.v1"); + }, + ); + + it("should get annotations for a message", { timeout: 60000 }, async () => { + setupTestFailureHandler("should get annotations for a message"); + + // First publish an annotation to ensure there is one + const publishResult = await runCommand( + [ + "channels", + "annotations", + "publish", + channelName, + messageSerial, + "metrics:total.v1", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + expect(publishResult.exitCode).toBe(0); + + // Wait for annotation to be indexed + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const result = await runCommand( + [ + "channels", + "annotations", + "get", + channelName, + messageSerial, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const parsed = findResult(result.stdout); + expect(parsed.success).toBe(true); + expect(parsed.annotations).toBeDefined(); + expect(Array.isArray(parsed.annotations)).toBe(true); + }); + + it( + "should delete an annotation on a message", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should delete an annotation on a message"); + + // First publish an annotation to delete + const annotationType = "receipts:flag.v1"; + const publishResult = await runCommand( + [ + "channels", + "annotations", + "publish", + channelName, + messageSerial, + annotationType, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + expect(publishResult.exitCode).toBe(0); + + // Wait for annotation to be indexed + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Now delete it + const result = await runCommand( + [ + "channels", + "annotations", + "delete", + channelName, + messageSerial, + annotationType, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const parsed = findResult(result.stdout); + expect(parsed.success).toBe(true); + expect(parsed.annotation).toBeDefined(); + + const annotation = parsed.annotation as { + channel: string; + serial: string; + type: string; + }; + expect(annotation.channel).toBe(channelName); + expect(annotation.serial).toBe(messageSerial); + expect(annotation.type).toBe(annotationType); + }, + ); + + it( + "should subscribe to annotation events on a channel", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should subscribe to annotation events on a channel", + ); + + let subscriber: CliRunner | null = null; + + try { + // Start subscribing to annotations + subscriber = await startSubscribeCommand( + [ + "channels", + "annotations", + "subscribe", + channelName, + "--duration", + "30", + ], + /Listening for annotations/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Publish an annotation to trigger the subscriber + const publishResult = await runCommand( + [ + "channels", + "annotations", + "publish", + channelName, + messageSerial, + "reactions:subscribe-test.v1", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + expect(publishResult.exitCode).toBe(0); + + // Wait for the annotation event to appear in subscriber output + await waitForOutput(subscriber, "subscribe-test", 15000); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + }, +); diff --git a/test/e2e/channels/channel-batch-publish-e2e.test.ts b/test/e2e/channels/channel-batch-publish-e2e.test.ts new file mode 100644 index 00000000..00dde839 --- /dev/null +++ b/test/e2e/channels/channel-batch-publish-e2e.test.ts @@ -0,0 +1,96 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + createAblyClient, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Channel Batch Publish E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + it( + "should batch publish a message to multiple channels and verify via SDK history", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should batch publish a message to multiple channels and verify via SDK history", + ); + + const ch1 = getUniqueChannelName("batch1"); + const ch2 = getUniqueChannelName("batch2"); + + const result = await runCommand( + [ + "channels", + "batch-publish", + "hello", + "--channels", + `${ch1},${ch2}`, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultLine = records.find((r) => r.type === "result"); + expect(resultLine).toBeDefined(); + expect(resultLine!.success).toBe(true); + + // Verify via SDK that at least one channel received the message + const client = createAblyClient(); + const channel = client.channels.get(ch1); + + // Retry until history is available (eventually consistent) + let found = false; + for (let i = 0; i < 10; i++) { + const historyPage = await channel.history(); + const messages = historyPage.items; + if ( + messages.some( + (msg) => + msg.data === "hello" || JSON.stringify(msg.data) === '"hello"', + ) + ) { + found = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + expect(found).toBe(true); + }, + ); +}); diff --git a/test/e2e/channels/channel-message-ops-e2e.test.ts b/test/e2e/channels/channel-message-ops-e2e.test.ts new file mode 100644 index 00000000..6d7c9a60 --- /dev/null +++ b/test/e2e/channels/channel-message-ops-e2e.test.ts @@ -0,0 +1,146 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; +import { + checkMutableMessagesSupport, + publishAndGetSerial, +} from "../../helpers/e2e-mutable-messages.js"; + +// Check if the E2E test app supports mutable messages (required for update/append/delete) +const mutableMessagesSupported = SHOULD_SKIP_E2E + ? false + : await checkMutableMessagesSupport(); + +describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( + "Channel Message Operations E2E Tests", + () => { + let channelName: string; + let messageSerial: string; + + beforeAll(async () => { + process.on("SIGINT", forceExit); + + // Publish a test message and get its serial for use in all tests + channelName = getUniqueChannelName("msg-ops"); + messageSerial = await publishAndGetSerial(channelName, "test-msg"); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + it( + "should update a message via channels update", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should update a message via channels update"); + + const result = await runCommand( + [ + "channels", + "update", + channelName, + messageSerial, + "updated-text", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const parsed = records.find((r) => r.type === "result") ?? records[0]; + expect(parsed.success).toBe(true); + expect(parsed.message).toBeDefined(); + }, + ); + + it( + "should append to a message via channels append", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should append to a message via channels append", + ); + + const result = await runCommand( + [ + "channels", + "append", + channelName, + messageSerial, + "appended-text", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const parsed = records.find((r) => r.type === "result") ?? records[0]; + expect(parsed.success).toBe(true); + expect(parsed.message).toBeDefined(); + }, + ); + + it( + "should delete a message via channels delete", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should delete a message via channels delete"); + + // Publish a fresh message to delete (so we don't conflict with update/append tests) + const deleteChannel = getUniqueChannelName("msg-delete"); + const serial = await publishAndGetSerial(deleteChannel, "to-delete"); + + const result = await runCommand( + ["channels", "delete", deleteChannel, serial, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const parsed = records.find((r) => r.type === "result") ?? records[0]; + expect(parsed.success).toBe(true); + expect(parsed.message).toBeDefined(); + }, + ); + }, +); diff --git a/test/e2e/channels/channel-occupancy-get-e2e.test.ts b/test/e2e/channels/channel-occupancy-get-e2e.test.ts new file mode 100644 index 00000000..fc28484c --- /dev/null +++ b/test/e2e/channels/channel-occupancy-get-e2e.test.ts @@ -0,0 +1,96 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Channel Occupancy Get E2E Tests", () => { + const runners: CliRunner[] = []; + + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupRunners(runners); + runners.length = 0; + await cleanupTrackedResources(); + }); + + it( + "should get occupancy data for a channel with an active subscriber", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should get occupancy data for a channel with an active subscriber", + ); + + const channel = getUniqueChannelName("occupancy"); + + // Start a subscriber to create some occupancy + const subscriber = await startSubscribeCommand( + ["channels", "subscribe", channel, "--duration", "30"], + /Listening for messages/, + { env: { ABLY_API_KEY: E2E_API_KEY || "" } }, + ); + runners.push(subscriber); + + // Give some time for the subscriber to be fully registered + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get occupancy via CLI + const result = await runCommand( + ["channels", "occupancy", "get", channel, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + + // Parse JSON output + const records = parseNdjsonLines(result.stdout); + const resultObj = records.find((r) => r.type === "result" || r.occupancy); + + expect(resultObj).toBeDefined(); + expect(resultObj!.occupancy).toBeDefined(); + + const occupancy = resultObj!.occupancy as { + channel: string; + metrics: Record; + }; + expect(occupancy.channel).toBe(channel); + expect(occupancy.metrics).toBeDefined(); + expect(typeof occupancy.metrics.subscribers).toBe("number"); + }, + ); +}); diff --git a/test/e2e/channels/channel-presence-subscribe-e2e.test.ts b/test/e2e/channels/channel-presence-subscribe-e2e.test.ts new file mode 100644 index 00000000..c42ff5df --- /dev/null +++ b/test/e2e/channels/channel-presence-subscribe-e2e.test.ts @@ -0,0 +1,104 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + startSubscribeCommand, + startPresenceCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Channel Presence Subscribe E2E Tests", () => { + const runners: CliRunner[] = []; + + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupRunners(runners); + runners.length = 0; + await cleanupTrackedResources(); + }); + + it( + "should receive presence enter events on a subscribed channel", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should receive presence enter events on a subscribed channel", + ); + + const channel = getUniqueChannelName("pres-sub"); + const subClientId = getUniqueClientId("sub-client"); + const enterClientId = getUniqueClientId("enter-client"); + + // Start presence subscriber + const subscriber = await startSubscribeCommand( + [ + "channels", + "presence", + "subscribe", + channel, + "--client-id", + subClientId, + "--duration", + "30", + ], + /Listening for presence events/, + { env: { ABLY_API_KEY: E2E_API_KEY || "" } }, + ); + runners.push(subscriber); + + // Start presence enter on the same channel with a different client + const enterer = await startPresenceCommand( + [ + "channels", + "presence", + "enter", + channel, + "--client-id", + enterClientId, + "--data", + '{"status":"online"}', + "--duration", + "30", + ], + /Entered presence/, + { env: { ABLY_API_KEY: E2E_API_KEY || "" } }, + ); + runners.push(enterer); + + // Wait for the subscriber to see the enter event + await waitForOutput(subscriber, enterClientId, 15000); + + const output = subscriber.combined(); + expect(output).toContain(enterClientId); + }, + ); +}); diff --git a/test/e2e/config/config-e2e.test.ts b/test/e2e/config/config-e2e.test.ts new file mode 100644 index 00000000..9e756822 --- /dev/null +++ b/test/e2e/config/config-e2e.test.ts @@ -0,0 +1,96 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe("Config E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("config show", () => { + it("should run config show without crashing", async () => { + setupTestFailureHandler("should run config show without crashing"); + + const result = await runCommand(["config", "show"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 10000, + }); + + // Command should either succeed (config exists) or fail gracefully + // (config missing — exit code 1 in JSON mode, 2 in non-JSON mode). + expect([0, 1, 2]).toContain(result.exitCode); + }); + + it("should run config show with --json without crashing", async () => { + setupTestFailureHandler( + "should run config show with --json without crashing", + ); + + const result = await runCommand(["config", "show", "--json"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 10000, + }); + + // Same as above — just verify it doesn't crash + expect([0, 1]).toContain(result.exitCode); + }); + }); + + describe("config path", () => { + it("should print the config file path", async () => { + setupTestFailureHandler("should print the config file path"); + + const result = await runCommand(["config", "path"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 10000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\.ably/); + }); + + it("should output config path as JSON", async () => { + setupTestFailureHandler("should output config path as JSON"); + + const result = await runCommand(["config", "path", "--json"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 10000, + }); + + expect(result.exitCode).toBe(0); + const lines = result.stdout + .trim() + .split("\n") + .filter((l) => l.trim().startsWith("{")); + expect(lines.length).toBeGreaterThan(0); + const json = JSON.parse(lines[0]); + expect(json).toHaveProperty("config"); + expect(json.config).toHaveProperty("path"); + }); + }); +}); diff --git a/test/e2e/integrations/integrations-e2e.test.ts b/test/e2e/integrations/integrations-e2e.test.ts new file mode 100644 index 00000000..c24465be --- /dev/null +++ b/test/e2e/integrations/integrations-e2e.test.ts @@ -0,0 +1,150 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_ACCESS_TOKEN, + SHOULD_SKIP_CONTROL_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { + let testAppId: string; + + beforeAll(async () => { + process.on("SIGINT", forceExit); + + // Create a test app for integration operations + const createResult = await runCommand( + [ + "apps", + "create", + "--name", + `e2e-integrations-test-${Date.now()}`, + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + if (createResult.exitCode !== 0) { + throw new Error(`Failed to create test app: ${createResult.stderr}`); + } + const result = parseNdjsonLines(createResult.stdout).find( + (r) => r.type === "result", + ) as Record; + const app = result.app as Record; + testAppId = (app.id ?? app.appId) as string; + if (!testAppId) { + throw new Error(`No app ID found in result: ${JSON.stringify(result)}`); + } + }); + + afterAll(async () => { + if (testAppId) { + try { + await runCommand(["apps", "delete", testAppId, "--force"], { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }); + } catch { + // Ignore cleanup errors — the app may already be deleted + } + } + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + it("should list integrations for an app", { timeout: 15000 }, async () => { + setupTestFailureHandler("should list integrations for an app"); + + const listResult = await runCommand( + ["integrations", "list", "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(listResult.exitCode).toBe(0); + }); + + it( + "should create, get, and delete an integration rule", + { timeout: 30000 }, + async () => { + setupTestFailureHandler( + "should create, get, and delete an integration rule", + ); + + // Create an HTTP integration rule + const createResult = await runCommand( + [ + "integrations", + "create", + "--app", + testAppId, + "--rule-type", + "http", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/e2e-webhook-test", + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(createResult.exitCode).toBe(0); + + // Extract the rule ID from the result + const createLines = parseNdjsonLines(createResult.stdout); + const createRecord = createLines.find((r) => r.type === "result"); + expect(createRecord).toBeDefined(); + + const rule = (createRecord?.rule ?? createRecord?.integration) as + | Record + | undefined; + const ruleId = (rule?.id ?? rule?.ruleId ?? "") as string; + expect(ruleId).toBeTruthy(); + + // Get the integration rule by ID + const getResult = await runCommand( + ["integrations", "get", ruleId, "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(getResult.exitCode).toBe(0); + + // Delete the integration rule + const deleteResult = await runCommand( + ["integrations", "delete", ruleId, "--app", testAppId, "--force"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(deleteResult.exitCode).toBe(0); + }, + ); +}); diff --git a/test/e2e/logs/logs-e2e.test.ts b/test/e2e/logs/logs-e2e.test.ts new file mode 100644 index 00000000..c1c2941b --- /dev/null +++ b/test/e2e/logs/logs-e2e.test.ts @@ -0,0 +1,205 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Logs E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("logs history", () => { + it("should retrieve application log history", async () => { + setupTestFailureHandler("should retrieve application log history"); + + const result = await runCommand(["logs", "history", "--json"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }); + + // Should succeed even if empty + expect(result.exitCode).toBe(0); + }); + }); + + describe("logs subscribe", () => { + it( + "should subscribe to live application logs", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should subscribe to live application logs"); + + let subscriber: CliRunner | null = null; + + try { + // Start subscribing to logs + subscriber = await startSubscribeCommand( + ["logs", "subscribe", "--rewind", "1", "--duration", "30"], + /Listening for log events/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // The subscriber connected successfully - that's the smoke test + expect(subscriber.isRunning()).toBe(true); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + }); + + describe("logs channel-lifecycle subscribe", () => { + it( + "should subscribe to channel lifecycle events", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should subscribe to channel lifecycle events"); + + let subscriber: CliRunner | null = null; + + try { + subscriber = await startSubscribeCommand( + ["logs", "channel-lifecycle", "subscribe", "--duration", "30"], + /Listening for channel lifecycle/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(subscriber.isRunning()).toBe(true); + + // Trigger a channel lifecycle event by publishing to a new channel + const channelName = getUniqueChannelName("lifecycle-trigger"); + await runCommand(["channels", "publish", channelName, "trigger"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + }); + + describe("logs connection-lifecycle", () => { + it( + "should subscribe to connection lifecycle events", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should subscribe to connection lifecycle events", + ); + + let subscriber: CliRunner | null = null; + + try { + subscriber = await startSubscribeCommand( + ["logs", "connection-lifecycle", "subscribe", "--duration", "30"], + /Listening for connection lifecycle/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(subscriber.isRunning()).toBe(true); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + + it("should retrieve connection lifecycle history", async () => { + setupTestFailureHandler("should retrieve connection lifecycle history"); + + const result = await runCommand( + ["logs", "connection-lifecycle", "history", "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(result.exitCode).toBe(0); + }); + }); + + describe("logs push", () => { + it("should retrieve push log history", async () => { + setupTestFailureHandler("should retrieve push log history"); + + const result = await runCommand(["logs", "push", "history", "--json"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }); + + // Should succeed even if empty + expect(result.exitCode).toBe(0); + }); + + it("should subscribe to push logs", { timeout: 60000 }, async () => { + setupTestFailureHandler("should subscribe to push logs"); + + let subscriber: CliRunner | null = null; + + try { + subscriber = await startSubscribeCommand( + ["logs", "push", "subscribe", "--duration", "30"], + /Listening for push logs/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(subscriber.isRunning()).toBe(true); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }); + }); +}); diff --git a/test/e2e/push/push-config-e2e.test.ts b/test/e2e/push/push-config-e2e.test.ts index dc565988..525a812f 100644 --- a/test/e2e/push/push-config-e2e.test.ts +++ b/test/e2e/push/push-config-e2e.test.ts @@ -11,8 +11,6 @@ import { ControlApi } from "../../../src/services/control-api.js"; import { forceExit, cleanupTrackedResources, - testOutputFiles, - testCommands, setupTestFailureHandler, resetTestTracking, } from "../../helpers/e2e-test-helper.js"; @@ -73,8 +71,6 @@ describe("Push Config E2E Tests", () => { beforeEach(() => { resetTestTracking(); - testOutputFiles.clear(); - testCommands.length = 0; }); afterEach(async () => { diff --git a/test/e2e/rooms/rooms-list-e2e.test.ts b/test/e2e/rooms/rooms-list-e2e.test.ts new file mode 100644 index 00000000..480cadbc --- /dev/null +++ b/test/e2e/rooms/rooms-list-e2e.test.ts @@ -0,0 +1,64 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms List E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms list", () => { + it("should list rooms", async () => { + setupTestFailureHandler("should list rooms"); + + const result = await runCommand(["rooms", "list", "--json"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }); + + // Should succeed even if the list is empty + expect(result.exitCode).toBe(0); + }); + + it("should list rooms with limit", async () => { + setupTestFailureHandler("should list rooms with limit"); + + const result = await runCommand( + ["rooms", "list", "--limit", "5", "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(result.exitCode).toBe(0); + }); + }); +}); diff --git a/test/e2e/rooms/rooms-messages-e2e.test.ts b/test/e2e/rooms/rooms-messages-e2e.test.ts new file mode 100644 index 00000000..eae55d6f --- /dev/null +++ b/test/e2e/rooms/rooms-messages-e2e.test.ts @@ -0,0 +1,226 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Messages E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let testRoom: string; + let clientId: string; + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-msg"); + clientId = getUniqueClientId("msg-client"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms messages send and history", () => { + it("should send a message to a room", async () => { + setupTestFailureHandler("should send a message to a room"); + + const result = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "hello-e2e-test", + "--client-id", + clientId, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + }); + + it( + "should retrieve message history for a room", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should retrieve message history for a room"); + + // First send a message + await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "history-test-msg", + "--client-id", + clientId, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait a moment for the message to be available in history + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get history + const historyResult = await runCommand( + ["rooms", "messages", "history", testRoom, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(historyResult.exitCode).toBe(0); + const jsonLines = parseNdjsonLines(historyResult.stdout); + expect(jsonLines.length).toBeGreaterThan(0); + }, + ); + }); + + describe("rooms messages update and delete", () => { + it("should update a room message", { timeout: 60000 }, async () => { + setupTestFailureHandler("should update a room message"); + + // Send a message and get its serial from the JSON response + const sendResult = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "original-msg", + "--client-id", + clientId, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + // Parse the serial from the send result + const sendJsonLines = parseNdjsonLines(sendResult.stdout); + const resultLine = sendJsonLines.find((l) => l.type === "result"); + expect(resultLine).toBeDefined(); + + // Extract serial - it could be nested under a domain key + const message = (resultLine?.message ?? resultLine) as Record< + string, + unknown + >; + const serial = message.serial as string | undefined; + expect(serial).toBeDefined(); + + // Update the message + const updateResult = await runCommand( + [ + "rooms", + "messages", + "update", + testRoom, + serial!, + "updated-msg", + "--client-id", + clientId, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(updateResult.exitCode).toBe(0); + }); + + it("should delete a room message", { timeout: 60000 }, async () => { + setupTestFailureHandler("should delete a room message"); + + // Send a message + const sendResult = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "to-delete-msg", + "--client-id", + clientId, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + const sendJsonLines = parseNdjsonLines(sendResult.stdout); + const resultLine = sendJsonLines.find((l) => l.type === "result"); + expect(resultLine).toBeDefined(); + + const message = (resultLine?.message ?? resultLine) as Record< + string, + unknown + >; + const serial = message.serial as string | undefined; + expect(serial).toBeDefined(); + + // Delete the message + const deleteResult = await runCommand( + [ + "rooms", + "messages", + "delete", + testRoom, + serial!, + "--client-id", + clientId, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(deleteResult.exitCode).toBe(0); + }); + }); +}); diff --git a/test/e2e/rooms/rooms-messages-reactions-e2e.test.ts b/test/e2e/rooms/rooms-messages-reactions-e2e.test.ts new file mode 100644 index 00000000..a31b6ad9 --- /dev/null +++ b/test/e2e/rooms/rooms-messages-reactions-e2e.test.ts @@ -0,0 +1,280 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Message Reactions E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let testRoom: string; + let client1Id: string; + let client2Id: string; + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-msgreact"); + client1Id = getUniqueClientId("msgreact-sub"); + client2Id = getUniqueClientId("msgreact-send"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms messages reactions", () => { + it("should send a message reaction", { timeout: 60000 }, async () => { + setupTestFailureHandler("should send a message reaction"); + + // First send a message to get a serial + const sendResult = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "reaction-target", + "--client-id", + client1Id, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + const sendJsonLines = parseNdjsonLines(sendResult.stdout); + const resultLine = sendJsonLines.find((l) => l.type === "result"); + expect(resultLine).toBeDefined(); + + const message = (resultLine?.message ?? resultLine) as Record< + string, + unknown + >; + const serial = message.serial as string | undefined; + expect(serial).toBeDefined(); + + // Send a reaction to the message + const reactionResult = await runCommand( + [ + "rooms", + "messages", + "reactions", + "send", + testRoom, + serial!, + "like", + "--client-id", + client2Id, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(reactionResult.exitCode).toBe(0); + }); + + it( + "should subscribe to message reactions and receive events", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should subscribe to message reactions and receive events", + ); + + let subscriber: CliRunner | null = null; + + try { + // Start subscribing to message reactions + subscriber = await startSubscribeCommand( + [ + "rooms", + "messages", + "reactions", + "subscribe", + testRoom, + "--client-id", + client1Id, + "--duration", + "30", + ], + /Listening for message reactions/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Send a message first + const sendResult = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "react-subscribe-target", + "--client-id", + client1Id, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + const sendJsonLines = parseNdjsonLines(sendResult.stdout); + const resultLine = sendJsonLines.find((l) => l.type === "result"); + const message = (resultLine?.message ?? resultLine) as Record< + string, + unknown + >; + const serial = message.serial as string | undefined; + + if (serial) { + // Send a reaction + await runCommand( + [ + "rooms", + "messages", + "reactions", + "send", + testRoom, + serial, + "heart", + "--client-id", + client2Id, + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + // Wait for the reaction event in subscriber output + await waitForOutput(subscriber, "heart", 15000); + } + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + + it("should remove a message reaction", { timeout: 60000 }, async () => { + setupTestFailureHandler("should remove a message reaction"); + + // Send a message + const sendResult = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "remove-react-target", + "--client-id", + client1Id, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + const sendJsonLines = parseNdjsonLines(sendResult.stdout); + const resultLine = sendJsonLines.find((l) => l.type === "result"); + expect(resultLine).toBeDefined(); + + const message = (resultLine?.message ?? resultLine) as Record< + string, + unknown + >; + const serial = message.serial as string | undefined; + expect(serial).toBeDefined(); + + // Send a reaction first + const addResult = await runCommand( + [ + "rooms", + "messages", + "reactions", + "send", + testRoom, + serial!, + "thumbsup", + "--client-id", + client2Id, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(addResult.exitCode).toBe(0); + + // Remove the reaction + const removeResult = await runCommand( + [ + "rooms", + "messages", + "reactions", + "remove", + testRoom, + serial!, + "thumbsup", + "--client-id", + client2Id, + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(removeResult.exitCode).toBe(0); + }); + }); +}); diff --git a/test/e2e/rooms/rooms-messages-subscribe-e2e.test.ts b/test/e2e/rooms/rooms-messages-subscribe-e2e.test.ts new file mode 100644 index 00000000..4e7fb12f --- /dev/null +++ b/test/e2e/rooms/rooms-messages-subscribe-e2e.test.ts @@ -0,0 +1,112 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Messages Subscribe E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let testRoom: string; + let subscriberId: string; + let senderId: string; + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-sub"); + subscriberId = getUniqueClientId("subscriber"); + senderId = getUniqueClientId("sender"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms messages subscribe", () => { + it( + "should subscribe to room messages and receive a sent message", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should subscribe to room messages and receive a sent message", + ); + + let subscriber: CliRunner | null = null; + + try { + // Start subscribing to room messages + subscriber = await startSubscribeCommand( + [ + "rooms", + "messages", + "subscribe", + testRoom, + "--client-id", + subscriberId, + "--duration", + "30", + ], + /Listening for messages/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Send a message to the room + const sendResult = await runCommand( + [ + "rooms", + "messages", + "send", + testRoom, + "subscribe-test-msg", + "--client-id", + senderId, + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + // Wait for the message to appear in subscriber output + await waitForOutput(subscriber, "subscribe-test-msg", 15000); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + }); +}); diff --git a/test/e2e/rooms/rooms-occupancy-e2e.test.ts b/test/e2e/rooms/rooms-occupancy-e2e.test.ts new file mode 100644 index 00000000..02724ac0 --- /dev/null +++ b/test/e2e/rooms/rooms-occupancy-e2e.test.ts @@ -0,0 +1,149 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + startPresenceCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Occupancy E2E Tests", () => { + let testRoom: string; + let clientId: string; + + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-occ"); + clientId = getUniqueClientId("occ-client"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms occupancy get", () => { + it("should get occupancy metrics for a room", async () => { + setupTestFailureHandler("should get occupancy metrics for a room"); + + const result = await runCommand( + ["rooms", "occupancy", "get", testRoom, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.occupancy).toBeDefined(); + + const occupancy = resultRecord!.occupancy as { + room: string; + metrics: { connections?: number; presenceMembers?: number }; + }; + expect(occupancy.room).toBe(testRoom); + expect(occupancy.metrics).toBeDefined(); + }, 60000); + }); + + describe("rooms occupancy subscribe", () => { + it("should receive occupancy updates when members join a room", async () => { + setupTestFailureHandler( + "should receive occupancy updates when members join a room", + ); + + let subscriber: CliRunner | null = null; + let enterer: CliRunner | null = null; + try { + // Start occupancy subscriber + subscriber = await startSubscribeCommand( + [ + "rooms", + "occupancy", + "subscribe", + testRoom, + "--client-id", + clientId, + "--duration", + "30", + ], + /Listening for occupancy|Subscribed to occupancy/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for subscription to be established + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Enter presence to trigger an occupancy change + const enterClientId = getUniqueClientId("occ-enter"); + enterer = await startPresenceCommand( + [ + "rooms", + "presence", + "enter", + testRoom, + "--client-id", + enterClientId, + "--duration", + "15", + ], + /Entered presence|Holding|entering/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for the occupancy subscriber to receive an update + await waitForOutput( + subscriber, + /connections|presenceMembers|Connections|Presence/i, + 15000, + ); + + // Verify subscriber is still running and received output + expect(subscriber.combined()).toMatch( + /connections|presenceMembers|Connections|Presence/i, + ); + } finally { + const runnersToCleanup = [subscriber, enterer].filter( + Boolean, + ) as CliRunner[]; + await cleanupRunners(runnersToCleanup); + } + }, 60000); + }); +}); diff --git a/test/e2e/rooms/rooms-presence-e2e.test.ts b/test/e2e/rooms/rooms-presence-e2e.test.ts new file mode 100644 index 00000000..b72323ab --- /dev/null +++ b/test/e2e/rooms/rooms-presence-e2e.test.ts @@ -0,0 +1,170 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startPresenceCommand, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Presence E2E Tests", () => { + let testRoom: string; + let clientId: string; + + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-pres"); + clientId = getUniqueClientId("pres-client"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms presence enter", () => { + it("should enter presence in a room and hold", async () => { + setupTestFailureHandler("should enter presence in a room and hold"); + + let runner: CliRunner | null = null; + try { + runner = await startPresenceCommand( + [ + "rooms", + "presence", + "enter", + testRoom, + "--client-id", + clientId, + "--duration", + "10", + ], + /Entered presence|Holding|entering/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Verify the command started and entered presence + const output = runner.combined(); + expect(output).toMatch(/presence|enter|hold/i); + } finally { + if (runner) { + await cleanupRunners([runner]); + } + } + }, 60000); + + it("should enter presence with JSON output", async () => { + setupTestFailureHandler("should enter presence with JSON output"); + + let runner: CliRunner | null = null; + try { + runner = await startPresenceCommand( + [ + "rooms", + "presence", + "enter", + testRoom, + "--client-id", + clientId, + "--duration", + "10", + "--json", + ], + /presenceMessage|action.*enter|holding/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait a moment for JSON output to settle + await new Promise((resolve) => setTimeout(resolve, 2000)); + const records = parseNdjsonLines(runner.stdout()); + // Verify we got some JSON output from the command + expect(records.length).toBeGreaterThan(0); + } finally { + if (runner) { + await cleanupRunners([runner]); + } + } + }, 60000); + }); + + describe("rooms presence get", () => { + it("should get presence members for a room", async () => { + setupTestFailureHandler("should get presence members for a room"); + + let enterRunner: CliRunner | null = null; + try { + // First enter presence so there's a member to find + enterRunner = await startPresenceCommand( + [ + "rooms", + "presence", + "enter", + testRoom, + "--client-id", + clientId, + "--duration", + "15", + ], + /Entered presence|Holding|entering/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for presence to propagate + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Now query presence members + const result = await runCommand( + ["rooms", "presence", "get", testRoom, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.members).toBeDefined(); + } finally { + if (enterRunner) { + await cleanupRunners([enterRunner]); + } + } + }, 60000); + }); +}); diff --git a/test/e2e/rooms/rooms-presence-subscribe-e2e.test.ts b/test/e2e/rooms/rooms-presence-subscribe-e2e.test.ts new file mode 100644 index 00000000..e80684be --- /dev/null +++ b/test/e2e/rooms/rooms-presence-subscribe-e2e.test.ts @@ -0,0 +1,107 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + startSubscribeCommand, + startPresenceCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Presence Subscribe E2E Tests", () => { + const runners: CliRunner[] = []; + + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupRunners(runners); + runners.length = 0; + await cleanupTrackedResources(); + }); + + it("should receive presence enter events when a member enters a room", async () => { + setupTestFailureHandler( + "should receive presence enter events when a member enters a room", + ); + + const testRoom = getUniqueChannelName("room-pres-sub"); + const subClientId = getUniqueClientId("pres-sub"); + const enterClientId = getUniqueClientId("pres-enter"); + + // Start presence subscriber + const subscriber = await startSubscribeCommand( + [ + "rooms", + "presence", + "subscribe", + testRoom, + "--client-id", + subClientId, + "--duration", + "30", + ], + /Listening for presence|Subscribed to presence/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + runners.push(subscriber); + + // Wait a moment for subscription to be ready + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Enter presence with a different client + const enterer = await startPresenceCommand( + [ + "rooms", + "presence", + "enter", + testRoom, + "--client-id", + enterClientId, + "--duration", + "15", + ], + /Entered presence|Holding|entering/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + runners.push(enterer); + + // Wait for the subscriber to see the enter event + await waitForOutput(subscriber, enterClientId, 15000); + + // Verify the enter event appeared in subscriber output + expect(subscriber.combined()).toContain(enterClientId); + }, 60000); +}); diff --git a/test/e2e/rooms/rooms-reactions-e2e.test.ts b/test/e2e/rooms/rooms-reactions-e2e.test.ts new file mode 100644 index 00000000..342111e6 --- /dev/null +++ b/test/e2e/rooms/rooms-reactions-e2e.test.ts @@ -0,0 +1,112 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Reactions E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let testRoom: string; + let client1Id: string; + let client2Id: string; + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-react"); + client1Id = getUniqueClientId("react-sub"); + client2Id = getUniqueClientId("react-send"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms reactions send and subscribe", () => { + it( + "should send a room reaction and receive it via subscribe", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should send a room reaction and receive it via subscribe", + ); + + let subscriber: CliRunner | null = null; + + try { + // Start subscribing to room reactions + subscriber = await startSubscribeCommand( + [ + "rooms", + "reactions", + "subscribe", + testRoom, + "--client-id", + client1Id, + "--duration", + "30", + ], + /Listening for reactions/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Send a reaction + const sendResult = await runCommand( + [ + "rooms", + "reactions", + "send", + testRoom, + "thumbsup", + "--client-id", + client2Id, + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(sendResult.exitCode).toBe(0); + + // Wait for the reaction to appear in subscriber output + await waitForOutput(subscriber, "thumbsup", 15000); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + }); +}); diff --git a/test/e2e/rooms/rooms-typing-e2e.test.ts b/test/e2e/rooms/rooms-typing-e2e.test.ts new file mode 100644 index 00000000..6c130df5 --- /dev/null +++ b/test/e2e/rooms/rooms-typing-e2e.test.ts @@ -0,0 +1,104 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Rooms Typing E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let testRoom: string; + let subscriberId: string; + let typerId: string; + + beforeEach(() => { + resetTestTracking(); + testRoom = getUniqueChannelName("room-typing"); + subscriberId = getUniqueClientId("type-sub"); + typerId = getUniqueClientId("typer"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("rooms typing keystroke and subscribe", () => { + it( + "should send a keystroke and receive it via subscribe", + { timeout: 60000 }, + async () => { + setupTestFailureHandler( + "should send a keystroke and receive it via subscribe", + ); + + let subscriber: CliRunner | null = null; + + try { + // Start subscribing to typing events + subscriber = await startSubscribeCommand( + [ + "rooms", + "typing", + "subscribe", + testRoom, + "--client-id", + subscriberId, + "--duration", + "30", + ], + /Listening for typing/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Send a keystroke + const keystrokeResult = await runCommand( + ["rooms", "typing", "keystroke", testRoom, "--client-id", typerId], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(keystrokeResult.exitCode).toBe(0); + + // Wait for the typing event to appear in subscriber output + await waitForOutput(subscriber, typerId, 15000); + } finally { + if (subscriber) { + await cleanupRunners([subscriber]); + } + } + }, + ); + }); +}); diff --git a/test/e2e/spaces/spaces-crud-e2e.test.ts b/test/e2e/spaces/spaces-crud-e2e.test.ts new file mode 100644 index 00000000..babb9422 --- /dev/null +++ b/test/e2e/spaces/spaces-crud-e2e.test.ts @@ -0,0 +1,122 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startSubscribeCommand, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Spaces CRUD E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let spaceName: string; + let clientId: string; + + beforeEach(() => { + resetTestTracking(); + spaceName = getUniqueChannelName("space"); + clientId = getUniqueClientId("space-client"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("spaces create", () => { + it("should create a space", async () => { + setupTestFailureHandler("should create a space"); + + const result = await runCommand( + ["spaces", "create", spaceName, "--client-id", clientId, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + expect(result.exitCode).toBe(0); + }); + }); + + describe("spaces list", () => { + it("should list spaces", async () => { + setupTestFailureHandler("should list spaces"); + + const result = await runCommand(["spaces", "list", "--json"], { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }); + + // Should succeed even if the list is empty + expect(result.exitCode).toBe(0); + }); + }); + + describe("spaces get", () => { + it("should get space details", { timeout: 60000 }, async () => { + setupTestFailureHandler("should get space details"); + + let member: CliRunner | null = null; + + try { + // Enter a member into the space and keep it present (spaces only exist while members are present) + member = await startSubscribeCommand( + [ + "spaces", + "members", + "enter", + spaceName, + "--client-id", + clientId, + "--duration", + "30", + ], + /Holding presence/, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Get space details while the member is still present + const result = await runCommand( + ["spaces", "get", spaceName, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(result.exitCode).toBe(0); + } finally { + if (member) { + await cleanupRunners([member]); + } + } + }); + }); +}); diff --git a/test/e2e/spaces/spaces-locations-e2e.test.ts b/test/e2e/spaces/spaces-locations-e2e.test.ts new file mode 100644 index 00000000..85e31934 --- /dev/null +++ b/test/e2e/spaces/spaces-locations-e2e.test.ts @@ -0,0 +1,236 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startPresenceCommand, + startSubscribeCommand, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)( + "Spaces Locations, Cursors, and Locks E2E Tests", + () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let spaceName: string; + let clientId: string; + + beforeEach(() => { + resetTestTracking(); + spaceName = getUniqueChannelName("space-loc"); + clientId = getUniqueClientId("loc-client"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("spaces locations", () => { + it( + "should set location and get locations", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should set location and get locations"); + + let locationRunner: CliRunner | null = null; + + try { + // Set location (long-running hold command) + locationRunner = await startPresenceCommand( + [ + "spaces", + "locations", + "set", + spaceName, + "--location", + '{"slide":1}', + "--client-id", + clientId, + "--duration", + "15", + ], + /Holding|Set location|location/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait a moment for the location to propagate + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get locations + const getResult = await runCommand( + ["spaces", "locations", "get", spaceName, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(getResult.exitCode).toBe(0); + } finally { + if (locationRunner) { + await cleanupRunners([locationRunner]); + } + } + }, + ); + }); + + describe("spaces cursors", () => { + it("should set cursor and get cursors", { timeout: 60000 }, async () => { + setupTestFailureHandler("should set cursor and get cursors"); + + let subscriberRunner: CliRunner | null = null; + let cursorRunner: CliRunner | null = null; + + try { + // The Spaces SDK only publishes cursor data when 2+ members are on the + // ::$cursors channel (CursorBatching.shouldSend optimization). Start a + // subscriber first so cursor set actually publishes to channel history. + subscriberRunner = await startSubscribeCommand( + [ + "spaces", + "cursors", + "subscribe", + spaceName, + "--client-id", + `${clientId}-subscriber`, + "--duration", + "30", + ], + /Listening|listening/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for subscriber's presence to propagate on the cursors channel + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Set cursor with --simulate for continuous publishes (resilient against + // brief race between presence callback and first cursor.set call) + cursorRunner = await startPresenceCommand( + [ + "spaces", + "cursors", + "set", + spaceName, + "--simulate", + "--x", + "10", + "--y", + "20", + "--client-id", + clientId, + "--duration", + "15", + ], + /Holding|Simulating|cursor/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for simulated cursor data to be published to channel history + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Get cursors + const getResult = await runCommand( + ["spaces", "cursors", "get", spaceName, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(getResult.exitCode).toBe(0); + } finally { + const runners: CliRunner[] = []; + if (subscriberRunner) runners.push(subscriberRunner); + if (cursorRunner) runners.push(cursorRunner); + if (runners.length > 0) await cleanupRunners(runners); + } + }); + }); + + describe("spaces locks", () => { + it( + "should acquire a lock and get locks", + { timeout: 60000 }, + async () => { + setupTestFailureHandler("should acquire a lock and get locks"); + + let lockRunner: CliRunner | null = null; + + try { + // Acquire a lock (long-running hold command) + lockRunner = await startPresenceCommand( + [ + "spaces", + "locks", + "acquire", + spaceName, + "test-lock-1", + "--client-id", + clientId, + "--duration", + "15", + ], + /Holding|Acquired|lock/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for lock to propagate + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get locks + const getResult = await runCommand( + ["spaces", "locks", "get", spaceName, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(getResult.exitCode).toBe(0); + } finally { + if (lockRunner) { + await cleanupRunners([lockRunner]); + } + } + }, + ); + }); + }, +); diff --git a/test/e2e/spaces/spaces-occupancy-e2e.test.ts b/test/e2e/spaces/spaces-occupancy-e2e.test.ts new file mode 100644 index 00000000..b6c70d86 --- /dev/null +++ b/test/e2e/spaces/spaces-occupancy-e2e.test.ts @@ -0,0 +1,95 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + runCommand, + startPresenceCommand, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Spaces Occupancy E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + let spaceName: string; + let clientId: string; + + beforeEach(() => { + resetTestTracking(); + spaceName = getUniqueChannelName("space-occ"); + clientId = getUniqueClientId("occ-client"); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("spaces occupancy get", () => { + it("should get space occupancy", { timeout: 60000 }, async () => { + setupTestFailureHandler("should get space occupancy"); + + let memberRunner: CliRunner | null = null; + + try { + // Enter a member into the space to ensure non-zero occupancy + memberRunner = await startPresenceCommand( + [ + "spaces", + "members", + "enter", + spaceName, + "--client-id", + clientId, + "--duration", + "15", + ], + /Entered|Holding|member/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for member to propagate + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get occupancy + const result = await runCommand( + ["spaces", "occupancy", "get", spaceName, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + expect(result.exitCode).toBe(0); + } finally { + if (memberRunner) { + await cleanupRunners([memberRunner]); + } + } + }); + }); +}); diff --git a/test/e2e/spaces/spaces-subscribe-e2e.test.ts b/test/e2e/spaces/spaces-subscribe-e2e.test.ts new file mode 100644 index 00000000..51949372 --- /dev/null +++ b/test/e2e/spaces/spaces-subscribe-e2e.test.ts @@ -0,0 +1,106 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + E2E_API_KEY, + SHOULD_SKIP_E2E, + getUniqueChannelName, + getUniqueClientId, + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { + startSubscribeCommand, + startPresenceCommand, + waitForOutput, + cleanupRunners, +} from "../../helpers/command-helpers.js"; +import type { CliRunner } from "../../helpers/cli-runner.js"; + +describe.skipIf(SHOULD_SKIP_E2E)("Spaces Subscribe E2E Tests", () => { + const runners: CliRunner[] = []; + + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupRunners(runners); + runners.length = 0; + await cleanupTrackedResources(); + }); + + it("should receive member events when a member enters a space", async () => { + setupTestFailureHandler( + "should receive member events when a member enters a space", + ); + + const spaceName = getUniqueChannelName("space-sub"); + const subClientId = getUniqueClientId("space-sub-client"); + const enterClientId = getUniqueClientId("space-enter-client"); + + // Start the space subscriber + const subscriber = await startSubscribeCommand( + [ + "spaces", + "subscribe", + spaceName, + "--client-id", + subClientId, + "--duration", + "30", + ], + /Listening for space|Subscribed to space/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + runners.push(subscriber); + + // Wait for subscription to be established + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Enter the space with a different client to trigger a member event + const enterer = await startPresenceCommand( + [ + "spaces", + "members", + "enter", + spaceName, + "--client-id", + enterClientId, + "--duration", + "15", + ], + /Entered space|Holding|entering/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + runners.push(enterer); + + // Wait for the subscriber to receive the member event + await waitForOutput(subscriber, enterClientId, 15000); + + // Verify the member event appeared in subscriber output + expect(subscriber.combined()).toContain(enterClientId); + }, 60000); +}); diff --git a/test/e2e/stats/stats.test.ts b/test/e2e/stats/stats.test.ts index f270783f..88fb9d70 100644 --- a/test/e2e/stats/stats.test.ts +++ b/test/e2e/stats/stats.test.ts @@ -12,8 +12,6 @@ import { SHOULD_SKIP_E2E, forceExit, cleanupTrackedResources, - testOutputFiles, - testCommands, setupTestFailureHandler, resetTestTracking, } from "../../helpers/e2e-test-helper.js"; @@ -48,8 +46,6 @@ describe.skipIf(SHOULD_SKIP_E2E || SKIP_ACCOUNT_STATS)( beforeEach(() => { resetTestTracking(); - testOutputFiles.clear(); - testCommands.length = 0; }); afterEach(async () => { diff --git a/test/e2e/status/status-e2e.test.ts b/test/e2e/status/status-e2e.test.ts new file mode 100644 index 00000000..5f8f9f56 --- /dev/null +++ b/test/e2e/status/status-e2e.test.ts @@ -0,0 +1,68 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe("Status E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("status", () => { + it("should check Ably service status", async () => { + setupTestFailureHandler("should check Ably service status"); + + const result = await runCommand(["status"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 15000, + }); + + expect(result.exitCode).toBe(0); + // Status output should contain some indication of service health + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/status|operational|ok|healthy|incident/i); + }); + + it("should output status as JSON", async () => { + setupTestFailureHandler("should output status as JSON"); + + const result = await runCommand(["status", "--json"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 15000, + }); + + expect(result.exitCode).toBe(0); + const lines = result.stdout + .trim() + .split("\n") + .filter((l) => l.trim().startsWith("{")); + expect(lines.length).toBeGreaterThan(0); + const json = JSON.parse(lines[0]); + expect(json).toHaveProperty("type"); + }); + }); +}); diff --git a/test/e2e/support/support-e2e.test.ts b/test/e2e/support/support-e2e.test.ts new file mode 100644 index 00000000..6e652340 --- /dev/null +++ b/test/e2e/support/support-e2e.test.ts @@ -0,0 +1,48 @@ +import { + describe, + it, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, +} from "vitest"; +import { + forceExit, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; +import { runCommand } from "../../helpers/command-helpers.js"; + +describe("Support E2E Tests", () => { + beforeAll(() => { + process.on("SIGINT", forceExit); + }); + + afterAll(() => { + process.removeListener("SIGINT", forceExit); + }); + + beforeEach(() => { + resetTestTracking(); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + }); + + describe("support contact", () => { + it("should show support contact help", async () => { + setupTestFailureHandler("should show support contact help"); + + const result = await runCommand(["support", "contact", "--help"], { + env: { NODE_OPTIONS: "", ABLY_CLI_NON_INTERACTIVE: "true" }, + timeoutMs: 10000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/contact|support|ably/i); + }); + }); +}); diff --git a/test/e2e/web-cli/terminal-ui.test.ts b/test/e2e/web-cli/terminal-ui.test.ts index 5d1a2ee4..8f38c97b 100644 --- a/test/e2e/web-cli/terminal-ui.test.ts +++ b/test/e2e/web-cli/terminal-ui.test.ts @@ -215,8 +215,8 @@ test.describe("Web CLI Terminal UI Tests", () => { page.locator('[data-testid="terminal-container-secondary"]'), ).toBeVisible(); - // Wait a bit for both terminals to initialize - await page.waitForTimeout(2000); + // Wait for both terminals to initialize (needs extra time to avoid server-side rate limiting) + await page.waitForTimeout(5000); // Type in the first terminal const primaryTerminal = page.locator( diff --git a/test/helpers/e2e-mutable-messages.ts b/test/helpers/e2e-mutable-messages.ts new file mode 100644 index 00000000..dfe60224 --- /dev/null +++ b/test/helpers/e2e-mutable-messages.ts @@ -0,0 +1,125 @@ +import { runCommand } from "./command-helpers.js"; +import { parseNdjsonLines } from "./ndjson.js"; +import { E2E_API_KEY, getUniqueChannelName } from "./e2e-test-helper.js"; + +/** + * Probe whether the E2E test app supports mutable messages by attempting + * a message update on a temporary channel. Returns false if error 93002 + * (mutableMessages not enabled) is returned. + */ +export async function checkMutableMessagesSupport(): Promise { + const probeChannel = getUniqueChannelName("mutable-probe"); + + // Publish a message first + const pubResult = await runCommand( + ["channels", "publish", probeChannel, "probe", "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + if (pubResult.exitCode !== 0) return false; + + // Get the serial from history + for (let attempt = 0; attempt < 5; attempt++) { + const histResult = await runCommand( + ["channels", "history", probeChannel, "--json", "--limit", "1"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + if (histResult.exitCode !== 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + + const records = parseNdjsonLines(histResult.stdout); + const result = records.find((r) => r.type === "result"); + const messages = result?.messages as Array<{ serial?: string }> | undefined; + if (!messages?.[0]?.serial) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + + // Try to update the message — if it fails with 93002, mutable messages isn't enabled + const updateResult = await runCommand( + [ + "channels", + "update", + probeChannel, + messages[0].serial, + "updated-probe", + "--json", + ], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 15000, + }, + ); + + if (updateResult.exitCode === 0) return true; + + // Check for error code 93002 + const errorRecords = parseNdjsonLines(updateResult.stdout); + const errorRecord = errorRecords.find((r) => r.type === "error"); + const error = errorRecord?.error as { code?: number } | undefined; + if (error?.code === 93002) { + return false; + } + + // Some other error — assume not supported + return false; + } + + return false; +} + +/** + * Publish a message via CLI with --json, then get the serial from history --json. + * Uses retry logic to handle eventual consistency. + */ +export async function publishAndGetSerial( + channelName: string, + messageText: string, +): Promise { + const publishResult = await runCommand( + ["channels", "publish", channelName, messageText, "--json"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + if (publishResult.exitCode !== 0) { + throw new Error( + `Publish failed: exitCode=${publishResult.exitCode}, stderr=${publishResult.stderr}`, + ); + } + + // Retry history until the message is available (eventually consistent) + for (let attempt = 0; attempt < 10; attempt++) { + const historyResult = await runCommand( + ["channels", "history", channelName, "--json", "--limit", "1"], + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + if (historyResult.exitCode !== 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + + const records = parseNdjsonLines(historyResult.stdout); + const result = records.find((r) => r.type === "result"); + const messages = result?.messages as Array<{ serial?: string }> | undefined; + if (messages && messages.length > 0 && messages[0].serial) { + return messages[0].serial; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error( + `No message serial found in history after retries for channel: ${channelName}`, + ); +} diff --git a/test/helpers/e2e-test-helper.ts b/test/helpers/e2e-test-helper.ts index 6ca3e3f4..c7ebbdbb 100644 --- a/test/helpers/e2e-test-helper.ts +++ b/test/helpers/e2e-test-helper.ts @@ -17,6 +17,8 @@ import { onTestFailed } from "vitest"; export const E2E_API_KEY = process.env.E2E_ABLY_API_KEY; export const SHOULD_SKIP_E2E = !E2E_API_KEY || process.env.SKIP_E2E_TESTS === "true"; +export const E2E_ACCESS_TOKEN = process.env.E2E_ABLY_ACCESS_TOKEN; +export const SHOULD_SKIP_CONTROL_E2E = SHOULD_SKIP_E2E || !E2E_ACCESS_TOKEN; // Store active background processes and temp files for cleanup const activeProcesses: Map = new Map(); diff --git a/test/unit/base/base-command.test.ts b/test/unit/base/base-command.test.ts index 3b9d1494..d72226f3 100644 --- a/test/unit/base/base-command.test.ts +++ b/test/unit/base/base-command.test.ts @@ -590,35 +590,19 @@ describe("AblyBaseCommand", function () { it("should use ABLY_API_KEY environment variable if available", async function () { const flags: BaseFlags = {}; - // Reset relevant stubs + // Reset relevant stubs — no config available configManagerStub.getCurrentAppId.mockReturnValue(); configManagerStub.getApiKey.mockReturnValue(); - // Set access token to ensure the control API path is followed - configManagerStub.getAccessToken.mockReturnValue("test-token"); - - // Set up interactive helper to simulate user selecting an app and key - const mockApp = { id: "envApp", name: "Test App" }; - const mockKey = { - id: "keyId", - name: "Test Key", - key: "envApp.keyId:keySecret", - }; - - interactiveHelperStub.selectApp.mockResolvedValue(mockApp); - interactiveHelperStub.selectKey.mockResolvedValue(mockKey); - // Set environment variable but it will be used in getClientOptions, not directly in this test path + // Set environment variable process.env.ABLY_API_KEY = "envApp.keyId:keySecret"; const result = await command.testEnsureAppAndKey(flags); + // Should return the env var directly without interactive selection expect(result).not.toBeNull(); expect(result?.appId).toBe("envApp"); expect(result?.apiKey).toBe("envApp.keyId:keySecret"); - expect(interactiveHelperStub.selectKey).toHaveBeenCalledWith( - expect.anything(), - "envApp", - ); }); it("should handle web CLI mode appropriately", async function () { From 6a434332d0cd9cb410878f16667891d33096b50f Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 9 Apr 2026 14:37:39 +0100 Subject: [PATCH 2/5] Merge duplicate if (flags["auto-type"]) blocks in keystroke command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keystroke command had two consecutive if blocks checking the same flag — one for the interval setup and one for the hold loop. Merged them into a single if/else as requested in PR review. --- src/commands/rooms/typing/keystroke.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index 9de82318..23aff3c7 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -158,10 +158,7 @@ export default class TypingKeystroke extends ChatBaseCommand { ); }); }, KEYSTROKE_INTERVAL); - } - // If auto-type is enabled, keep the command running to maintain typing state - if (flags["auto-type"]) { this.logCliEvent( flags, "typing", @@ -175,7 +172,7 @@ export default class TypingKeystroke extends ChatBaseCommand { failurePromise, ]); } else { - // Suppress unhandled rejection if room fails during cleanup + // Suppress unhandled rejection — failurePromise exists from setupRoomStatusHandler failurePromise.catch(() => {}); } } catch (error) { From 681f04cdbd2f2df499e2bb8f3709fbb9aadb318f Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 9 Apr 2026 14:37:45 +0100 Subject: [PATCH 3/5] Remove redundant ABLY_API_KEY env var fallback in ensureAppAndKey configManager.getApiKey() already falls back to process.env.ABLY_API_KEY internally, making the explicit env var block dead code. Replaced with appId extraction from the key string when appId is missing but apiKey is present. Updated unit test mock to match real getApiKey behavior. --- src/base-command.ts | 14 +++++--------- test/unit/base/base-command.test.ts | 8 ++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/base-command.ts b/src/base-command.ts index f112780f..9f35a27a 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -746,20 +746,16 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { let appId = flags.app || this.configManager.getCurrentAppId(); let apiKey = this.configManager.getApiKey(appId); + // When apiKey comes from ABLY_API_KEY env var but appId is missing, extract it from the key + if (apiKey && !appId) { + appId = apiKey.split(".")[0] || ""; + } + // If we have both, return them if (appId && apiKey) { return { apiKey, appId }; } - // Fall back to ABLY_API_KEY environment variable (for CI/scripting) - const envApiKey = process.env.ABLY_API_KEY; - if (envApiKey) { - const envAppId = envApiKey.split(".")[0] || ""; - if (envAppId) { - return { apiKey: envApiKey, appId: envAppId }; - } - } - // Get access token for control API const accessToken = process.env.ABLY_ACCESS_TOKEN || this.configManager.getAccessToken(); diff --git a/test/unit/base/base-command.test.ts b/test/unit/base/base-command.test.ts index d72226f3..b5ac99d5 100644 --- a/test/unit/base/base-command.test.ts +++ b/test/unit/base/base-command.test.ts @@ -590,16 +590,16 @@ describe("AblyBaseCommand", function () { it("should use ABLY_API_KEY environment variable if available", async function () { const flags: BaseFlags = {}; - // Reset relevant stubs — no config available + // Reset relevant stubs — no app in config, but getApiKey falls back to env var configManagerStub.getCurrentAppId.mockReturnValue(); - configManagerStub.getApiKey.mockReturnValue(); + configManagerStub.getApiKey.mockReturnValue("envApp.keyId:keySecret"); - // Set environment variable + // Set environment variable (getApiKey reads this internally) process.env.ABLY_API_KEY = "envApp.keyId:keySecret"; const result = await command.testEnsureAppAndKey(flags); - // Should return the env var directly without interactive selection + // Should extract appId from the key and return without interactive selection expect(result).not.toBeNull(); expect(result?.appId).toBe("envApp"); expect(result?.apiKey).toBe("envApp.keyId:keySecret"); From 5d39435f2a6fd1fb5d7a76964b66156d563b775b Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 9 Apr 2026 14:37:51 +0100 Subject: [PATCH 4/5] Fix mutable message E2E tests to create channel rules via Control API The previous probe-based approach always failed because no channel rule with mutableMessages enabled existed for the probe namespace. Replaced with proper setup/teardown that creates a namespace rule before tests and cleans it up after, using the Control API CLI commands. Tests now skip only when access token is unavailable, not due to a broken probe. --- .../channels/channel-annotations-e2e.test.ts | 21 +-- .../channels/channel-message-ops-e2e.test.ts | 23 +-- test/helpers/e2e-mutable-messages.ts | 147 +++++++++++------- 3 files changed, 115 insertions(+), 76 deletions(-) diff --git a/test/e2e/channels/channel-annotations-e2e.test.ts b/test/e2e/channels/channel-annotations-e2e.test.ts index a62346c6..a259497e 100644 --- a/test/e2e/channels/channel-annotations-e2e.test.ts +++ b/test/e2e/channels/channel-annotations-e2e.test.ts @@ -10,7 +10,6 @@ import { import { E2E_API_KEY, SHOULD_SKIP_E2E, - getUniqueChannelName, forceExit, cleanupTrackedResources, setupTestFailureHandler, @@ -25,7 +24,10 @@ import { import type { CliRunner } from "../../helpers/cli-runner.js"; import { parseNdjsonLines } from "../../helpers/ndjson.js"; import { - checkMutableMessagesSupport, + SHOULD_SKIP_MUTABLE_TESTS, + setupMutableMessagesRule, + teardownMutableMessagesRule, + getMutableChannelName, publishAndGetSerial, } from "../../helpers/e2e-mutable-messages.js"; @@ -34,12 +36,7 @@ function findResult(stdout: string): Record { return records.find((r) => r.type === "result") ?? records.at(-1) ?? {}; } -// Check if the E2E test app supports mutable messages (required for annotations) -const mutableMessagesSupported = SHOULD_SKIP_E2E - ? false - : await checkMutableMessagesSupport(); - -describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( +describe.skipIf(SHOULD_SKIP_E2E || SHOULD_SKIP_MUTABLE_TESTS)( "Channel Annotations E2E Tests", () => { let channelName: string; @@ -48,12 +45,16 @@ describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( beforeAll(async () => { process.on("SIGINT", forceExit); + // Create channel rule with mutableMessages enabled + await setupMutableMessagesRule(); + // Publish a test message and get its serial for use in all tests - channelName = getUniqueChannelName("annotations"); + channelName = getMutableChannelName("annotations"); messageSerial = await publishAndGetSerial(channelName, "annotate-me"); }); - afterAll(() => { + afterAll(async () => { + await teardownMutableMessagesRule(); process.removeListener("SIGINT", forceExit); }); diff --git a/test/e2e/channels/channel-message-ops-e2e.test.ts b/test/e2e/channels/channel-message-ops-e2e.test.ts index 6d7c9a60..094c6569 100644 --- a/test/e2e/channels/channel-message-ops-e2e.test.ts +++ b/test/e2e/channels/channel-message-ops-e2e.test.ts @@ -10,7 +10,6 @@ import { import { E2E_API_KEY, SHOULD_SKIP_E2E, - getUniqueChannelName, forceExit, cleanupTrackedResources, setupTestFailureHandler, @@ -19,16 +18,14 @@ import { import { runCommand } from "../../helpers/command-helpers.js"; import { parseNdjsonLines } from "../../helpers/ndjson.js"; import { - checkMutableMessagesSupport, + SHOULD_SKIP_MUTABLE_TESTS, + setupMutableMessagesRule, + teardownMutableMessagesRule, + getMutableChannelName, publishAndGetSerial, } from "../../helpers/e2e-mutable-messages.js"; -// Check if the E2E test app supports mutable messages (required for update/append/delete) -const mutableMessagesSupported = SHOULD_SKIP_E2E - ? false - : await checkMutableMessagesSupport(); - -describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( +describe.skipIf(SHOULD_SKIP_E2E || SHOULD_SKIP_MUTABLE_TESTS)( "Channel Message Operations E2E Tests", () => { let channelName: string; @@ -37,12 +34,16 @@ describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( beforeAll(async () => { process.on("SIGINT", forceExit); + // Create channel rule with mutableMessages enabled + await setupMutableMessagesRule(); + // Publish a test message and get its serial for use in all tests - channelName = getUniqueChannelName("msg-ops"); + channelName = getMutableChannelName("msg-ops"); messageSerial = await publishAndGetSerial(channelName, "test-msg"); }); - afterAll(() => { + afterAll(async () => { + await teardownMutableMessagesRule(); process.removeListener("SIGINT", forceExit); }); @@ -123,7 +124,7 @@ describe.skipIf(SHOULD_SKIP_E2E || !mutableMessagesSupported)( setupTestFailureHandler("should delete a message via channels delete"); // Publish a fresh message to delete (so we don't conflict with update/append tests) - const deleteChannel = getUniqueChannelName("msg-delete"); + const deleteChannel = getMutableChannelName("msg-delete"); const serial = await publishAndGetSerial(deleteChannel, "to-delete"); const result = await runCommand( diff --git a/test/helpers/e2e-mutable-messages.ts b/test/helpers/e2e-mutable-messages.ts index dfe60224..86f63b20 100644 --- a/test/helpers/e2e-mutable-messages.ts +++ b/test/helpers/e2e-mutable-messages.ts @@ -1,78 +1,115 @@ +import { randomUUID } from "node:crypto"; import { runCommand } from "./command-helpers.js"; import { parseNdjsonLines } from "./ndjson.js"; -import { E2E_API_KEY, getUniqueChannelName } from "./e2e-test-helper.js"; +import { E2E_API_KEY, E2E_ACCESS_TOKEN } from "./e2e-test-helper.js"; +export { SHOULD_SKIP_CONTROL_E2E as SHOULD_SKIP_MUTABLE_TESTS } from "./e2e-test-helper.js"; + +/** Namespace prefix for mutable message test channels. */ +export const MUTABLE_NAMESPACE = "e2e-mutable"; + +/** Track whether the rule was created so teardown knows whether to clean up. */ +let ruleCreated = false; + +function getAppId(): string { + if (!E2E_API_KEY) throw new Error("E2E_API_KEY is not set"); + return E2E_API_KEY.split(".")[0] || ""; +} /** - * Probe whether the E2E test app supports mutable messages by attempting - * a message update on a temporary channel. Returns false if error 93002 - * (mutableMessages not enabled) is returned. + * Create a channel rule (namespace) with mutableMessages enabled. + * Call in beforeAll. Handles "already exists" gracefully. */ -export async function checkMutableMessagesSupport(): Promise { - const probeChannel = getUniqueChannelName("mutable-probe"); +export async function setupMutableMessagesRule(): Promise { + const appId = getAppId(); - // Publish a message first - const pubResult = await runCommand( - ["channels", "publish", probeChannel, "probe", "--json"], + const result = await runCommand( + [ + "apps", + "rules", + "create", + "--name", + MUTABLE_NAMESPACE, + "--mutable-messages", + "--app", + appId, + "--json", + ], { - env: { ABLY_API_KEY: E2E_API_KEY || "" }, - timeoutMs: 15000, + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + timeoutMs: 30000, }, ); - if (pubResult.exitCode !== 0) return false; - // Get the serial from history - for (let attempt = 0; attempt < 5; attempt++) { - const histResult = await runCommand( - ["channels", "history", probeChannel, "--json", "--limit", "1"], + if (result.exitCode === 0) { + ruleCreated = true; + } else { + // Rule may already exist from a previous run — try to verify + const listResult = await runCommand( + ["apps", "rules", "list", "--app", appId, "--json"], { - env: { ABLY_API_KEY: E2E_API_KEY || "" }, - timeoutMs: 15000, + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + timeoutMs: 30000, }, ); - if (histResult.exitCode !== 0) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - continue; - } - const records = parseNdjsonLines(histResult.stdout); - const result = records.find((r) => r.type === "result"); - const messages = result?.messages as Array<{ serial?: string }> | undefined; - if (!messages?.[0]?.serial) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - continue; + const records = parseNdjsonLines(listResult.stdout); + const resultRecord = records.find((r) => r.type === "result"); + const rules = (resultRecord?.rules ?? []) as Array<{ + id?: string; + mutableMessages?: boolean; + }>; + const existing = rules.find( + (r) => r.id === MUTABLE_NAMESPACE && r.mutableMessages === true, + ); + + if (existing) { + ruleCreated = true; + } else { + throw new Error( + `Failed to create mutable messages rule: exitCode=${result.exitCode}, stderr=${result.stderr}`, + ); } + } - // Try to update the message — if it fails with 93002, mutable messages isn't enabled - const updateResult = await runCommand( - [ - "channels", - "update", - probeChannel, - messages[0].serial, - "updated-probe", - "--json", - ], - { - env: { ABLY_API_KEY: E2E_API_KEY || "" }, - timeoutMs: 15000, - }, - ); + // Brief delay for rule propagation + await new Promise((resolve) => setTimeout(resolve, 2000)); +} - if (updateResult.exitCode === 0) return true; +/** + * Delete the channel rule created by setupMutableMessagesRule(). + * Call in afterAll. Ignores "not found" errors. + */ +export async function teardownMutableMessagesRule(): Promise { + if (!ruleCreated) return; - // Check for error code 93002 - const errorRecords = parseNdjsonLines(updateResult.stdout); - const errorRecord = errorRecords.find((r) => r.type === "error"); - const error = errorRecord?.error as { code?: number } | undefined; - if (error?.code === 93002) { - return false; - } + const appId = getAppId(); - // Some other error — assume not supported - return false; - } + await runCommand( + [ + "apps", + "rules", + "delete", + MUTABLE_NAMESPACE, + "--app", + appId, + "--force", + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + timeoutMs: 30000, + }, + ); - return false; + ruleCreated = false; +} + +/** + * Generate a channel name under the mutable namespace. + * Format: "e2e-mutable:-" — matches the namespace rule. + */ +export function getMutableChannelName(suffix: string): string { + return `${MUTABLE_NAMESPACE}:${suffix}-${randomUUID().slice(0, 8)}`; } /** From a773a17468602d73527152a174a7b0b31e6b6fa0 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 9 Apr 2026 14:37:58 +0100 Subject: [PATCH 5/5] Add JSON output validation to E2E tests that were only checking exit code 13 E2E tests passed --json but only asserted exitCode === 0, ignoring the response body entirely. Added parseNdjsonLines + assertions for the JSON envelope (success, type) and domain-specific fields (arrays, pagination, nested objects) across spaces, rooms, logs, integrations, and config tests. --- test/e2e/config/config-e2e.test.ts | 9 ++++ .../e2e/integrations/integrations-e2e.test.ts | 8 ++++ test/e2e/logs/logs-e2e.test.ts | 22 +++++++++ test/e2e/rooms/rooms-list-e2e.test.ts | 17 +++++++ test/e2e/spaces/spaces-crud-e2e.test.ts | 31 +++++++++++++ test/e2e/spaces/spaces-locations-e2e.test.ts | 45 +++++++++++++++++++ test/e2e/spaces/spaces-occupancy-e2e.test.ts | 15 +++++++ 7 files changed, 147 insertions(+) diff --git a/test/e2e/config/config-e2e.test.ts b/test/e2e/config/config-e2e.test.ts index 9e756822..33d923c8 100644 --- a/test/e2e/config/config-e2e.test.ts +++ b/test/e2e/config/config-e2e.test.ts @@ -58,6 +58,15 @@ describe("Config E2E Tests", () => { // Same as above — just verify it doesn't crash expect([0, 1]).toContain(result.exitCode); + + // Validate JSON structure — both success and error produce valid JSON + const lines = result.stdout + .trim() + .split("\n") + .filter((l) => l.trim().startsWith("{")); + expect(lines.length).toBeGreaterThan(0); + const json = JSON.parse(lines[0]); + expect(json).toHaveProperty("type"); }); }); diff --git a/test/e2e/integrations/integrations-e2e.test.ts b/test/e2e/integrations/integrations-e2e.test.ts index c24465be..c1de928c 100644 --- a/test/e2e/integrations/integrations-e2e.test.ts +++ b/test/e2e/integrations/integrations-e2e.test.ts @@ -83,6 +83,14 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { ); expect(listResult.exitCode).toBe(0); + + const listRecords = parseNdjsonLines(listResult.stdout); + const listRecord = listRecords.find((r) => r.type === "result"); + expect(listRecord).toBeDefined(); + expect(listRecord!.success).toBe(true); + expect(Array.isArray(listRecord!.integrations)).toBe(true); + expect(listRecord).toHaveProperty("appId"); + expect(listRecord).toHaveProperty("total"); }); it( diff --git a/test/e2e/logs/logs-e2e.test.ts b/test/e2e/logs/logs-e2e.test.ts index c1c2941b..f3896fbd 100644 --- a/test/e2e/logs/logs-e2e.test.ts +++ b/test/e2e/logs/logs-e2e.test.ts @@ -22,6 +22,7 @@ import { cleanupRunners, } from "../../helpers/command-helpers.js"; import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Logs E2E Tests", () => { beforeAll(() => { @@ -51,6 +52,13 @@ describe.skipIf(SHOULD_SKIP_E2E)("Logs E2E Tests", () => { // Should succeed even if empty expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(Array.isArray(resultRecord!.messages)).toBe(true); + expect(resultRecord).toHaveProperty("hasMore"); }); }); @@ -163,6 +171,13 @@ describe.skipIf(SHOULD_SKIP_E2E)("Logs E2E Tests", () => { ); expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(Array.isArray(resultRecord!.messages)).toBe(true); + expect(resultRecord).toHaveProperty("hasMore"); }); }); @@ -177,6 +192,13 @@ describe.skipIf(SHOULD_SKIP_E2E)("Logs E2E Tests", () => { // Should succeed even if empty expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(Array.isArray(resultRecord!.messages)).toBe(true); + expect(resultRecord).toHaveProperty("hasMore"); }); it("should subscribe to push logs", { timeout: 60000 }, async () => { diff --git a/test/e2e/rooms/rooms-list-e2e.test.ts b/test/e2e/rooms/rooms-list-e2e.test.ts index 480cadbc..6a6b56aa 100644 --- a/test/e2e/rooms/rooms-list-e2e.test.ts +++ b/test/e2e/rooms/rooms-list-e2e.test.ts @@ -16,6 +16,7 @@ import { resetTestTracking, } from "../../helpers/e2e-test-helper.js"; import { runCommand } from "../../helpers/command-helpers.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Rooms List E2E Tests", () => { beforeAll(() => { @@ -45,6 +46,14 @@ describe.skipIf(SHOULD_SKIP_E2E)("Rooms List E2E Tests", () => { // Should succeed even if the list is empty expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(Array.isArray(resultRecord!.rooms)).toBe(true); + expect(resultRecord).toHaveProperty("total"); + expect(resultRecord).toHaveProperty("hasMore"); }); it("should list rooms with limit", async () => { @@ -59,6 +68,14 @@ describe.skipIf(SHOULD_SKIP_E2E)("Rooms List E2E Tests", () => { ); expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(Array.isArray(resultRecord!.rooms)).toBe(true); + expect(resultRecord).toHaveProperty("total"); + expect(resultRecord).toHaveProperty("hasMore"); }); }); }); diff --git a/test/e2e/spaces/spaces-crud-e2e.test.ts b/test/e2e/spaces/spaces-crud-e2e.test.ts index babb9422..fd522d4e 100644 --- a/test/e2e/spaces/spaces-crud-e2e.test.ts +++ b/test/e2e/spaces/spaces-crud-e2e.test.ts @@ -23,6 +23,7 @@ import { cleanupRunners, } from "../../helpers/command-helpers.js"; import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Spaces CRUD E2E Tests", () => { beforeAll(() => { @@ -59,6 +60,14 @@ describe.skipIf(SHOULD_SKIP_E2E)("Spaces CRUD E2E Tests", () => { ); expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + const space = resultRecord!.space as { name: string }; + expect(space).toBeDefined(); + expect(space.name).toBe(spaceName); }); }); @@ -73,6 +82,14 @@ describe.skipIf(SHOULD_SKIP_E2E)("Spaces CRUD E2E Tests", () => { // Should succeed even if the list is empty expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(Array.isArray(resultRecord!.spaces)).toBe(true); + expect(resultRecord).toHaveProperty("total"); + expect(resultRecord).toHaveProperty("hasMore"); }); }); @@ -112,6 +129,20 @@ describe.skipIf(SHOULD_SKIP_E2E)("Spaces CRUD E2E Tests", () => { ); expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + const space = resultRecord!.space as { + name: string; + members: Array<{ clientId: string }>; + }; + expect(space).toBeDefined(); + expect(space.name).toBe(spaceName); + expect(Array.isArray(space.members)).toBe(true); + expect(space.members.length).toBeGreaterThan(0); + expect(space.members[0].clientId).toBe(clientId); } finally { if (member) { await cleanupRunners([member]); diff --git a/test/e2e/spaces/spaces-locations-e2e.test.ts b/test/e2e/spaces/spaces-locations-e2e.test.ts index 85e31934..1f77fe6b 100644 --- a/test/e2e/spaces/spaces-locations-e2e.test.ts +++ b/test/e2e/spaces/spaces-locations-e2e.test.ts @@ -24,6 +24,7 @@ import { cleanupRunners, } from "../../helpers/command-helpers.js"; import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)( "Spaces Locations, Cursors, and Locks E2E Tests", @@ -93,6 +94,19 @@ describe.skipIf(SHOULD_SKIP_E2E)( ); expect(getResult.exitCode).toBe(0); + + const records = parseNdjsonLines(getResult.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + const locations = resultRecord!.locations as Array<{ + connectionId: string; + location: unknown; + }>; + expect(locations).toBeDefined(); + expect(locations.length).toBeGreaterThan(0); + expect(locations[0]).toHaveProperty("connectionId"); + expect(locations[0]).toHaveProperty("location"); } finally { if (locationRunner) { await cleanupRunners([locationRunner]); @@ -172,6 +186,19 @@ describe.skipIf(SHOULD_SKIP_E2E)( ); expect(getResult.exitCode).toBe(0); + + const cursorRecords = parseNdjsonLines(getResult.stdout); + const cursorResult = cursorRecords.find((r) => r.type === "result"); + expect(cursorResult).toBeDefined(); + expect(cursorResult!.success).toBe(true); + const cursors = cursorResult!.cursors as Array<{ + position: { x: number; y: number }; + }>; + expect(cursors).toBeDefined(); + expect(cursors.length).toBeGreaterThan(0); + expect(cursors[0].position).toBeDefined(); + expect(typeof cursors[0].position.x).toBe("number"); + expect(typeof cursors[0].position.y).toBe("number"); } finally { const runners: CliRunner[] = []; if (subscriberRunner) runners.push(subscriberRunner); @@ -224,6 +251,24 @@ describe.skipIf(SHOULD_SKIP_E2E)( ); expect(getResult.exitCode).toBe(0); + + const lockRecords = parseNdjsonLines(getResult.stdout); + const lockResult = lockRecords.find((r) => r.type === "result"); + expect(lockResult).toBeDefined(); + expect(lockResult!.success).toBe(true); + const locks = lockResult!.locks as Array<{ + id: string; + status: string; + member: { clientId: string }; + timestamp: string; + }>; + expect(locks).toBeDefined(); + expect(locks.length).toBeGreaterThan(0); + expect(locks[0].id).toBe("test-lock-1"); + expect(locks[0].status).toBe("locked"); + expect(locks[0].member).toBeDefined(); + expect(locks[0].member.clientId).toBe(clientId); + expect(locks[0].timestamp).toBeDefined(); } finally { if (lockRunner) { await cleanupRunners([lockRunner]); diff --git a/test/e2e/spaces/spaces-occupancy-e2e.test.ts b/test/e2e/spaces/spaces-occupancy-e2e.test.ts index b6c70d86..74287950 100644 --- a/test/e2e/spaces/spaces-occupancy-e2e.test.ts +++ b/test/e2e/spaces/spaces-occupancy-e2e.test.ts @@ -23,6 +23,7 @@ import { cleanupRunners, } from "../../helpers/command-helpers.js"; import type { CliRunner } from "../../helpers/cli-runner.js"; +import { parseNdjsonLines } from "../../helpers/ndjson.js"; describe.skipIf(SHOULD_SKIP_E2E)("Spaces Occupancy E2E Tests", () => { beforeAll(() => { @@ -85,6 +86,20 @@ describe.skipIf(SHOULD_SKIP_E2E)("Spaces Occupancy E2E Tests", () => { ); expect(result.exitCode).toBe(0); + + const records = parseNdjsonLines(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + expect(resultRecord).toBeDefined(); + expect(resultRecord!.success).toBe(true); + const occupancy = resultRecord!.occupancy as { + spaceName: string; + metrics: { connections: number; presenceMembers: number }; + }; + expect(occupancy).toBeDefined(); + expect(occupancy.spaceName).toBe(spaceName); + expect(occupancy.metrics).toBeDefined(); + expect(typeof occupancy.metrics.connections).toBe("number"); + expect(typeof occupancy.metrics.presenceMembers).toBe("number"); } finally { if (memberRunner) { await cleanupRunners([memberRunner]);