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/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..8d5898cb --- /dev/null +++ b/test/e2e/spaces/spaces-locations-e2e.test.ts @@ -0,0 +1,207 @@ +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 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 cursorRunner: CliRunner | null = null; + + try { + // Set cursor position (long-running hold command) + cursorRunner = await startPresenceCommand( + [ + "spaces", + "cursors", + "set", + spaceName, + "--x", + "10", + "--y", + "20", + "--client-id", + clientId, + "--duration", + "15", + ], + /Holding|Set cursor|cursor/i, + { + env: { ABLY_API_KEY: E2E_API_KEY || "" }, + timeoutMs: 30000, + }, + ); + + // Wait for cursor to propagate + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // 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 { + if (cursorRunner) { + await cleanupRunners([cursorRunner]); + } + } + }); + }); + + 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/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 () {