From 649fd08dc9c82e410be5de879cd8c776cf6ff7c0 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 18 Feb 2026 12:27:09 -0700 Subject: [PATCH] chore: E2E tests for Realtime (#42518) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Minor amount of tests for Realtime because we have zero E2E coverage here ## Summary by CodeRabbit * **Tests** * Added comprehensive end-to-end test coverage for the Realtime Inspector: channel join/leave, start/stop listening, broadcast workflows, message display, JSON validation, and cleanup to improve reliability. * Added supporting test helpers/utilities to enable deterministic navigation, channel operations, message waiting, and modal interactions for stable test execution. --- .../features/realtime-inspector.spec.ts | 153 ++++++++++++++++++ e2e/studio/utils/realtime-helpers.ts | 69 ++++++++ 2 files changed, 222 insertions(+) create mode 100644 e2e/studio/features/realtime-inspector.spec.ts create mode 100644 e2e/studio/utils/realtime-helpers.ts diff --git a/e2e/studio/features/realtime-inspector.spec.ts b/e2e/studio/features/realtime-inspector.spec.ts new file mode 100644 index 00000000000..64737faa4a7 --- /dev/null +++ b/e2e/studio/features/realtime-inspector.spec.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/test.js' +import { + getMessageCount, + joinChannel, + leaveChannel, + navigateToRealtimeInspector, + openBroadcastModal, + startListening, + stopListening, + waitForRealtimeMessage, +} from '../utils/realtime-helpers.js' + +const testChannelName = 'pw_realtime_test_channel' + +test.describe('Realtime Inspector', () => { + test.beforeEach(async ({ page, ref }) => { + await navigateToRealtimeInspector(page, ref) + }) + + test.describe('Basic Inspector UI', () => { + test('inspector page loads correctly with empty state', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible() + + const startButton = page.getByRole('button', { name: 'Start listening' }) + await expect(startButton).toBeVisible() + await expect(startButton).toBeDisabled() + + await expect(page.getByText('Create realtime experiences')).toBeVisible() + }) + + test('channel selection popover opens and works', async ({ page }) => { + await page.getByRole('button', { name: 'Join a channel' }).click() + + await expect(page.getByPlaceholder('Enter a channel name')).toBeVisible({ timeout: 5000 }) + await expect(page.getByRole('button', { name: 'Listen to channel' })).toBeVisible() + await expect(page.getByText('Is channel private?')).toBeVisible() + + await page.keyboard.press('Escape') + }) + + test('can join and leave a channel', async ({ page }) => { + await joinChannel(page, testChannelName) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('button', { name: `Channel: ${testChannelName}` })).toBeVisible() + + await leaveChannel(page) + + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible() + }) + + test('start/stop listening button works', async ({ page }) => { + await joinChannel(page, testChannelName) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('button', { name: 'Stop listening' })).toBeVisible() + + await stopListening(page) + + await expect(page.getByRole('button', { name: 'Start listening' })).toBeVisible() + await expect(page.getByText('Listening', { exact: true })).not.toBeVisible() + + await startListening(page) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + + await leaveChannel(page) + }) + }) + + test.describe('Broadcast Messages', () => { + test('broadcast messages appear in the UI when listening', async ({ page }) => { + await joinChannel(page, testChannelName) + + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) + + await openBroadcastModal(page) + await page.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByText('Successfully broadcasted message')).toBeVisible({ + timeout: 10000, + }) + + const messageRow = await waitForRealtimeMessage(page, { timeout: 30000 }) + await expect(messageRow).toBeVisible() + + const count = await getMessageCount(page) + expect(count).toBeGreaterThanOrEqual(1) + + await leaveChannel(page) + }) + + test('clicking broadcast message shows detail panel', async ({ page }) => { + await joinChannel(page, testChannelName) + + await openBroadcastModal(page) + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByText('Successfully broadcasted message')).toBeVisible({ + timeout: 10000, + }) + await waitForRealtimeMessage(page, { timeout: 30000 }) + + const messageRow = page.getByRole('row').filter({ hasText: 'broadcast' }).first() + await expect(messageRow).toBeVisible({ timeout: 5000 }) + await messageRow.click() + + await expect(page.getByText('Timestamp')).toBeVisible({ timeout: 5000 }) + + await leaveChannel(page) + }) + + test('broadcast modal validates JSON payload', async ({ page }) => { + await joinChannel(page, testChannelName) + + await openBroadcastModal(page) + + const codeEditor = page.getByRole('textbox', { name: /Editor content/i }) + await codeEditor.click({ force: true }) + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.type('{ invalid json }') + + await page.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByText('Please provide a valid JSON')).toBeVisible({ timeout: 5000 }) + + await page.getByRole('button', { name: 'Cancel' }).click() + + await leaveChannel(page) + }) + }) + + test.describe('Message Display', () => { + test('messages counter shows correct count', async ({ page }) => { + await joinChannel(page, `${testChannelName}_counter`) + + const initialCount = await getMessageCount(page) + + await openBroadcastModal(page) + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByText('Successfully broadcasted message')).toBeVisible({ + timeout: 10000, + }) + + await waitForRealtimeMessage(page, { timeout: 30000 }) + + const newCount = await getMessageCount(page) + expect(newCount).toBeGreaterThan(initialCount) + + await leaveChannel(page) + }) + }) +}) diff --git a/e2e/studio/utils/realtime-helpers.ts b/e2e/studio/utils/realtime-helpers.ts new file mode 100644 index 00000000000..e1729f2c8f7 --- /dev/null +++ b/e2e/studio/utils/realtime-helpers.ts @@ -0,0 +1,69 @@ +import { expect, Page } from '@playwright/test' +import { toUrl } from './to-url.js' + +export async function navigateToRealtimeInspector(page: Page, ref: string) { + await page.goto(toUrl(`/project/${ref}/realtime/inspector`)) + await expect(page.locator('text=Join a channel')).toBeVisible({ timeout: 30000 }) +} + +export async function joinChannel(page: Page, channelName: string) { + await page.getByRole('button', { name: /Join a channel|Channel:/ }).click() + await expect(page.getByPlaceholder('Enter a channel name')).toBeVisible({ timeout: 5000 }) + await page.getByPlaceholder('Enter a channel name').fill(channelName) + await page.getByRole('button', { name: 'Listen to channel' }).click() + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) +} + +export async function leaveChannel(page: Page) { + await page.getByRole('button', { name: /Channel:/ }).click() + await expect(page.getByRole('button', { name: 'Leave channel' })).toBeVisible({ timeout: 5000 }) + await page.getByRole('button', { name: 'Leave channel' }).click() + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible({ timeout: 5000 }) +} + +export async function startListening(page: Page) { + const listenButton = page.getByRole('button', { name: 'Start listening' }) + await expect(listenButton).toBeVisible({ timeout: 5000 }) + await listenButton.click() + await expect(page.getByText('Listening', { exact: true })).toBeVisible({ timeout: 10000 }) +} + +export async function stopListening(page: Page) { + const stopButton = page.getByRole('button', { name: 'Stop listening' }) + await expect(stopButton).toBeVisible({ timeout: 5000 }) + await stopButton.click() + await expect(page.getByRole('button', { name: 'Start listening' })).toBeVisible({ timeout: 5000 }) +} + +export async function openBroadcastModal(page: Page) { + const broadcastButton = page.getByRole('button', { name: 'Broadcast a message' }) + await expect(broadcastButton).toBeVisible({ timeout: 5000 }) + await broadcastButton.click() + await expect(page.getByText('Broadcast a message to all clients')).toBeVisible({ timeout: 5000 }) +} + +export async function waitForRealtimeMessage(page: Page, options?: { timeout?: number }) { + const timeout = options?.timeout ?? 30000 + const gridRow = page.getByRole('row').filter({ hasText: /^\d{4}-\d{2}-\d{2}/ }).first() + await expect(gridRow).toBeVisible({ timeout }) + return gridRow +} + +export async function getMessageCount(page: Page): Promise { + const countText = page.locator('text=/Found \\d+ messages/') + if ((await countText.count()) === 0) { + const noMessages = page.locator('text=No message found yet') + if ((await noMessages.count()) > 0) { + return 0 + } + const maxMessages = page.locator('text=/showing only the latest 100/') + if ((await maxMessages.count()) > 0) { + return 100 + } + return 0 + } + + const text = await countText.textContent() + const match = text?.match(/Found (\d+) messages/) + return match ? parseInt(match[1], 10) : 0 +}